Skip to main content

verifyos_cli/
doctor.rs

1use crate::config::load_file_config;
2use serde::Serialize;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
6pub enum DoctorStatus {
7    Pass,
8    Warn,
9    Fail,
10}
11
12#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
13pub struct DoctorCheck {
14    pub name: String,
15    pub status: DoctorStatus,
16    pub detail: String,
17}
18
19#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
20pub struct DoctorReport {
21    pub checks: Vec<DoctorCheck>,
22}
23
24impl DoctorReport {
25    pub fn has_failures(&self) -> bool {
26        self.checks
27            .iter()
28            .any(|item| item.status == DoctorStatus::Fail)
29    }
30}
31
32pub fn run_doctor(config_path: Option<&Path>, agents_path: &Path) -> DoctorReport {
33    let mut checks = Vec::new();
34
35    checks.push(check_config(config_path));
36    checks.push(check_agents_presence(agents_path));
37
38    if agents_path.exists() {
39        let contents = std::fs::read_to_string(agents_path).unwrap_or_default();
40        checks.push(check_referenced_assets(&contents, agents_path));
41        checks.push(check_next_commands(&contents));
42    }
43
44    DoctorReport { checks }
45}
46
47fn check_config(config_path: Option<&Path>) -> DoctorCheck {
48    match load_file_config(config_path) {
49        Ok(_) => DoctorCheck {
50            name: "Config".to_string(),
51            status: DoctorStatus::Pass,
52            detail: config_path
53                .map(|path| format!("Config is valid: {}", path.display()))
54                .unwrap_or_else(|| "Config is valid or not present".to_string()),
55        },
56        Err(err) => DoctorCheck {
57            name: "Config".to_string(),
58            status: DoctorStatus::Fail,
59            detail: err.to_string(),
60        },
61    }
62}
63
64fn check_agents_presence(agents_path: &Path) -> DoctorCheck {
65    if agents_path.exists() {
66        DoctorCheck {
67            name: "AGENTS.md".to_string(),
68            status: DoctorStatus::Pass,
69            detail: format!("Found {}", agents_path.display()),
70        }
71    } else {
72        DoctorCheck {
73            name: "AGENTS.md".to_string(),
74            status: DoctorStatus::Warn,
75            detail: format!("Missing {}", agents_path.display()),
76        }
77    }
78}
79
80fn check_referenced_assets(contents: &str, agents_path: &Path) -> DoctorCheck {
81    let referenced = extract_backticked_paths(contents);
82    if referenced.is_empty() {
83        return DoctorCheck {
84            name: "Referenced assets".to_string(),
85            status: DoctorStatus::Warn,
86            detail: "No referenced agent assets found in AGENTS.md".to_string(),
87        };
88    }
89
90    let mut missing = Vec::new();
91    for item in referenced {
92        let path = resolve_reference(agents_path, &item);
93        if !path.exists() {
94            missing.push(path.display().to_string());
95        }
96    }
97
98    if missing.is_empty() {
99        DoctorCheck {
100            name: "Referenced assets".to_string(),
101            status: DoctorStatus::Pass,
102            detail: "All referenced agent assets exist".to_string(),
103        }
104    } else {
105        DoctorCheck {
106            name: "Referenced assets".to_string(),
107            status: DoctorStatus::Fail,
108            detail: format!("Missing assets: {}", missing.join(", ")),
109        }
110    }
111}
112
113fn check_next_commands(contents: &str) -> DoctorCheck {
114    let commands = extract_voc_commands(contents);
115    if commands.is_empty() {
116        return DoctorCheck {
117            name: "Next commands".to_string(),
118            status: DoctorStatus::Warn,
119            detail: "No sample voc commands found in AGENTS.md".to_string(),
120        };
121    }
122
123    let malformed: Vec<String> = commands
124        .into_iter()
125        .filter(|line| !line.starts_with("voc "))
126        .collect();
127
128    if malformed.is_empty() {
129        DoctorCheck {
130            name: "Next commands".to_string(),
131            status: DoctorStatus::Pass,
132            detail: "Sample voc commands look valid".to_string(),
133        }
134    } else {
135        DoctorCheck {
136            name: "Next commands".to_string(),
137            status: DoctorStatus::Fail,
138            detail: format!("Malformed commands: {}", malformed.join(" | ")),
139        }
140    }
141}
142
143fn extract_backticked_paths(contents: &str) -> Vec<String> {
144    let mut items = Vec::new();
145    let mut in_tick = false;
146    let mut current = String::new();
147
148    for ch in contents.chars() {
149        if ch == '`' {
150            if in_tick {
151                if current.ends_with(".json")
152                    || current.ends_with(".md")
153                    || current.ends_with(".sh")
154                {
155                    items.push(current.clone());
156                }
157                current.clear();
158            }
159            in_tick = !in_tick;
160            continue;
161        }
162        if in_tick {
163            current.push(ch);
164        }
165    }
166
167    items
168}
169
170fn extract_voc_commands(contents: &str) -> Vec<String> {
171    let mut commands = Vec::new();
172    let mut in_block = false;
173    for line in contents.lines() {
174        if line.trim_start().starts_with("```") {
175            in_block = !in_block;
176            continue;
177        }
178        if in_block && line.trim_start().starts_with("voc ") {
179            commands.push(line.trim().to_string());
180        }
181    }
182    commands
183}
184
185fn resolve_reference(agents_path: &Path, reference: &str) -> PathBuf {
186    let ref_path = PathBuf::from(reference);
187    if ref_path.is_absolute() {
188        ref_path
189    } else {
190        agents_path
191            .parent()
192            .unwrap_or_else(|| Path::new("."))
193            .join(ref_path)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{run_doctor, DoctorStatus};
200    use std::fs;
201    use tempfile::tempdir;
202
203    #[test]
204    fn doctor_warns_when_agents_is_missing() {
205        let dir = tempdir().expect("temp dir");
206        let report = run_doctor(None, &dir.path().join("AGENTS.md"));
207
208        assert_eq!(report.checks[1].status, DoctorStatus::Warn);
209    }
210
211    #[test]
212    fn doctor_fails_when_referenced_assets_are_missing() {
213        let dir = tempdir().expect("temp dir");
214        let agents = dir.path().join("AGENTS.md");
215        fs::write(
216            &agents,
217            "### Current Project Risks\n\n- Agent bundle: `.verifyos-agent/agent-pack.json` and `.verifyos-agent/agent-pack.md`\n",
218        )
219        .expect("write agents");
220
221        let report = run_doctor(None, &agents);
222
223        assert!(report.has_failures());
224        assert_eq!(report.checks[2].status, DoctorStatus::Fail);
225    }
226}