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}