ito_templates/
project_templates.rs

1//! Jinja2 rendering for project templates (AGENTS.md, skills).
2//!
3//! Project templates may contain `minijinja` syntax (`{% ... %}` / `{{ ... }}`)
4//! that gets rendered with a [`WorktreeTemplateContext`](crate::project_templates::WorktreeTemplateContext) before being written
5//! to disk. Templates without Jinja2 syntax are returned unchanged.
6
7use serde::Serialize;
8
9use crate::instructions::render_template_str;
10
11/// Context for rendering worktree-aware project templates.
12///
13/// This carries the resolved worktree configuration values. Templates use
14/// these fields in `{% if %}` / `{{ }}` blocks to emit strategy-specific
15/// instructions.
16#[derive(Debug, Clone, Serialize)]
17pub struct WorktreeTemplateContext {
18    /// Whether worktrees are enabled.
19    pub enabled: bool,
20    /// Strategy name (e.g., `"checkout_subdir"`, `"checkout_siblings"`,
21    /// `"bare_control_siblings"`). Empty string when disabled.
22    pub strategy: String,
23    /// Directory name for worktree layouts (e.g., `"ito-worktrees"`).
24    pub layout_dir_name: String,
25    /// Integration mode (e.g., `"commit_pr"`, `"merge_parent"`).
26    /// Empty string when disabled.
27    pub integration_mode: String,
28    /// Default branch name (e.g., `"main"`).
29    pub default_branch: String,
30    /// Absolute path to the project root. Empty string when not resolved.
31    pub project_root: String,
32}
33
34impl Default for WorktreeTemplateContext {
35    /// Creates a WorktreeTemplateContext initialized with safe defaults for a disabled worktree setup.
36    ///
37    /// Defaults:
38    /// - `enabled`: false
39    /// - `strategy`: empty string
40    /// - `layout_dir_name`: "ito-worktrees"
41    /// - `integration_mode`: empty string
42    /// - `default_branch`: "main"
43    /// - `project_root`: empty string
44    ///
45    /// # Examples
46    ///
47    /// ```
48    /// use ito_templates::project_templates::WorktreeTemplateContext;
49    /// let ctx = WorktreeTemplateContext::default();
50    /// assert!(!ctx.enabled);
51    /// assert_eq!(ctx.layout_dir_name, "ito-worktrees");
52    /// assert_eq!(ctx.default_branch, "main");
53    /// assert!(ctx.project_root.is_empty());
54    /// ```
55    fn default() -> Self {
56        Self {
57            enabled: false,
58            strategy: String::new(),
59            layout_dir_name: "ito-worktrees".to_string(),
60            integration_mode: String::new(),
61            default_branch: "main".to_string(),
62            project_root: String::new(),
63        }
64    }
65}
66
67/// Render a project template with the given worktree context.
68///
69/// If the template bytes are not valid UTF-8 or do not contain Jinja2 syntax
70/// (`{%` or `{{`), the bytes are returned unchanged. Otherwise the template is
71/// rendered through `minijinja` with `ctx` as the context.
72///
73/// # Errors
74///
75/// Returns a `minijinja::Error` if the template contains Jinja2 syntax but
76/// fails to render (e.g., undefined variable in strict mode).
77pub fn render_project_template(
78    template_bytes: &[u8],
79    ctx: &WorktreeTemplateContext,
80) -> Result<Vec<u8>, minijinja::Error> {
81    let Ok(text) = std::str::from_utf8(template_bytes) else {
82        return Ok(template_bytes.to_vec());
83    };
84
85    if !contains_jinja2_syntax(text) {
86        return Ok(template_bytes.to_vec());
87    }
88
89    let rendered = render_template_str(text, ctx)?;
90    Ok(rendered.into_bytes())
91}
92
93/// Check whether a string contains Jinja2 template syntax.
94fn contains_jinja2_syntax(text: &str) -> bool {
95    text.contains("{%") || text.contains("{{")
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn render_project_template_passes_plain_text_through() {
104        let bytes = b"Hello, this is plain text.";
105        let ctx = WorktreeTemplateContext::default();
106        let result = render_project_template(bytes, &ctx).unwrap();
107        assert_eq!(result, bytes);
108    }
109
110    #[test]
111    fn render_project_template_passes_non_utf8_through() {
112        let bytes = [0xff, 0x00, 0x41];
113        let ctx = WorktreeTemplateContext::default();
114        let result = render_project_template(&bytes, &ctx).unwrap();
115        assert_eq!(result, bytes);
116    }
117
118    #[test]
119    fn render_project_template_renders_simple_variable() {
120        let template = b"Strategy: {{ strategy }}";
121        let ctx = WorktreeTemplateContext {
122            strategy: "checkout_subdir".to_string(),
123            ..Default::default()
124        };
125        let result = render_project_template(template, &ctx).unwrap();
126        assert_eq!(
127            String::from_utf8(result).unwrap(),
128            "Strategy: checkout_subdir"
129        );
130    }
131
132    #[test]
133    fn render_project_template_renders_conditional() {
134        let template = b"{% if enabled %}Worktrees ON{% else %}Worktrees OFF{% endif %}";
135        let ctx_enabled = WorktreeTemplateContext {
136            enabled: true,
137            strategy: "checkout_subdir".to_string(),
138            ..Default::default()
139        };
140        let ctx_disabled = WorktreeTemplateContext::default();
141
142        let on = render_project_template(template, &ctx_enabled).unwrap();
143        assert_eq!(String::from_utf8(on).unwrap(), "Worktrees ON");
144
145        let off = render_project_template(template, &ctx_disabled).unwrap();
146        assert_eq!(String::from_utf8(off).unwrap(), "Worktrees OFF");
147    }
148
149    #[test]
150    fn render_project_template_strict_on_undefined() {
151        let template = b"{{ missing_var }}";
152        let ctx = WorktreeTemplateContext::default();
153        let err = render_project_template(template, &ctx).unwrap_err();
154        assert_eq!(err.kind(), minijinja::ErrorKind::UndefinedError);
155    }
156
157    #[test]
158    fn default_context_is_disabled() {
159        let ctx = WorktreeTemplateContext::default();
160        assert!(!ctx.enabled);
161        assert!(ctx.strategy.is_empty());
162        assert!(ctx.integration_mode.is_empty());
163        assert_eq!(ctx.layout_dir_name, "ito-worktrees");
164        assert_eq!(ctx.default_branch, "main");
165        assert!(ctx.project_root.is_empty());
166    }
167
168    #[test]
169    fn render_agents_md_with_checkout_subdir() {
170        let agents_md = crate::default_project_files()
171            .into_iter()
172            .find(|f| f.relative_path == "AGENTS.md")
173            .expect("AGENTS.md should exist in project templates");
174
175        let ctx = WorktreeTemplateContext {
176            enabled: true,
177            strategy: "checkout_subdir".to_string(),
178            layout_dir_name: "ito-worktrees".to_string(),
179            integration_mode: "commit_pr".to_string(),
180            default_branch: "main".to_string(),
181            project_root: "/home/user/project".to_string(),
182        };
183        let rendered = render_project_template(agents_md.contents, &ctx).unwrap();
184        let text = String::from_utf8(rendered).unwrap();
185
186        assert!(text.contains("## Worktree Workflow"));
187        assert!(text.contains("**Strategy:** `checkout_subdir`"));
188        assert!(
189            text.contains("git worktree add \".ito-worktrees/<change-name>\" -b <change-name>")
190        );
191        assert!(
192            text.contains(".ito-worktrees/<change-name>/"),
193            "should contain repo-relative worktree path"
194        );
195        assert!(
196            !text.contains(&ctx.project_root),
197            "should not embed machine-specific absolute project_root"
198        );
199    }
200
201    #[test]
202    fn render_agents_md_with_checkout_siblings() {
203        let agents_md = crate::default_project_files()
204            .into_iter()
205            .find(|f| f.relative_path == "AGENTS.md")
206            .expect("AGENTS.md should exist in project templates");
207
208        let ctx = WorktreeTemplateContext {
209            enabled: true,
210            strategy: "checkout_siblings".to_string(),
211            layout_dir_name: "worktrees".to_string(),
212            integration_mode: "merge_parent".to_string(),
213            default_branch: "develop".to_string(),
214            project_root: "/home/user/project".to_string(),
215        };
216        let rendered = render_project_template(agents_md.contents, &ctx).unwrap();
217        let text = String::from_utf8(rendered).unwrap();
218
219        assert!(text.contains("**Strategy:** `checkout_siblings`"));
220        assert!(text.contains(
221            "git worktree add \"../<project-name>-worktrees/<change-name>\" -b <change-name>"
222        ));
223        assert!(
224            text.contains("../<project-name>-worktrees/<change-name>/"),
225            "should contain repo-relative sibling worktree path"
226        );
227        assert!(
228            !text.contains(&ctx.project_root),
229            "should not embed machine-specific absolute project_root"
230        );
231    }
232
233    #[test]
234    fn render_agents_md_with_bare_control_siblings() {
235        let agents_md = crate::default_project_files()
236            .into_iter()
237            .find(|f| f.relative_path == "AGENTS.md")
238            .expect("AGENTS.md should exist in project templates");
239
240        let ctx = WorktreeTemplateContext {
241            enabled: true,
242            strategy: "bare_control_siblings".to_string(),
243            layout_dir_name: "ito-worktrees".to_string(),
244            integration_mode: "commit_pr".to_string(),
245            default_branch: "main".to_string(),
246            project_root: "/home/user/project".to_string(),
247        };
248        let rendered = render_project_template(agents_md.contents, &ctx).unwrap();
249        let text = String::from_utf8(rendered).unwrap();
250
251        assert!(text.contains("**Strategy:** `bare_control_siblings`"));
252        assert!(text.contains(".bare/"));
253        assert!(text.contains("ito-worktrees/"));
254        let layout_line = text
255            .lines()
256            .find(|l| l.contains("# bare/control repo"))
257            .expect("should contain bare/control repo layout line");
258        assert!(
259            layout_line.contains("../"),
260            "should contain repo-relative bare/control layout"
261        );
262        assert!(
263            !text.contains(&ctx.project_root),
264            "should not embed machine-specific absolute project_root"
265        );
266    }
267
268    #[test]
269    fn render_agents_md_with_worktrees_disabled() {
270        let agents_md = crate::default_project_files()
271            .into_iter()
272            .find(|f| f.relative_path == "AGENTS.md")
273            .expect("AGENTS.md should exist in project templates");
274
275        let ctx = WorktreeTemplateContext::default();
276        let rendered = render_project_template(agents_md.contents, &ctx).unwrap();
277        let text = String::from_utf8(rendered).unwrap();
278
279        assert!(text.contains("Worktrees are not configured for this project."));
280        assert!(text.contains("Do NOT create git worktrees by default."));
281    }
282}