Skip to main content

verifyos_cli/
doctor.rs

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}