Skip to main content

agentzero_config/
templates.rs

1use std::path::{Path, PathBuf};
2
3/// All supported template files.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum TemplateFile {
6    Agents,
7    Boot,
8    Bootstrap,
9    Heartbeat,
10    Identity,
11    Soul,
12    Tools,
13    User,
14}
15
16impl TemplateFile {
17    pub fn file_name(self) -> &'static str {
18        match self {
19            Self::Agents => "AGENTS.md",
20            Self::Boot => "BOOT.md",
21            Self::Bootstrap => "BOOTSTRAP.md",
22            Self::Heartbeat => "HEARTBEAT.md",
23            Self::Identity => "IDENTITY.md",
24            Self::Soul => "SOUL.md",
25            Self::Tools => "TOOLS.md",
26            Self::User => "USER.md",
27        }
28    }
29
30    /// Whether this template is scoped to the main session only (not shared sessions).
31    pub fn is_main_session_only(self) -> bool {
32        matches!(self, Self::Boot | Self::Bootstrap)
33    }
34
35    /// Whether this template is shared across all sessions.
36    pub fn is_shared(self) -> bool {
37        !self.is_main_session_only()
38    }
39}
40
41/// Deterministic load order for templates. Identity and Soul come first
42/// (define who the agent is), then Tools and Agents (define what it can do),
43/// then Boot/Bootstrap (initialization), Heartbeat (lifecycle), User (context).
44pub const TEMPLATE_LOAD_ORDER: &[TemplateFile] = &[
45    TemplateFile::Identity,
46    TemplateFile::Soul,
47    TemplateFile::Tools,
48    TemplateFile::Agents,
49    TemplateFile::Boot,
50    TemplateFile::Bootstrap,
51    TemplateFile::Heartbeat,
52    TemplateFile::User,
53];
54
55/// Templates that only load in the main session.
56pub const MAIN_SESSION_TEMPLATES: &[TemplateFile] = &[TemplateFile::Boot, TemplateFile::Bootstrap];
57
58/// Templates that load in all sessions (main + shared).
59pub const SHARED_SESSION_TEMPLATES: &[TemplateFile] = &[
60    TemplateFile::Identity,
61    TemplateFile::Soul,
62    TemplateFile::Tools,
63    TemplateFile::Agents,
64    TemplateFile::Heartbeat,
65    TemplateFile::User,
66];
67
68/// Template search directories in precedence order (highest first).
69///
70/// 1. Workspace root — project-specific overrides (e.g., `./AGENTS.md`)
71/// 2. `.agentzero/` — project config directory (e.g., `./.agentzero/AGENTS.md`)
72/// 3. Global config — user-wide defaults (e.g., `~/.config/agentzero/AGENTS.md`)
73pub fn template_search_dirs(
74    workspace_root: &Path,
75    global_config_dir: Option<&Path>,
76) -> Vec<PathBuf> {
77    let mut dirs = vec![
78        workspace_root.to_path_buf(),
79        workspace_root.join(".agentzero"),
80    ];
81    if let Some(global) = global_config_dir {
82        dirs.push(global.to_path_buf());
83    }
84    dirs
85}
86
87/// Generate template paths for a workspace (workspace root only, for backward compatibility).
88pub fn template_paths_for_workspace(workspace_root: &Path) -> Vec<PathBuf> {
89    TEMPLATE_LOAD_ORDER
90        .iter()
91        .map(|template| workspace_root.join(template.file_name()))
92        .collect()
93}
94
95/// A resolved template with its source location and content.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ResolvedTemplate {
98    pub template: TemplateFile,
99    pub source: PathBuf,
100    pub content: String,
101}
102
103/// Result of discovering templates in a workspace.
104#[derive(Debug, Clone)]
105pub struct TemplateSet {
106    pub templates: Vec<ResolvedTemplate>,
107    pub missing: Vec<TemplateFile>,
108}
109
110impl TemplateSet {
111    /// Get the content for a specific template, if loaded.
112    pub fn get(&self, template: TemplateFile) -> Option<&ResolvedTemplate> {
113        self.templates.iter().find(|t| t.template == template)
114    }
115
116    /// Get all templates appropriate for the main session.
117    pub fn main_session_templates(&self) -> Vec<&ResolvedTemplate> {
118        self.templates
119            .iter()
120            .filter(|_| true) // main session gets all templates
121            .collect()
122    }
123
124    /// Get only templates appropriate for shared sessions.
125    pub fn shared_session_templates(&self) -> Vec<&ResolvedTemplate> {
126        self.templates
127            .iter()
128            .filter(|t| t.template.is_shared())
129            .collect()
130    }
131
132    /// Format a guidance message for missing templates.
133    pub fn missing_guidance(&self) -> Option<String> {
134        if self.missing.is_empty() {
135            return None;
136        }
137        let names: Vec<&str> = self.missing.iter().map(|t| t.file_name()).collect();
138        Some(format!(
139            "Optional templates not found: {}. Create them in your workspace root or .agentzero/ directory to customize agent behavior.",
140            names.join(", ")
141        ))
142    }
143}
144
145/// Discover a single template by searching directories in precedence order.
146///
147/// Returns the first match found. Higher-precedence directories shadow lower ones.
148fn discover_template(template: TemplateFile, search_dirs: &[PathBuf]) -> Option<PathBuf> {
149    let name = template.file_name();
150    for dir in search_dirs {
151        let candidate = dir.join(name);
152        if candidate.is_file() {
153            return Some(candidate);
154        }
155    }
156    None
157}
158
159/// Discover and load all templates from the workspace.
160///
161/// Templates are discovered using the precedence rules:
162/// 1. Workspace root (highest priority)
163/// 2. `.agentzero/` directory
164/// 3. Global config directory (lowest priority)
165///
166/// Missing templates are tracked but do not cause errors — they are optional.
167/// Templates are loaded in the deterministic `TEMPLATE_LOAD_ORDER`.
168pub fn discover_templates(workspace_root: &Path, global_config_dir: Option<&Path>) -> TemplateSet {
169    let search_dirs = template_search_dirs(workspace_root, global_config_dir);
170    let mut templates = Vec::new();
171    let mut missing = Vec::new();
172
173    for &template in TEMPLATE_LOAD_ORDER {
174        match discover_template(template, &search_dirs) {
175            Some(path) => match std::fs::read_to_string(&path) {
176                Ok(content) => {
177                    templates.push(ResolvedTemplate {
178                        template,
179                        source: path,
180                        content,
181                    });
182                }
183                Err(_) => {
184                    missing.push(template);
185                }
186            },
187            None => {
188                missing.push(template);
189            }
190        }
191    }
192
193    TemplateSet { templates, missing }
194}
195
196/// Discover templates for a shared session (excludes main-session-only templates).
197pub fn discover_shared_templates(
198    workspace_root: &Path,
199    global_config_dir: Option<&Path>,
200) -> TemplateSet {
201    let full = discover_templates(workspace_root, global_config_dir);
202    let templates: Vec<ResolvedTemplate> = full
203        .templates
204        .into_iter()
205        .filter(|t| t.template.is_shared())
206        .collect();
207    let missing: Vec<TemplateFile> = SHARED_SESSION_TEMPLATES
208        .iter()
209        .copied()
210        .filter(|t| !templates.iter().any(|rt| rt.template == *t))
211        .collect();
212    TemplateSet { templates, missing }
213}
214
215/// List all template files that exist in the given search directories,
216/// showing which directory each was resolved from.
217pub fn list_template_sources(
218    workspace_root: &Path,
219    global_config_dir: Option<&Path>,
220) -> Vec<(TemplateFile, Option<PathBuf>)> {
221    let search_dirs = template_search_dirs(workspace_root, global_config_dir);
222    TEMPLATE_LOAD_ORDER
223        .iter()
224        .map(|&template| {
225            let path = discover_template(template, &search_dirs);
226            (template, path)
227        })
228        .collect()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use std::fs;
235    use std::sync::atomic::{AtomicU64, Ordering};
236    use std::time::{SystemTime, UNIX_EPOCH};
237
238    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
239
240    fn temp_dir() -> PathBuf {
241        let nanos = SystemTime::now()
242            .duration_since(UNIX_EPOCH)
243            .expect("clock")
244            .as_nanos();
245        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
246        let dir = std::env::temp_dir().join(format!(
247            "agentzero-templates-{}-{nanos}-{seq}",
248            std::process::id()
249        ));
250        fs::create_dir_all(&dir).expect("temp dir should be created");
251        dir
252    }
253
254    // --- Existing tests (preserved) ---
255
256    #[test]
257    fn template_paths_follow_declared_load_order_success_path() {
258        let root = Path::new("/tmp/workspace");
259        let paths = template_paths_for_workspace(root);
260        assert_eq!(paths.len(), TEMPLATE_LOAD_ORDER.len());
261        assert_eq!(paths[0].to_string_lossy(), "/tmp/workspace/IDENTITY.md");
262        assert_eq!(
263            paths
264                .last()
265                .expect("last path should exist")
266                .to_string_lossy(),
267            "/tmp/workspace/USER.md"
268        );
269    }
270
271    #[test]
272    fn template_file_names_are_uppercase_markdown_negative_path() {
273        for template in TEMPLATE_LOAD_ORDER {
274            let name = template.file_name();
275            assert!(name.ends_with(".md"));
276            let stem = name.trim_end_matches(".md");
277            assert!(
278                stem.chars().all(|ch| !ch.is_ascii_lowercase()),
279                "template name should remain uppercase: {name}"
280            );
281        }
282        assert_eq!(TemplateFile::Agents.file_name(), "AGENTS.md");
283    }
284
285    // --- Discovery tests ---
286
287    #[test]
288    fn discover_templates_finds_workspace_root_files() {
289        let dir = temp_dir();
290        fs::write(dir.join("AGENTS.md"), "# Agents rules").unwrap();
291        fs::write(dir.join("IDENTITY.md"), "# Identity").unwrap();
292
293        let result = discover_templates(&dir, None);
294        assert_eq!(result.templates.len(), 2);
295        assert!(result.get(TemplateFile::Agents).is_some());
296        assert!(result.get(TemplateFile::Identity).is_some());
297        assert_eq!(
298            result.get(TemplateFile::Agents).unwrap().content,
299            "# Agents rules"
300        );
301
302        // Other templates are missing
303        assert!(result.missing.contains(&TemplateFile::Boot));
304        assert!(result.missing.contains(&TemplateFile::Soul));
305
306        fs::remove_dir_all(dir).ok();
307    }
308
309    #[test]
310    fn discover_templates_finds_agentzero_dir_files() {
311        let dir = temp_dir();
312        let az_dir = dir.join(".agentzero");
313        fs::create_dir_all(&az_dir).unwrap();
314        fs::write(az_dir.join("SOUL.md"), "# Soul from .agentzero").unwrap();
315
316        let result = discover_templates(&dir, None);
317        assert_eq!(result.templates.len(), 1);
318        let soul = result.get(TemplateFile::Soul).unwrap();
319        assert_eq!(soul.content, "# Soul from .agentzero");
320        assert!(soul.source.to_string_lossy().contains(".agentzero"));
321
322        fs::remove_dir_all(dir).ok();
323    }
324
325    #[test]
326    fn discover_templates_finds_global_dir_files() {
327        let workspace = temp_dir();
328        let global = temp_dir();
329        fs::write(global.join("TOOLS.md"), "# Global tools").unwrap();
330
331        let result = discover_templates(&workspace, Some(&global));
332        assert_eq!(result.templates.len(), 1);
333        let tools = result.get(TemplateFile::Tools).unwrap();
334        assert_eq!(tools.content, "# Global tools");
335
336        fs::remove_dir_all(workspace).ok();
337        fs::remove_dir_all(global).ok();
338    }
339
340    // --- Precedence tests ---
341
342    #[test]
343    fn workspace_root_overrides_agentzero_dir() {
344        let dir = temp_dir();
345        let az_dir = dir.join(".agentzero");
346        fs::create_dir_all(&az_dir).unwrap();
347
348        fs::write(dir.join("AGENTS.md"), "workspace version").unwrap();
349        fs::write(az_dir.join("AGENTS.md"), "agentzero dir version").unwrap();
350
351        let result = discover_templates(&dir, None);
352        let agents = result.get(TemplateFile::Agents).unwrap();
353        assert_eq!(agents.content, "workspace version");
354        assert!(!agents.source.to_string_lossy().contains(".agentzero"));
355
356        fs::remove_dir_all(dir).ok();
357    }
358
359    #[test]
360    fn agentzero_dir_overrides_global() {
361        let workspace = temp_dir();
362        let global = temp_dir();
363        let az_dir = workspace.join(".agentzero");
364        fs::create_dir_all(&az_dir).unwrap();
365
366        fs::write(az_dir.join("SOUL.md"), "project soul").unwrap();
367        fs::write(global.join("SOUL.md"), "global soul").unwrap();
368
369        let result = discover_templates(&workspace, Some(&global));
370        let soul = result.get(TemplateFile::Soul).unwrap();
371        assert_eq!(soul.content, "project soul");
372
373        fs::remove_dir_all(workspace).ok();
374        fs::remove_dir_all(global).ok();
375    }
376
377    #[test]
378    fn workspace_root_overrides_global() {
379        let workspace = temp_dir();
380        let global = temp_dir();
381
382        fs::write(workspace.join("IDENTITY.md"), "workspace identity").unwrap();
383        fs::write(global.join("IDENTITY.md"), "global identity").unwrap();
384
385        let result = discover_templates(&workspace, Some(&global));
386        let identity = result.get(TemplateFile::Identity).unwrap();
387        assert_eq!(identity.content, "workspace identity");
388
389        fs::remove_dir_all(workspace).ok();
390        fs::remove_dir_all(global).ok();
391    }
392
393    // --- Missing-file behavior ---
394
395    #[test]
396    fn empty_workspace_returns_all_missing() {
397        let dir = temp_dir();
398        let result = discover_templates(&dir, None);
399        assert!(result.templates.is_empty());
400        assert_eq!(result.missing.len(), TEMPLATE_LOAD_ORDER.len());
401
402        fs::remove_dir_all(dir).ok();
403    }
404
405    #[test]
406    fn missing_guidance_lists_files() {
407        let dir = temp_dir();
408        let result = discover_templates(&dir, None);
409        let guidance = result.missing_guidance().expect("should have guidance");
410        assert!(guidance.contains("AGENTS.md"));
411        assert!(guidance.contains("IDENTITY.md"));
412        assert!(guidance.contains(".agentzero/"));
413
414        fs::remove_dir_all(dir).ok();
415    }
416
417    #[test]
418    fn no_missing_guidance_when_all_present() {
419        let dir = temp_dir();
420        for template in TEMPLATE_LOAD_ORDER {
421            fs::write(dir.join(template.file_name()), "content").unwrap();
422        }
423
424        let result = discover_templates(&dir, None);
425        assert!(result.missing.is_empty());
426        assert!(result.missing_guidance().is_none());
427
428        fs::remove_dir_all(dir).ok();
429    }
430
431    // --- Session scoping tests ---
432
433    #[test]
434    fn boot_and_bootstrap_are_main_session_only() {
435        assert!(TemplateFile::Boot.is_main_session_only());
436        assert!(TemplateFile::Bootstrap.is_main_session_only());
437        assert!(!TemplateFile::Agents.is_main_session_only());
438        assert!(!TemplateFile::Identity.is_main_session_only());
439        assert!(!TemplateFile::Soul.is_main_session_only());
440    }
441
442    #[test]
443    fn main_session_gets_all_templates() {
444        let dir = temp_dir();
445        fs::write(dir.join("BOOT.md"), "boot").unwrap();
446        fs::write(dir.join("AGENTS.md"), "agents").unwrap();
447        fs::write(dir.join("IDENTITY.md"), "identity").unwrap();
448
449        let result = discover_templates(&dir, None);
450        let main = result.main_session_templates();
451        assert_eq!(main.len(), 3);
452
453        fs::remove_dir_all(dir).ok();
454    }
455
456    #[test]
457    fn shared_session_excludes_boot_and_bootstrap() {
458        let dir = temp_dir();
459        fs::write(dir.join("BOOT.md"), "boot").unwrap();
460        fs::write(dir.join("BOOTSTRAP.md"), "bootstrap").unwrap();
461        fs::write(dir.join("AGENTS.md"), "agents").unwrap();
462        fs::write(dir.join("IDENTITY.md"), "identity").unwrap();
463
464        let result = discover_templates(&dir, None);
465        let shared = result.shared_session_templates();
466        // Should only have AGENTS and IDENTITY, not BOOT or BOOTSTRAP
467        assert_eq!(shared.len(), 2);
468        assert!(shared.iter().all(|t| t.template.is_shared()));
469        assert!(!shared.iter().any(|t| t.template == TemplateFile::Boot));
470        assert!(!shared.iter().any(|t| t.template == TemplateFile::Bootstrap));
471
472        fs::remove_dir_all(dir).ok();
473    }
474
475    #[test]
476    fn discover_shared_templates_excludes_main_only() {
477        let dir = temp_dir();
478        fs::write(dir.join("BOOT.md"), "boot").unwrap();
479        fs::write(dir.join("AGENTS.md"), "agents").unwrap();
480
481        let result = discover_shared_templates(&dir, None);
482        assert_eq!(result.templates.len(), 1);
483        assert_eq!(result.templates[0].template, TemplateFile::Agents);
484        // BOOT is not in the missing list since it's not a shared template
485        assert!(!result.missing.contains(&TemplateFile::Boot));
486
487        fs::remove_dir_all(dir).ok();
488    }
489
490    // --- Load order tests ---
491
492    #[test]
493    fn templates_loaded_in_deterministic_order() {
494        let dir = temp_dir();
495        // Write in reverse order to verify load order is by TEMPLATE_LOAD_ORDER, not filesystem
496        fs::write(dir.join("USER.md"), "user").unwrap();
497        fs::write(dir.join("HEARTBEAT.md"), "heartbeat").unwrap();
498        fs::write(dir.join("BOOTSTRAP.md"), "bootstrap").unwrap();
499        fs::write(dir.join("BOOT.md"), "boot").unwrap();
500        fs::write(dir.join("AGENTS.md"), "agents").unwrap();
501        fs::write(dir.join("TOOLS.md"), "tools").unwrap();
502        fs::write(dir.join("SOUL.md"), "soul").unwrap();
503        fs::write(dir.join("IDENTITY.md"), "identity").unwrap();
504
505        let result = discover_templates(&dir, None);
506        assert_eq!(result.templates.len(), 8);
507
508        // Verify order matches TEMPLATE_LOAD_ORDER
509        for (i, resolved) in result.templates.iter().enumerate() {
510            assert_eq!(resolved.template, TEMPLATE_LOAD_ORDER[i]);
511        }
512
513        fs::remove_dir_all(dir).ok();
514    }
515
516    // --- list_template_sources ---
517
518    #[test]
519    fn list_sources_shows_found_and_missing() {
520        let dir = temp_dir();
521        fs::write(dir.join("AGENTS.md"), "agents").unwrap();
522
523        let sources = list_template_sources(&dir, None);
524        assert_eq!(sources.len(), TEMPLATE_LOAD_ORDER.len());
525
526        let agents_entry = sources
527            .iter()
528            .find(|(t, _)| *t == TemplateFile::Agents)
529            .unwrap();
530        assert!(agents_entry.1.is_some());
531
532        let boot_entry = sources
533            .iter()
534            .find(|(t, _)| *t == TemplateFile::Boot)
535            .unwrap();
536        assert!(boot_entry.1.is_none());
537
538        fs::remove_dir_all(dir).ok();
539    }
540
541    // --- Search dirs ---
542
543    #[test]
544    fn search_dirs_include_all_locations() {
545        let workspace = Path::new("/workspace");
546        let global = Path::new("/global");
547        let dirs = template_search_dirs(workspace, Some(global));
548        assert_eq!(dirs.len(), 3);
549        assert_eq!(dirs[0], PathBuf::from("/workspace"));
550        assert_eq!(dirs[1], PathBuf::from("/workspace/.agentzero"));
551        assert_eq!(dirs[2], PathBuf::from("/global"));
552    }
553
554    #[test]
555    fn search_dirs_without_global() {
556        let workspace = Path::new("/workspace");
557        let dirs = template_search_dirs(workspace, None);
558        assert_eq!(dirs.len(), 2);
559        assert_eq!(dirs[0], PathBuf::from("/workspace"));
560        assert_eq!(dirs[1], PathBuf::from("/workspace/.agentzero"));
561    }
562}