1use crate::config::load_file_config;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum DoctorStatus {
8 Pass,
9 Warn,
10 Fail,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct DoctorCheck {
15 pub name: String,
16 pub status: DoctorStatus,
17 pub detail: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct DoctorReport {
22 pub checks: Vec<DoctorCheck>,
23 #[serde(default)]
24 pub repair_plan: Vec<RepairPlanItem>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct RepairPlanItem {
29 pub target: String,
30 pub path: String,
31 pub reason: String,
32}
33
34impl DoctorReport {
35 pub fn has_failures(&self) -> bool {
36 self.checks
37 .iter()
38 .any(|item| item.status == DoctorStatus::Fail)
39 }
40}
41
42pub fn run_doctor(
43 config_path: Option<&Path>,
44 agents_path: &Path,
45 freshness_against: Option<&Path>,
46) -> DoctorReport {
47 let mut checks = Vec::new();
48
49 checks.push(check_config(config_path));
50 checks.push(check_agents_presence(agents_path));
51
52 if agents_path.exists() {
53 let contents = std::fs::read_to_string(agents_path).unwrap_or_default();
54 checks.push(check_referenced_assets(&contents, agents_path));
55 checks.push(check_asset_freshness(
56 &contents,
57 agents_path,
58 freshness_against,
59 ));
60 checks.push(check_next_commands(&contents));
61 checks.push(check_next_steps_script(&contents, agents_path));
62 }
63
64 DoctorReport {
65 checks,
66 repair_plan: Vec::new(),
67 }
68}
69
70fn check_config(config_path: Option<&Path>) -> DoctorCheck {
71 match load_file_config(config_path) {
72 Ok(_) => DoctorCheck {
73 name: "Config".to_string(),
74 status: DoctorStatus::Pass,
75 detail: config_path
76 .map(|path| format!("Config is valid: {}", path.display()))
77 .unwrap_or_else(|| "Config is valid or not present".to_string()),
78 },
79 Err(err) => DoctorCheck {
80 name: "Config".to_string(),
81 status: DoctorStatus::Fail,
82 detail: err.to_string(),
83 },
84 }
85}
86
87fn check_agents_presence(agents_path: &Path) -> DoctorCheck {
88 if agents_path.exists() {
89 DoctorCheck {
90 name: "AGENTS.md".to_string(),
91 status: DoctorStatus::Pass,
92 detail: format!("Found {}", agents_path.display()),
93 }
94 } else {
95 DoctorCheck {
96 name: "AGENTS.md".to_string(),
97 status: DoctorStatus::Warn,
98 detail: format!("Missing {}", agents_path.display()),
99 }
100 }
101}
102
103fn check_referenced_assets(contents: &str, agents_path: &Path) -> DoctorCheck {
104 let referenced = extract_backticked_paths(contents);
105 if referenced.is_empty() {
106 return DoctorCheck {
107 name: "Referenced assets".to_string(),
108 status: DoctorStatus::Warn,
109 detail: "No referenced agent assets found in AGENTS.md".to_string(),
110 };
111 }
112
113 let mut missing = Vec::new();
114 for item in referenced {
115 let path = resolve_reference(agents_path, &item);
116 if !path.exists() {
117 missing.push(path.display().to_string());
118 }
119 }
120
121 if missing.is_empty() {
122 DoctorCheck {
123 name: "Referenced assets".to_string(),
124 status: DoctorStatus::Pass,
125 detail: "All referenced agent assets exist".to_string(),
126 }
127 } else {
128 DoctorCheck {
129 name: "Referenced assets".to_string(),
130 status: DoctorStatus::Fail,
131 detail: format!("Missing assets: {}", missing.join(", ")),
132 }
133 }
134}
135
136fn check_asset_freshness(
137 contents: &str,
138 agents_path: &Path,
139 freshness_against: Option<&Path>,
140) -> DoctorCheck {
141 let output_dir = agents_path.parent().unwrap_or_else(|| Path::new("."));
142 let Some((report_path, report_modified)) =
143 resolve_freshness_source(output_dir, freshness_against)
144 else {
145 return DoctorCheck {
146 name: "Asset freshness".to_string(),
147 status: DoctorStatus::Pass,
148 detail: "No report.json or report.sarif found; freshness check skipped".to_string(),
149 };
150 };
151
152 let mut stale = Vec::new();
153 for path in freshness_targets(contents, agents_path) {
154 let Ok(metadata) = std::fs::metadata(&path) else {
155 continue;
156 };
157 let Ok(modified) = metadata.modified() else {
158 continue;
159 };
160 if modified < report_modified {
161 stale.push(path.display().to_string());
162 }
163 }
164
165 if stale.is_empty() {
166 DoctorCheck {
167 name: "Asset freshness".to_string(),
168 status: DoctorStatus::Pass,
169 detail: format!(
170 "Generated assets are at least as new as {}",
171 report_path.display()
172 ),
173 }
174 } else {
175 DoctorCheck {
176 name: "Asset freshness".to_string(),
177 status: DoctorStatus::Warn,
178 detail: format!(
179 "These assets look older than {}: {}",
180 report_path.display(),
181 stale.join(", ")
182 ),
183 }
184 }
185}
186
187fn check_next_commands(contents: &str) -> DoctorCheck {
188 let commands = extract_voc_commands(contents);
189 if commands.is_empty() {
190 return DoctorCheck {
191 name: "Next commands".to_string(),
192 status: DoctorStatus::Warn,
193 detail: "No sample voc commands found in AGENTS.md".to_string(),
194 };
195 }
196
197 let malformed: Vec<String> = commands
198 .into_iter()
199 .filter(|line| !line.starts_with("voc "))
200 .collect();
201
202 if malformed.is_empty() {
203 DoctorCheck {
204 name: "Next commands".to_string(),
205 status: DoctorStatus::Pass,
206 detail: "Sample voc commands look valid".to_string(),
207 }
208 } else {
209 DoctorCheck {
210 name: "Next commands".to_string(),
211 status: DoctorStatus::Fail,
212 detail: format!("Malformed commands: {}", malformed.join(" | ")),
213 }
214 }
215}
216
217fn check_next_steps_script(contents: &str, agents_path: &Path) -> DoctorCheck {
218 let script_refs: Vec<String> = extract_backticked_paths(contents)
219 .into_iter()
220 .filter(|item| item.ends_with(".sh"))
221 .collect();
222
223 if script_refs.is_empty() {
224 return DoctorCheck {
225 name: "next-steps.sh".to_string(),
226 status: DoctorStatus::Warn,
227 detail: "No referenced next-steps.sh script found in AGENTS.md".to_string(),
228 };
229 }
230
231 let script_path = resolve_reference(agents_path, &script_refs[0]);
232 if !script_path.exists() {
233 return DoctorCheck {
234 name: "next-steps.sh".to_string(),
235 status: DoctorStatus::Fail,
236 detail: format!("Missing script: {}", script_path.display()),
237 };
238 }
239
240 let script = match std::fs::read_to_string(&script_path) {
241 Ok(script) => script,
242 Err(err) => {
243 return DoctorCheck {
244 name: "next-steps.sh".to_string(),
245 status: DoctorStatus::Fail,
246 detail: format!("Failed to read {}: {}", script_path.display(), err),
247 };
248 }
249 };
250
251 let commands = extract_script_voc_commands(&script);
252 if commands.is_empty() {
253 return DoctorCheck {
254 name: "next-steps.sh".to_string(),
255 status: DoctorStatus::Fail,
256 detail: format!("No voc commands found in {}", script_path.display()),
257 };
258 }
259
260 let malformed: Vec<String> = commands
261 .iter()
262 .filter(|line| !line.starts_with("voc "))
263 .cloned()
264 .collect();
265 if !malformed.is_empty() {
266 return DoctorCheck {
267 name: "next-steps.sh".to_string(),
268 status: DoctorStatus::Fail,
269 detail: format!("Malformed script commands: {}", malformed.join(" | ")),
270 };
271 }
272
273 let expects_pr_brief = contents.contains("pr-brief.md");
274 let expects_pr_comment = contents.contains("pr-comment.md");
275 let combined = commands.join("\n");
276 let mut missing_flags = Vec::new();
277 if expects_pr_brief && !combined.contains("--open-pr-brief") {
278 missing_flags.push("--open-pr-brief");
279 }
280 if expects_pr_comment && !combined.contains("--open-pr-comment") {
281 missing_flags.push("--open-pr-comment");
282 }
283
284 if !missing_flags.is_empty() {
285 return DoctorCheck {
286 name: "next-steps.sh".to_string(),
287 status: DoctorStatus::Fail,
288 detail: format!(
289 "Script is missing follow-up flags referenced by AGENTS.md: {}",
290 missing_flags.join(", ")
291 ),
292 };
293 }
294
295 DoctorCheck {
296 name: "next-steps.sh".to_string(),
297 status: DoctorStatus::Pass,
298 detail: format!("Shortcut script looks valid: {}", script_path.display()),
299 }
300}
301
302fn extract_backticked_paths(contents: &str) -> Vec<String> {
303 let mut items = Vec::new();
304 let mut in_tick = false;
305 let mut current = String::new();
306
307 for ch in contents.chars() {
308 if ch == '`' {
309 if in_tick {
310 if current.ends_with(".json")
311 || current.ends_with(".md")
312 || current.ends_with(".sh")
313 {
314 items.push(current.clone());
315 }
316 current.clear();
317 }
318 in_tick = !in_tick;
319 continue;
320 }
321 if in_tick {
322 current.push(ch);
323 }
324 }
325
326 items
327}
328
329fn freshness_targets(contents: &str, agents_path: &Path) -> Vec<PathBuf> {
330 let mut targets = vec![agents_path.to_path_buf()];
331 for item in extract_backticked_paths(contents) {
332 let path = resolve_reference(agents_path, &item);
333 if !targets.contains(&path) {
334 targets.push(path);
335 }
336 }
337 targets
338}
339
340fn latest_report_artifact(output_dir: &Path) -> Option<(PathBuf, SystemTime)> {
341 ["report.json", "report.sarif"]
342 .into_iter()
343 .filter_map(|name| {
344 let path = output_dir.join(name);
345 let modified = std::fs::metadata(&path).ok()?.modified().ok()?;
346 Some((path, modified))
347 })
348 .max_by_key(|(_, modified)| *modified)
349}
350
351fn resolve_freshness_source(
352 output_dir: &Path,
353 freshness_against: Option<&Path>,
354) -> Option<(PathBuf, SystemTime)> {
355 if let Some(path) = freshness_against {
356 let resolved = if path.is_absolute() {
357 path.to_path_buf()
358 } else {
359 output_dir.join(path)
360 };
361 let modified = std::fs::metadata(&resolved).ok()?.modified().ok()?;
362 return Some((resolved, modified));
363 }
364
365 latest_report_artifact(output_dir)
366}
367
368fn extract_voc_commands(contents: &str) -> Vec<String> {
369 let mut commands = Vec::new();
370 let mut in_block = false;
371 for line in contents.lines() {
372 if line.trim_start().starts_with("```") {
373 in_block = !in_block;
374 continue;
375 }
376 if in_block && line.trim_start().starts_with("voc ") {
377 commands.push(line.trim().to_string());
378 }
379 }
380 commands
381}
382
383fn extract_script_voc_commands(contents: &str) -> Vec<String> {
384 contents
385 .lines()
386 .map(str::trim)
387 .filter(|line| line.starts_with("voc "))
388 .map(ToString::to_string)
389 .collect()
390}
391
392fn resolve_reference(agents_path: &Path, reference: &str) -> PathBuf {
393 let ref_path = PathBuf::from(reference);
394 if ref_path.is_absolute() {
395 ref_path
396 } else {
397 agents_path
398 .parent()
399 .unwrap_or_else(|| Path::new("."))
400 .join(ref_path)
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::{run_doctor, DoctorStatus};
407 use std::fs;
408 use std::time::Duration;
409 use tempfile::tempdir;
410
411 #[test]
412 fn doctor_warns_when_agents_is_missing() {
413 let dir = tempdir().expect("temp dir");
414 let report = run_doctor(None, &dir.path().join("AGENTS.md"), None);
415
416 assert_eq!(report.checks[1].status, DoctorStatus::Warn);
417 }
418
419 #[test]
420 fn doctor_fails_when_referenced_assets_are_missing() {
421 let dir = tempdir().expect("temp dir");
422 let agents = dir.path().join("AGENTS.md");
423 fs::write(
424 &agents,
425 "### Current Project Risks\n\n- Agent bundle: `.verifyos-agent/agent-pack.json` and `.verifyos-agent/agent-pack.md`\n",
426 )
427 .expect("write agents");
428
429 let report = run_doctor(None, &agents, None);
430
431 assert!(report.has_failures());
432 assert_eq!(report.checks[2].status, DoctorStatus::Fail);
433 }
434
435 #[test]
436 fn doctor_warns_when_assets_are_older_than_report() {
437 let dir = tempdir().expect("temp dir");
438 let agents = dir.path().join("AGENTS.md");
439 let script_dir = dir.path().join(".verifyos-agent");
440 fs::create_dir_all(&script_dir).expect("create script dir");
441 fs::write(
442 script_dir.join("next-steps.sh"),
443 "voc --app app.ipa --profile basic\n",
444 )
445 .expect("write script");
446 fs::write(
447 &agents,
448 "## verifyOS-cli\n\n- Shortcut script: `.verifyos-agent/next-steps.sh`\n",
449 )
450 .expect("write agents");
451 std::thread::sleep(Duration::from_secs(1));
452 fs::write(dir.path().join("report.json"), "{}").expect("write report");
453
454 let report = run_doctor(None, &agents, None);
455
456 assert_eq!(report.checks[3].name, "Asset freshness");
457 assert_eq!(report.checks[3].status, DoctorStatus::Warn);
458 assert!(report.checks[3].detail.contains("report.json"));
459 }
460
461 #[test]
462 fn doctor_passes_when_assets_are_fresh_against_report() {
463 let dir = tempdir().expect("temp dir");
464 let agents = dir.path().join("AGENTS.md");
465 let script_dir = dir.path().join(".verifyos-agent");
466 fs::create_dir_all(&script_dir).expect("create script dir");
467 fs::write(dir.path().join("report.sarif"), "{}").expect("write report");
468 std::thread::sleep(Duration::from_secs(1));
469 fs::write(
470 script_dir.join("next-steps.sh"),
471 "voc --app app.ipa --profile basic\n",
472 )
473 .expect("write script");
474 fs::write(
475 &agents,
476 "## verifyOS-cli\n\n- Shortcut script: `.verifyos-agent/next-steps.sh`\n",
477 )
478 .expect("write agents");
479
480 let report = run_doctor(None, &agents, None);
481
482 assert_eq!(report.checks[3].name, "Asset freshness");
483 assert_eq!(report.checks[3].status, DoctorStatus::Pass);
484 }
485
486 #[test]
487 fn doctor_fails_when_next_steps_script_drifts_from_agents_block() {
488 let dir = tempdir().expect("temp dir");
489 let agents = dir.path().join("AGENTS.md");
490 let script_dir = dir.path().join(".verifyos-agent");
491 fs::create_dir_all(&script_dir).expect("create script dir");
492 fs::write(
493 script_dir.join("next-steps.sh"),
494 "#!/usr/bin/env bash\nset -euo pipefail\nvoc --app path/to/app.ipa --profile basic\nvoc doctor --output-dir .verifyos --fix --from-scan path/to/app.ipa --profile basic\n",
495 )
496 .expect("write script");
497 fs::write(
498 &agents,
499 "## verifyOS-cli\n\n- Shortcut script: `.verifyos-agent/next-steps.sh`\n- PR comment draft: `pr-comment.md`\n",
500 )
501 .expect("write agents");
502
503 let report = run_doctor(None, &agents, None);
504
505 assert!(report.has_failures());
506 assert_eq!(report.checks[5].status, DoctorStatus::Fail);
507 assert!(report.checks[5].detail.contains("--open-pr-comment"));
508 }
509
510 #[test]
511 fn doctor_passes_when_next_steps_script_matches_agents_block() {
512 let dir = tempdir().expect("temp dir");
513 let agents = dir.path().join("AGENTS.md");
514 let script_dir = dir.path().join(".verifyos-agent");
515 fs::create_dir_all(&script_dir).expect("create script dir");
516 fs::write(dir.path().join("pr-brief.md"), "brief").expect("write brief");
517 fs::write(dir.path().join("pr-comment.md"), "comment").expect("write comment");
518 fs::write(
519 script_dir.join("next-steps.sh"),
520 "#!/usr/bin/env bash\nset -euo pipefail\nvoc --app path/to/app.ipa --profile basic\nvoc doctor --output-dir .verifyos --fix --from-scan path/to/app.ipa --profile basic --open-pr-brief --open-pr-comment\n",
521 )
522 .expect("write script");
523 fs::write(
524 &agents,
525 "## verifyOS-cli\n\n- Shortcut script: `.verifyos-agent/next-steps.sh`\n- PR brief: `pr-brief.md`\n- PR comment draft: `pr-comment.md`\n",
526 )
527 .expect("write agents");
528
529 let report = run_doctor(None, &agents, None);
530
531 assert!(!report.has_failures());
532 assert_eq!(report.checks[5].status, DoctorStatus::Pass);
533 }
534}