ito_templates/
project_templates.rs1use serde::Serialize;
8
9use crate::instructions::render_template_str;
10
11#[derive(Debug, Clone, Serialize)]
17pub struct WorktreeTemplateContext {
18 pub enabled: bool,
20 pub strategy: String,
23 pub layout_dir_name: String,
25 pub integration_mode: String,
28 pub default_branch: String,
30 pub project_root: String,
32}
33
34impl Default for WorktreeTemplateContext {
35 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
67pub 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
93fn 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}