Skip to main content

edict/
template.rs

1//! Template rendering for docs, prompts, and AGENTS.md managed section.
2
3use minijinja::Environment;
4use serde::Serialize;
5
6use crate::config::{Config, ReviewConfig, ToolsConfig};
7
8const MANAGED_START: &str = "<!-- edict:managed-start -->";
9const MANAGED_END: &str = "<!-- edict:managed-end -->";
10/// Legacy markers from the botbox era — recognized on read, replaced with new markers on write.
11const MANAGED_START_LEGACY: &str = "<!-- botbox:managed-start -->";
12const MANAGED_END_LEGACY: &str = "<!-- botbox:managed-end -->";
13
14const AGENTS_MANAGED_TEMPLATE: &str = include_str!("templates/agents-managed.md.jinja");
15
16/// Context data passed to templates
17#[derive(Debug, Serialize)]
18pub struct TemplateContext {
19    /// Project configuration
20    pub project: ProjectInfo,
21    /// Tools configuration
22    pub tools: ToolsConfig,
23    /// Review configuration
24    pub review: ReviewConfig,
25    /// Install command (optional)
26    pub install_command: Option<String>,
27    /// Check command run before merging (optional)
28    pub check_command: Option<String>,
29    /// Workflow docs with descriptions
30    pub workflow_docs: Vec<DocEntry>,
31    /// Design docs with descriptions (filtered by project type)
32    pub design_docs: Vec<DocEntry>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct ProjectInfo {
37    pub name: String,
38    pub project_type: Vec<String>,
39    pub default_agent: Option<String>,
40    pub channel: Option<String>,
41}
42
43#[derive(Debug, Serialize, Clone)]
44pub struct DocEntry {
45    pub name: String,
46    pub description: String,
47}
48
49impl TemplateContext {
50    /// Build template context from project config
51    pub fn from_config(config: &Config) -> Self {
52        let workflow_docs = list_workflow_docs();
53        let design_docs = list_design_docs(&config.project.project_type);
54
55        Self {
56            project: ProjectInfo {
57                name: config.project.name.clone(),
58                project_type: config.project.project_type.clone(),
59                default_agent: config.project.default_agent.clone(),
60                channel: config.project.channel.clone(),
61            },
62            tools: config.tools.clone(),
63            review: config.review.clone(),
64            install_command: config.project.install_command.clone(),
65            check_command: config.project.check_command.clone(),
66            workflow_docs,
67            design_docs,
68        }
69    }
70}
71
72/// List all workflow docs with descriptions
73fn list_workflow_docs() -> Vec<DocEntry> {
74    vec![
75        DocEntry {
76            name: "triage.md".to_string(),
77            description: "Find work from inbox and bones".to_string(),
78        },
79        DocEntry {
80            name: "start.md".to_string(),
81            description: "Claim bone, create workspace, announce".to_string(),
82        },
83        DocEntry {
84            name: "update.md".to_string(),
85            description: "Change bone state (open/doing/done)".to_string(),
86        },
87        DocEntry {
88            name: "finish.md".to_string(),
89            description: "Close bone, merge workspace, release claims".to_string(),
90        },
91        DocEntry {
92            name: "worker-loop.md".to_string(),
93            description: "Full triage-work-finish lifecycle".to_string(),
94        },
95        DocEntry {
96            name: "planning.md".to_string(),
97            description: "Turn specs/PRDs into actionable bones".to_string(),
98        },
99        DocEntry {
100            name: "scout.md".to_string(),
101            description: "Explore unfamiliar code before planning".to_string(),
102        },
103        DocEntry {
104            name: "proposal.md".to_string(),
105            description: "Create and validate proposals before implementation".to_string(),
106        },
107        DocEntry {
108            name: "review-request.md".to_string(),
109            description: "Request a review".to_string(),
110        },
111        DocEntry {
112            name: "review-response.md".to_string(),
113            description: "Handle reviewer feedback (fix/address/defer)".to_string(),
114        },
115        DocEntry {
116            name: "review-loop.md".to_string(),
117            description: "Reviewer agent loop".to_string(),
118        },
119        DocEntry {
120            name: "merge-check.md".to_string(),
121            description: "Merge a worker workspace (protocol merge + conflict recovery)"
122                .to_string(),
123        },
124        DocEntry {
125            name: "preflight.md".to_string(),
126            description: "Validate toolchain health".to_string(),
127        },
128        DocEntry {
129            name: "cross-channel.md".to_string(),
130            description: "Ask questions, report bugs, and track responses across projects"
131                .to_string(),
132        },
133        DocEntry {
134            name: "report-issue.md".to_string(),
135            description: "Report bugs/features to other projects".to_string(),
136        },
137        DocEntry {
138            name: "groom.md".to_string(),
139            description: "groom".to_string(),
140        },
141    ]
142}
143
144/// List design docs filtered by project types
145fn list_design_docs(project_types: &[String]) -> Vec<DocEntry> {
146    let mut docs = Vec::new();
147
148    // cli-conventions is eligible for all project types
149    for _project_type in project_types {
150        docs.push(DocEntry {
151            name: "cli-conventions.md".to_string(),
152            description: "CLI tool design for humans, agents, and machines".to_string(),
153        });
154        break; // Add once, not per type
155    }
156
157    docs
158}
159
160/// Render the AGENTS.md managed section
161pub fn render_managed_section(ctx: &TemplateContext) -> anyhow::Result<String> {
162    let mut env = Environment::new();
163    env.add_template("agents-managed", AGENTS_MANAGED_TEMPLATE)?;
164
165    let template = env.get_template("agents-managed")?;
166    let rendered = template.render(ctx)?;
167
168    Ok(rendered)
169}
170
171/// Render a complete AGENTS.md file for a new project
172pub fn render_agents_md(config: &Config) -> anyhow::Result<String> {
173    let ctx = TemplateContext::from_config(config);
174
175    let tool_list = config
176        .tools
177        .enabled_tools()
178        .into_iter()
179        .map(|t| format!("`{}`", t))
180        .collect::<Vec<_>>()
181        .join(", ");
182
183    let reviewer_line = if config.review.reviewers.is_empty() {
184        String::new()
185    } else {
186        format!("\nReviewer roles: {}", config.review.reviewers.join(", "))
187    };
188
189    let managed = render_managed_section(&ctx)?;
190
191    Ok(format!(
192        "# {}\n\nProject type: {}\nTools: {}{}\n\n<!-- Add project-specific context below: architecture, conventions, key files, etc. -->\n\n{}{}\n{}\n",
193        config.project.name,
194        config.project.project_type.join(", "),
195        tool_list,
196        reviewer_line,
197        MANAGED_START,
198        managed,
199        MANAGED_END
200    ))
201}
202
203/// Update the managed section in an existing AGENTS.md.
204///
205/// Handles both current (`edict:managed-*`) and legacy (`botbox:managed-*`) markers,
206/// always writing back with current markers. This enables automatic migration of
207/// AGENTS.md files from botbox-era projects on the next `edict sync`.
208pub fn update_managed_section(content: &str, ctx: &TemplateContext) -> anyhow::Result<String> {
209    let managed = render_managed_section(ctx)?;
210    let full_managed = format!("{}\n{}\n{}", MANAGED_START, managed, MANAGED_END);
211
212    // Try current markers first
213    if let Some(start_idx) = content.find(MANAGED_START)
214        && let Some(end_idx) = content.find(MANAGED_END)
215        && end_idx > start_idx
216    {
217        let before = &content[..start_idx];
218        let after = &content[end_idx + MANAGED_END.len()..];
219        return Ok(format!("{}{}{}", before, full_managed, after));
220    }
221
222    // Try legacy markers (botbox era) — replace them with current markers
223    if let Some(start_idx) = content.find(MANAGED_START_LEGACY)
224        && let Some(end_idx) = content.find(MANAGED_END_LEGACY)
225        && end_idx > start_idx
226    {
227        let before = &content[..start_idx];
228        let after = &content[end_idx + MANAGED_END_LEGACY.len()..];
229        return Ok(format!("{}{}{}", before, full_managed, after));
230    }
231
232    // Missing or invalid markers — strip any stale marker fragments and append
233    let temp = content
234        .replace(MANAGED_START, "")
235        .replace(MANAGED_END, "")
236        .replace(MANAGED_START_LEGACY, "")
237        .replace(MANAGED_END_LEGACY, "");
238    let cleaned = temp.trim_end();
239    Ok(format!("{}\n\n{}\n", cleaned, full_managed))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_render_agents_md() {
248        let config = Config {
249            version: "1.0.0".to_string(),
250            project: crate::config::ProjectConfig {
251                name: "test-project".to_string(),
252                project_type: vec!["cli".to_string()],
253                default_agent: Some("test-dev".to_string()),
254                channel: Some("test".to_string()),
255                install_command: Some("just install".to_string()),
256                check_command: Some("true".to_string()),
257                languages: vec![],
258                critical_approvers: None,
259            },
260            tools: ToolsConfig {
261                bones: true,
262                maw: true,
263                crit: true,
264                botbus: true,
265                vessel: true,
266            },
267            review: ReviewConfig {
268                enabled: true,
269                reviewers: vec!["security".to_string()],
270            },
271            push_main: false,
272            agents: Default::default(),
273            models: Default::default(),
274            env: Default::default(),
275        };
276
277        let result = render_agents_md(&config).unwrap();
278
279        assert!(result.contains("# test-project"));
280        assert!(result.contains("Tools: `bones`, `maw`, `crit`, `botbus`, `vessel`"));
281        assert!(result.contains("Reviewer roles: security"));
282        assert!(result.contains(MANAGED_START));
283        assert!(result.contains(MANAGED_END));
284        assert!(result.contains("## Edict Workflow"));
285    }
286
287    #[test]
288    fn test_update_managed_section() {
289        let original = r#"# My Project
290
291Some custom content.
292
293<!-- edict:managed-start -->
294Old managed content here
295<!-- edict:managed-end -->
296
297More custom content.
298"#;
299
300        let config = Config {
301            version: "1.0.0".to_string(),
302            project: crate::config::ProjectConfig {
303                name: "test".to_string(),
304                project_type: vec!["cli".to_string()],
305                default_agent: None,
306                channel: None,
307                install_command: None,
308                check_command: None,
309                languages: vec![],
310                critical_approvers: None,
311            },
312            tools: ToolsConfig {
313                bones: true,
314                maw: false,
315                crit: false,
316                botbus: false,
317                vessel: false,
318            },
319            review: ReviewConfig {
320                enabled: false,
321                reviewers: vec![],
322            },
323            push_main: false,
324            agents: Default::default(),
325            models: Default::default(),
326            env: Default::default(),
327        };
328
329        let ctx = TemplateContext::from_config(&config);
330        let result = update_managed_section(original, &ctx).unwrap();
331
332        assert!(result.contains("# My Project"));
333        assert!(result.contains("Some custom content."));
334        assert!(result.contains("More custom content."));
335        assert!(result.contains(MANAGED_START));
336        assert!(result.contains(MANAGED_END));
337        assert!(!result.contains("Old managed content"));
338        assert!(result.contains("## Edict Workflow"));
339    }
340
341    #[test]
342    fn test_update_managed_section_migrates_legacy_markers() {
343        // AGENTS.md still has botbox:managed-* markers — should be replaced with edict:managed-*
344        let original = r#"# My Project
345
346Custom content.
347
348<!-- botbox:managed-start -->
349Old botbox-era managed content
350<!-- botbox:managed-end -->
351"#;
352
353        let config = Config {
354            version: "1.0.0".to_string(),
355            project: crate::config::ProjectConfig {
356                name: "test".to_string(),
357                project_type: vec!["cli".to_string()],
358                default_agent: None,
359                channel: None,
360                install_command: None,
361                check_command: None,
362                languages: vec![],
363                critical_approvers: None,
364            },
365            tools: ToolsConfig {
366                bones: true,
367                maw: false,
368                crit: false,
369                botbus: false,
370                vessel: false,
371            },
372            review: ReviewConfig {
373                enabled: false,
374                reviewers: vec![],
375            },
376            push_main: false,
377            agents: Default::default(),
378            models: Default::default(),
379            env: Default::default(),
380        };
381
382        let ctx = TemplateContext::from_config(&config);
383        let result = update_managed_section(original, &ctx).unwrap();
384
385        assert!(result.contains("# My Project"));
386        assert!(result.contains("Custom content."));
387        // Old markers and content gone
388        assert!(!result.contains("botbox:managed-start"));
389        assert!(!result.contains("botbox:managed-end"));
390        assert!(!result.contains("Old botbox-era managed content"));
391        // New markers present
392        assert!(result.contains(MANAGED_START));
393        assert!(result.contains(MANAGED_END));
394    }
395}