1use include_dir::{Dir, include_dir};
4use minijinja::{Environment, UndefinedBehavior};
5use serde::Serialize;
6
7static INSTRUCTIONS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/instructions");
8
9pub fn list_instruction_templates() -> Vec<&'static str> {
11 let mut out = Vec::new();
12 collect_paths(&INSTRUCTIONS_DIR, &mut out);
13 out.sort_unstable();
14 out
15}
16
17pub fn get_instruction_template_bytes(path: &str) -> Option<&'static [u8]> {
19 INSTRUCTIONS_DIR.get_file(path).map(|f| f.contents())
20}
21
22pub fn get_instruction_template(path: &str) -> Option<&'static str> {
24 let bytes = get_instruction_template_bytes(path)?;
25 std::str::from_utf8(bytes).ok()
26}
27
28pub fn render_instruction_template<T: Serialize>(
30 path: &str,
31 ctx: &T,
32) -> Result<String, minijinja::Error> {
33 let template = get_instruction_template(path).ok_or_else(|| {
34 minijinja::Error::new(minijinja::ErrorKind::TemplateNotFound, path.to_string())
35 })?;
36 render_template_str(template, ctx)
37}
38
39pub fn render_template_str<T: Serialize>(
41 template: &str,
42 ctx: &T,
43) -> Result<String, minijinja::Error> {
44 let mut env = Environment::new();
45 env.set_undefined_behavior(UndefinedBehavior::Strict);
46
47 env.set_auto_escape_callback(|_name| minijinja::AutoEscape::None);
49
50 env.add_template("_inline", template)?;
51 env.get_template("_inline")?.render(ctx)
52}
53
54fn collect_paths(dir: &'static Dir<'static>, out: &mut Vec<&'static str>) {
55 for f in dir.files() {
56 if let Some(p) = f.path().to_str() {
57 out.push(p);
58 }
59 }
60 for d in dir.dirs() {
61 collect_paths(d, out);
62 }
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn render_template_str_renders_from_serialize_ctx() {
71 #[derive(Serialize)]
72 struct Ctx {
73 name: &'static str,
74 }
75
76 let out = render_template_str("hello {{ name }}", &Ctx { name: "world" }).unwrap();
77 assert_eq!(out, "hello world");
78 }
79
80 #[test]
81 fn render_template_str_is_strict_on_undefined() {
82 #[derive(Serialize)]
83 struct Ctx {}
84
85 let err = render_template_str("hello {{ missing }}", &Ctx {}).unwrap_err();
86 assert_eq!(err.kind(), minijinja::ErrorKind::UndefinedError);
87 }
88
89 #[test]
90 fn list_instruction_templates_is_sorted_and_non_empty() {
91 let templates = list_instruction_templates();
92 assert!(!templates.is_empty());
93
94 let mut sorted = templates.clone();
95 sorted.sort_unstable();
96 assert_eq!(templates, sorted);
97 }
98
99 #[test]
100 fn template_fetchers_work_for_known_and_unknown_paths() {
101 let templates = list_instruction_templates();
102 let known = *templates
103 .first()
104 .expect("expected at least one embedded instruction template");
105
106 let bytes = get_instruction_template_bytes(known);
107 assert!(bytes.is_some());
108
109 let text = get_instruction_template(known);
110 assert!(text.is_some());
111
112 assert_eq!(get_instruction_template_bytes("missing/template.md"), None);
113 assert_eq!(get_instruction_template("missing/template.md"), None);
114 }
115
116 #[test]
117 fn render_instruction_template_returns_not_found_for_missing_template() {
118 #[derive(Serialize)]
119 struct Ctx {}
120
121 let err = render_instruction_template("missing/template.md", &Ctx {}).unwrap_err();
122 assert_eq!(err.kind(), minijinja::ErrorKind::TemplateNotFound);
123 }
124
125 #[test]
126 fn review_template_renders_conditional_sections() {
127 #[derive(Serialize)]
128 struct Artifact {
129 id: &'static str,
130 path: &'static str,
131 present: bool,
132 }
133
134 #[derive(Serialize)]
135 struct ValidationIssue {
136 level: &'static str,
137 path: &'static str,
138 message: &'static str,
139 line: Option<u32>,
140 column: Option<u32>,
141 }
142
143 #[derive(Serialize)]
144 struct TaskSummary {
145 total: usize,
146 complete: usize,
147 in_progress: usize,
148 pending: usize,
149 shelved: usize,
150 wave_count: usize,
151 }
152
153 #[derive(Serialize)]
154 struct AffectedSpec {
155 spec_id: &'static str,
156 operation: &'static str,
157 description: &'static str,
158 }
159
160 #[derive(Serialize)]
161 struct TestingPolicy {
162 tdd_workflow: &'static str,
163 coverage_target_percent: u64,
164 }
165
166 #[derive(Serialize)]
167 struct Ctx {
168 change_name: &'static str,
169 change_dir: &'static str,
170 schema_name: &'static str,
171 module_id: &'static str,
172 module_name: &'static str,
173 artifacts: Vec<Artifact>,
174 validation_issues: Vec<ValidationIssue>,
175 validation_passed: bool,
176 task_summary: TaskSummary,
177 affected_specs: Vec<AffectedSpec>,
178 user_guidance: &'static str,
179 testing_policy: TestingPolicy,
180 generated_at: &'static str,
181 }
182
183 let ctx = Ctx {
184 change_name: "000-01_test-change",
185 change_dir: "/tmp/.ito/changes/000-01_test-change",
186 schema_name: "spec-driven",
187 module_id: "000_ungrouped",
188 module_name: "Ungrouped",
189 artifacts: vec![
190 Artifact {
191 id: "proposal",
192 path: "/tmp/.ito/changes/000-01_test-change/proposal.md",
193 present: true,
194 },
195 Artifact {
196 id: "design",
197 path: "/tmp/.ito/changes/000-01_test-change/design.md",
198 present: false,
199 },
200 Artifact {
201 id: "tasks",
202 path: "/tmp/.ito/changes/000-01_test-change/tasks.md",
203 present: true,
204 },
205 Artifact {
206 id: "specs",
207 path: "/tmp/.ito/changes/000-01_test-change/specs",
208 present: true,
209 },
210 ],
211 validation_issues: vec![ValidationIssue {
212 level: "warning",
213 path: ".ito/changes/000-01_test-change/tasks.md",
214 message: "sample warning",
215 line: Some(3),
216 column: Some(1),
217 }],
218 validation_passed: false,
219 task_summary: TaskSummary {
220 total: 4,
221 complete: 1,
222 in_progress: 1,
223 pending: 2,
224 shelved: 0,
225 wave_count: 2,
226 },
227 affected_specs: vec![AffectedSpec {
228 spec_id: "agent-instructions",
229 operation: "MODIFIED",
230 description: "Review routing",
231 }],
232 user_guidance: "Follow strict review format.",
233 testing_policy: TestingPolicy {
234 tdd_workflow: "red-green-refactor",
235 coverage_target_percent: 80,
236 },
237 generated_at: "2026-02-19T00:00:00Z",
238 };
239
240 let out = render_instruction_template("agent/review.md.j2", &ctx).unwrap();
241 assert!(out.contains("Peer Review"));
242 assert!(out.contains("## Proposal Review"));
243 assert!(out.contains("## Spec Review"));
244 assert!(out.contains("## Task Review"));
245 assert!(!out.contains("## Design Review"));
246 assert!(out.contains("## Testing Policy"));
247 assert!(out.contains("<user_guidance>"));
248 assert!(out.contains("## Output Format"));
249 assert!(out.contains("Verdict: needs-discussion"));
250 }
251}