Skip to main content

verifyos_cli/
doctor.rs

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