ito_templates/
instructions.rs

1//! Embedded instruction template loading and rendering.
2
3use 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
9/// List all embedded instruction template paths.
10pub 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
17/// Fetch an embedded instruction template as raw bytes.
18pub fn get_instruction_template_bytes(path: &str) -> Option<&'static [u8]> {
19    INSTRUCTIONS_DIR.get_file(path).map(|f| f.contents())
20}
21
22/// Fetch an embedded instruction template as UTF-8 text.
23pub 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
28/// Render an instruction template by path using a serializable context.
29pub 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
39/// Render an arbitrary template string using a serializable context.
40pub 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    // Templates are markdown; we don't want any escaping.
48    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}