1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::discovery::DocType;
6
7pub fn read_doc(path: &Path) -> Result<String> {
9 std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
10}
11
12pub fn write_doc(path: &Path, content: &str) -> Result<()> {
14 if let Some(parent) = path.parent() {
15 std::fs::create_dir_all(parent)?;
16 }
17 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
18}
19
20pub fn doc_filename(doc_type: DocType) -> &'static str {
22 match doc_type {
23 DocType::Agents => "AGENTS.md",
24 DocType::Soul => "SOUL.md",
25 DocType::Tools => "TOOLS.md",
26 DocType::Identity => "IDENTITY.md",
27 DocType::Memory => "MEMORY.md",
28 DocType::User => "USER.md",
29 DocType::Heartbeat => "HEARTBEAT.md",
30 DocType::Boot => "BOOT.md",
31 DocType::Bootstrap => "BOOTSTRAP.md",
32 DocType::Other => "",
33 }
34}
35
36pub fn doc_description(doc_type: DocType) -> &'static str {
38 match doc_type {
39 DocType::Agents => "Operating instructions, workflow rules, memory management",
40 DocType::Soul => "Personality, values, communication style, behavioral boundaries",
41 DocType::Tools => "Notes about local tools, environment conventions",
42 DocType::Identity => "Agent name, emoji, role, how it introduces itself",
43 DocType::Memory => "Curated long-term memory (persistent facts)",
44 DocType::User => "About the user — preferences, context, schedule",
45 DocType::Heartbeat => "Tiny checklist for proactive heartbeat runs",
46 DocType::Boot => "Startup ritual on gateway restart",
47 DocType::Bootstrap => "One-time first-run interview script",
48 DocType::Other => "Additional document",
49 }
50}
51
52pub fn validate_lobster(path: &Path) -> Result<LobsterValidation> {
54 let content = std::fs::read_to_string(path)
55 .with_context(|| format!("Failed to read {}", path.display()))?;
56
57 let parsed: serde_yaml::Value = serde_yaml::from_str(&content)
58 .with_context(|| format!("Invalid YAML in {}", path.display()))?;
59
60 let mut warnings = Vec::new();
61 let mut has_name = false;
62 let mut has_steps = false;
63 let mut step_count = 0;
64
65 if let Some(name) = parsed.get("name") {
66 if name.is_string() || name.is_null() {
67 has_name = true;
68 } else {
69 warnings.push("'name' should be a string".to_string());
70 }
71 } else {
72 warnings.push("Missing 'name' field".to_string());
73 }
74
75 if let Some(steps) = parsed.get("steps") {
76 if let Some(steps_arr) = steps.as_sequence() {
77 has_steps = true;
78 step_count = steps_arr.len();
79 for (i, step) in steps_arr.iter().enumerate() {
80 if step.get("id").is_none() {
81 warnings.push(format!("Step {} missing 'id' field", i + 1));
82 }
83 if step.get("run").is_none()
84 && step.get("command").is_none()
85 && step.get("pipeline").is_none()
86 && step.get("lobster").is_none()
87 {
88 warnings.push(format!(
89 "Step {} missing action (run/command/pipeline/lobster)",
90 i + 1
91 ));
92 }
93 }
94 } else {
95 warnings.push("'steps' should be an array".to_string());
96 }
97 } else {
98 warnings.push("Missing 'steps' field".to_string());
99 }
100
101 Ok(LobsterValidation {
102 valid: has_name && has_steps && warnings.len() <= 2,
103 has_name,
104 has_steps,
105 step_count,
106 warnings,
107 })
108}
109
110#[derive(Debug, Clone)]
112pub struct LobsterValidation {
113 pub valid: bool,
114 pub has_name: bool,
115 pub has_steps: bool,
116 pub step_count: usize,
117 pub warnings: Vec<String>,
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn test_doc_filename() {
126 assert_eq!(doc_filename(DocType::Agents), "AGENTS.md");
127 assert_eq!(doc_filename(DocType::Soul), "SOUL.md");
128 assert_eq!(doc_filename(DocType::Tools), "TOOLS.md");
129 }
130
131 #[test]
132 fn test_doc_description() {
133 assert!(doc_description(DocType::Agents).contains("instructions"));
134 assert!(doc_description(DocType::Soul).contains("Personality"));
135 }
136
137 #[test]
138 fn test_validate_lobster_valid() {
139 let tmp = std::env::temp_dir().join("agentry_test_lobster");
140 let _ = std::fs::remove_dir_all(&tmp);
141 std::fs::create_dir_all(&tmp).unwrap();
142
143 let lobster_content = r#"
144name: test-workflow
145args:
146 tag:
147 default: "family"
148steps:
149 - id: collect
150 command: echo hello
151 - id: approve
152 approval: required
153"#;
154 let path = tmp.join("test.lobster");
155 std::fs::write(&path, lobster_content).unwrap();
156
157 let result = validate_lobster(&path).unwrap();
158 assert!(result.valid);
159 assert!(result.has_name);
160 assert!(result.has_steps);
161 assert_eq!(result.step_count, 2);
162
163 let _ = std::fs::remove_dir_all(&tmp);
164 }
165
166 #[test]
167 fn test_validate_lobster_missing_fields() {
168 let tmp = std::env::temp_dir().join("agentry_test_lobster_bad");
169 let _ = std::fs::remove_dir_all(&tmp);
170 std::fs::create_dir_all(&tmp).unwrap();
171
172 let lobster_content = "just: some\nrandom: yaml\n";
173 let path = tmp.join("bad.lobster");
174 std::fs::write(&path, lobster_content).unwrap();
175
176 let result = validate_lobster(&path).unwrap();
177 assert!(!result.valid);
178
179 let _ = std::fs::remove_dir_all(&tmp);
180 }
181
182 #[test]
183 fn test_read_write_doc() {
184 let tmp = std::env::temp_dir().join("agentry_test_doc");
185 let _ = std::fs::remove_dir_all(&tmp);
186 std::fs::create_dir_all(&tmp).unwrap();
187
188 let path = tmp.join("AGENTS.md");
189 write_doc(&path, "# Test Agents\nHello world").unwrap();
190 let content = read_doc(&path).unwrap();
191 assert!(content.contains("# Test Agents"));
192
193 let _ = std::fs::remove_dir_all(&tmp);
194 }
195}