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}