1use std::path::{Path, PathBuf};
2
3#[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 pub fn is_main_session_only(self) -> bool {
32 matches!(self, Self::Boot | Self::Bootstrap)
33 }
34
35 pub fn is_shared(self) -> bool {
37 !self.is_main_session_only()
38 }
39}
40
41pub 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
55pub const MAIN_SESSION_TEMPLATES: &[TemplateFile] = &[TemplateFile::Boot, TemplateFile::Bootstrap];
57
58pub 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
68pub 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
87pub 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#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ResolvedTemplate {
98 pub template: TemplateFile,
99 pub source: PathBuf,
100 pub content: String,
101}
102
103#[derive(Debug, Clone)]
105pub struct TemplateSet {
106 pub templates: Vec<ResolvedTemplate>,
107 pub missing: Vec<TemplateFile>,
108}
109
110impl TemplateSet {
111 pub fn get(&self, template: TemplateFile) -> Option<&ResolvedTemplate> {
113 self.templates.iter().find(|t| t.template == template)
114 }
115
116 pub fn main_session_templates(&self) -> Vec<&ResolvedTemplate> {
118 self.templates
119 .iter()
120 .filter(|_| true) .collect()
122 }
123
124 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 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
145fn 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
159pub 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
196pub 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
215pub 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 #[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 #[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 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 #[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 #[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 #[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 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 assert!(!result.missing.contains(&TemplateFile::Boot));
486
487 fs::remove_dir_all(dir).ok();
488 }
489
490 #[test]
493 fn templates_loaded_in_deterministic_order() {
494 let dir = temp_dir();
495 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 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 #[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 #[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}