Skip to main content

agentry_openclaw/
docs.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::discovery::DocType;
6
7/// Read a workspace document's content.
8pub fn read_doc(path: &Path) -> Result<String> {
9    std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
10}
11
12/// Write content to a workspace document.
13pub 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
20/// Get the canonical filename for a doc type.
21pub 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
36/// Get a human-readable description for a doc type.
37pub 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
52/// Validate a .lobster YAML workflow file.
53pub 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/// Result of validating a .lobster workflow.
111#[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}