1use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::AgentConfigError;
10use crate::plan::PlanTarget;
11use crate::status::{DriftIssue, InstallStatus, StatusReport, StatusWarning};
12use crate::util::{fs_atomic, md_block, ownership};
13
14#[must_use]
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct ValidationReport {
19 pub target: PlanTarget,
21 pub ok: bool,
23 pub issues: Vec<DriftIssue>,
25 pub suggested_actions: Vec<SuggestedAction>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum SuggestedAction {
33 Reinstall,
35 UninstallWithOwner,
37 RemoveLedgerEntry,
39 RemoveConfigEntry,
41 RestoreBackup,
43 ManualReview,
45 NoAction,
47}
48
49impl ValidationReport {
50 pub(crate) fn from_issues(target: PlanTarget, mut issues: Vec<DriftIssue>) -> Self {
51 sort_issues(&mut issues);
52 let ok = issues.is_empty();
53 let suggested_actions = suggested_actions_for(&issues);
54 Self {
55 target,
56 ok,
57 issues,
58 suggested_actions,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum Presence {
65 Present,
66 Absent,
67 Malformed,
68 Unknown,
69}
70
71pub(crate) fn hook_report_from_status(
72 target: PlanTarget,
73 status: StatusReport,
74) -> ValidationReport {
75 let mut issues = Vec::new();
76 match &status.status {
77 InstallStatus::Absent | InstallStatus::InstalledOwned { .. } => {}
78 InstallStatus::InstalledOtherOwner { owner } => {
79 let expected = match &target {
80 PlanTarget::Hook { tag, .. } => tag.clone(),
81 _ => String::new(),
82 };
83 push_issue(
84 &mut issues,
85 DriftIssue::OwnerMismatch {
86 expected,
87 actual: Some(owner.clone()),
88 path: status.ledger_path.clone(),
89 },
90 );
91 }
92 InstallStatus::PresentUnowned => {
93 push_issue(
94 &mut issues,
95 DriftIssue::ConfigOnly {
96 path: primary_path(&status),
97 },
98 );
99 }
100 InstallStatus::LedgerOnly { owner } => {
101 push_issue(
102 &mut issues,
103 DriftIssue::LedgerOnly {
104 path: status
105 .ledger_path
106 .clone()
107 .unwrap_or_else(|| primary_path(&status)),
108 owner: Some(owner.clone()),
109 },
110 );
111 }
112 InstallStatus::Drifted { issues: drift } => {
113 push_mapped_issues(&mut issues, drift);
114 }
115 InstallStatus::Unknown => {
116 push_issue(
117 &mut issues,
118 DriftIssue::UnexpectedDirectoryShape {
119 path: primary_path(&status),
120 reason: "status probe returned unknown".into(),
121 },
122 );
123 }
124 }
125 add_hook_ledger_issues(&mut issues, &target, &status);
126 add_markdown_fence_issues(&mut issues, &target, &status);
127 add_backup_issues(&mut issues, &status);
128 ValidationReport::from_issues(target, issues)
129}
130
131pub(crate) fn ledger_backed_report_from_status(
132 target: PlanTarget,
133 name: &str,
134 expected_owner: Option<&str>,
135 status: StatusReport,
136) -> Result<ValidationReport, AgentConfigError> {
137 let mut issues = ledger_backed_issues(name, expected_owner, &status)?;
138 add_backup_issues(&mut issues, &status);
139 Ok(ValidationReport::from_issues(target, issues))
140}
141
142pub(crate) fn skill_report_from_status(
143 target: PlanTarget,
144 name: &str,
145 expected_owner: Option<&str>,
146 status: StatusReport,
147) -> Result<ValidationReport, AgentConfigError> {
148 let mut issues = ledger_backed_issues(name, expected_owner, &status)?;
149 add_skill_shape_issues(&mut issues, &status)?;
150 add_backup_issues(&mut issues, &status);
151 Ok(ValidationReport::from_issues(target, issues))
152}
153
154pub(crate) fn malformed_ledger_report(
155 target: PlanTarget,
156 path: PathBuf,
157 reason: String,
158) -> ValidationReport {
159 ValidationReport::from_issues(target, vec![DriftIssue::MalformedLedger { path, reason }])
160}
161
162fn ledger_backed_issues(
163 name: &str,
164 expected_owner: Option<&str>,
165 status: &StatusReport,
166) -> Result<Vec<DriftIssue>, AgentConfigError> {
167 let mut issues = Vec::new();
168 if let InstallStatus::Drifted { issues: drift } = &status.status {
169 push_mapped_issues(&mut issues, drift);
170 }
171
172 let presence = presence_from_status(status);
173 let mut owner = None;
174 let mut ledger_malformed = false;
175
176 if let Some(ledger_path) = status.ledger_path.as_ref() {
177 match ownership::read_strict(ledger_path)? {
178 ownership::StrictLedgerRead::Missing => {}
179 ownership::StrictLedgerRead::Valid { entries } => {
180 owner = entries.get(name).cloned();
181 }
182 ownership::StrictLedgerRead::Malformed { reason } => {
183 ledger_malformed = true;
184 push_issue(
185 &mut issues,
186 DriftIssue::MalformedLedger {
187 path: ledger_path.clone(),
188 reason,
189 },
190 );
191 }
192 }
193 }
194
195 if presence == Presence::Malformed || ledger_malformed {
196 return Ok(issues);
197 }
198
199 match (presence, owner.as_ref()) {
200 (Presence::Present, None) => {
201 push_issue(
202 &mut issues,
203 DriftIssue::ConfigOnly {
204 path: primary_path(status),
205 },
206 );
207 }
208 (Presence::Absent, Some(owner)) => {
209 push_issue(
210 &mut issues,
211 DriftIssue::LedgerOnly {
212 path: status
213 .ledger_path
214 .clone()
215 .unwrap_or_else(|| primary_path(status)),
216 owner: Some(owner.owner.clone()),
217 },
218 );
219 }
220 _ => {}
221 }
222
223 if let (Some(expected), Some(actual)) =
224 (expected_owner, owner.as_ref().map(|e| e.owner.as_str()))
225 {
226 if expected != actual {
227 push_issue(
228 &mut issues,
229 DriftIssue::OwnerMismatch {
230 expected: expected.to_string(),
231 actual: Some(actual.to_string()),
232 path: status.ledger_path.clone(),
233 },
234 );
235 }
236 }
237
238 Ok(issues)
239}
240
241fn presence_from_status(status: &StatusReport) -> Presence {
242 match &status.status {
243 InstallStatus::InstalledOwned { .. }
244 | InstallStatus::InstalledOtherOwner { .. }
245 | InstallStatus::PresentUnowned => Presence::Present,
246 InstallStatus::Absent | InstallStatus::LedgerOnly { .. } => Presence::Absent,
247 InstallStatus::Drifted { issues } => {
248 if issues.iter().any(|issue| {
249 matches!(
250 issue,
251 DriftIssue::InvalidConfig { .. } | DriftIssue::MalformedConfig { .. }
252 )
253 }) {
254 Presence::Malformed
255 } else if issues.iter().any(|issue| {
256 matches!(
257 issue,
258 DriftIssue::MultipleEntries { .. }
259 | DriftIssue::SkillIncomplete { .. }
260 | DriftIssue::SkillMissingSkillMd { .. }
261 | DriftIssue::UnexpectedDirectoryShape { .. }
262 | DriftIssue::SkillAssetEscapesRoot { .. }
263 )
264 }) {
265 Presence::Present
266 } else {
267 Presence::Unknown
268 }
269 }
270 InstallStatus::Unknown => Presence::Unknown,
271 }
272}
273
274fn push_mapped_issues(out: &mut Vec<DriftIssue>, drift: &[DriftIssue]) {
275 for issue in drift {
276 match issue {
277 DriftIssue::InvalidConfig { path, reason } => {
278 push_issue(
279 out,
280 DriftIssue::MalformedConfig {
281 path: path.clone(),
282 reason: reason.clone(),
283 },
284 );
285 }
286 DriftIssue::SkillIncomplete { dir, missing } => {
287 push_issue(
288 out,
289 DriftIssue::SkillMissingSkillMd {
290 dir: dir.clone(),
291 missing: missing.clone(),
292 },
293 );
294 }
295 other => push_issue(out, other.clone()),
296 }
297 }
298}
299
300fn add_hook_ledger_issues(
301 issues: &mut Vec<DriftIssue>,
302 target: &PlanTarget,
303 status: &StatusReport,
304) {
305 let (PlanTarget::Hook { tag, .. }, Some(ledger_path)) = (target, status.ledger_path.as_ref())
306 else {
307 return;
308 };
309 match ownership::read_strict(ledger_path) {
310 Ok(ownership::StrictLedgerRead::Missing) => {}
311 Ok(ownership::StrictLedgerRead::Malformed { reason }) => {
312 push_issue(
313 issues,
314 DriftIssue::MalformedLedger {
315 path: ledger_path.clone(),
316 reason,
317 },
318 );
319 }
320 Ok(ownership::StrictLedgerRead::Valid { entries }) => {
321 let Some(hooks_dir) = ledger_path.parent() else {
322 return;
323 };
324 for (filename, entry) in entries {
325 if entry.owner != *tag {
326 continue;
327 }
328 let script = hooks_dir.join(&filename);
329 if !script.exists() {
330 push_issue(
331 issues,
332 DriftIssue::LedgerOnly {
333 path: ledger_path.clone(),
334 owner: Some(entry.owner.clone()),
335 },
336 );
337 continue;
338 }
339 add_unix_executable_issue(issues, &script);
340 }
341 }
342 Err(e) => {
343 push_issue(
344 issues,
345 DriftIssue::MalformedLedger {
346 path: ledger_path.clone(),
347 reason: e.to_string(),
348 },
349 );
350 }
351 }
352}
353
354fn add_markdown_fence_issues(
355 issues: &mut Vec<DriftIssue>,
356 target: &PlanTarget,
357 status: &StatusReport,
358) {
359 let PlanTarget::Hook { tag, .. } = target else {
360 return;
361 };
362 let path = primary_path(status);
363 let text = match fs_atomic::read_to_string_or_empty(&path) {
364 Ok(text) => text,
365 Err(e) => {
366 push_issue(
367 issues,
368 DriftIssue::MalformedConfig {
369 path,
370 reason: format!("could not read hook markdown: {e}"),
371 },
372 );
373 return;
374 }
375 };
376 if md_block::malformed(&text, tag) {
377 push_issue(
378 issues,
379 DriftIssue::MalformedConfig {
380 path,
381 reason: "malformed agent-config markdown fence".into(),
382 },
383 );
384 }
385}
386
387#[cfg(unix)]
388fn add_unix_executable_issue(issues: &mut Vec<DriftIssue>, path: &Path) {
389 use std::os::unix::fs::PermissionsExt;
390
391 match fs::metadata(path) {
392 Ok(metadata) if metadata.permissions().mode() & 0o111 == 0 => {
393 push_issue(
394 issues,
395 DriftIssue::UnexpectedDirectoryShape {
396 path: path.to_path_buf(),
397 reason: "hook script is not executable".into(),
398 },
399 );
400 }
401 Ok(_) => {}
402 Err(e) => {
403 push_issue(
404 issues,
405 DriftIssue::UnexpectedDirectoryShape {
406 path: path.to_path_buf(),
407 reason: format!("could not inspect hook script permissions: {e}"),
408 },
409 );
410 }
411 }
412}
413
414#[cfg(not(unix))]
415fn add_unix_executable_issue(_issues: &mut Vec<DriftIssue>, _path: &Path) {}
416
417fn add_skill_shape_issues(
418 issues: &mut Vec<DriftIssue>,
419 status: &StatusReport,
420) -> Result<(), AgentConfigError> {
421 let dir = primary_path(status);
422 if !dir.exists() {
423 return Ok(());
424 }
425
426 let metadata = fs::symlink_metadata(&dir).map_err(|e| AgentConfigError::io(&dir, e))?;
427 if !metadata.is_dir() {
428 push_issue(
429 issues,
430 DriftIssue::UnexpectedDirectoryShape {
431 path: dir,
432 reason: "skill path exists but is not a directory".into(),
433 },
434 );
435 return Ok(());
436 }
437
438 let manifest = dir.join("SKILL.md");
439 if !manifest.is_file() {
440 push_issue(
441 issues,
442 DriftIssue::SkillMissingSkillMd {
443 dir: dir.clone(),
444 missing: manifest,
445 },
446 );
447 }
448
449 let canonical_root = match fs::canonicalize(&dir) {
450 Ok(path) => path,
451 Err(e) => {
452 push_issue(
453 issues,
454 DriftIssue::UnexpectedDirectoryShape {
455 path: dir,
456 reason: format!("could not canonicalize skill directory: {e}"),
457 },
458 );
459 return Ok(());
460 }
461 };
462 walk_skill_dir(&canonical_root, &canonical_root, issues)?;
463 Ok(())
464}
465
466fn walk_skill_dir(
467 dir: &Path,
468 canonical_root: &Path,
469 issues: &mut Vec<DriftIssue>,
470) -> Result<(), AgentConfigError> {
471 for entry in fs::read_dir(dir).map_err(|e| AgentConfigError::io(dir, e))? {
475 let entry = entry.map_err(|e| AgentConfigError::io(dir, e))?;
476 let path = entry.path();
477 let metadata = fs::symlink_metadata(&path).map_err(|e| AgentConfigError::io(&path, e))?;
478
479 if metadata.file_type().is_symlink() {
480 match fs::canonicalize(&path) {
481 Ok(canonical) if !canonical.starts_with(canonical_root) => {
482 push_issue(
483 issues,
484 DriftIssue::SkillAssetEscapesRoot {
485 path: path.clone(),
486 root: canonical_root.to_path_buf(),
487 },
488 );
489 }
490 Ok(_) => {}
491 Err(e) => {
492 push_issue(
493 issues,
494 DriftIssue::UnexpectedDirectoryShape {
495 path: path.clone(),
496 reason: format!("could not canonicalize skill entry: {e}"),
497 },
498 );
499 }
500 }
501 }
502
503 if metadata.is_dir() && !metadata.file_type().is_symlink() {
504 walk_skill_dir(&path, canonical_root, issues)?;
505 }
506 }
507 Ok(())
508}
509
510fn add_backup_issues(issues: &mut Vec<DriftIssue>, status: &StatusReport) {
511 if let Some(config_path) = status.config_path.as_ref() {
512 let backup = fs_atomic::backup_path(config_path);
513 if backup.exists() {
514 let issue = if config_path.exists() {
515 DriftIssue::BackupCollision { path: backup }
516 } else {
517 DriftIssue::StaleBackup { path: backup }
518 };
519 push_issue(issues, issue);
520 }
521 }
522
523 for warning in &status.warnings {
524 if let StatusWarning::BackupExists { path } = warning {
525 let issue = if status.config_path.as_ref().is_some_and(|p| p.exists()) {
526 DriftIssue::BackupCollision { path: path.clone() }
527 } else {
528 DriftIssue::StaleBackup { path: path.clone() }
529 };
530 push_issue(issues, issue);
531 }
532 }
533}
534
535fn primary_path(status: &StatusReport) -> PathBuf {
536 status
537 .config_path
538 .clone()
539 .or_else(|| status.files.first().map(path_from_status))
540 .unwrap_or_default()
541}
542
543fn path_from_status(path_status: &crate::status::PathStatus) -> PathBuf {
544 match path_status {
545 crate::status::PathStatus::Missing { path }
546 | crate::status::PathStatus::Exists { path }
547 | crate::status::PathStatus::Invalid { path, .. } => path.clone(),
548 }
549}
550
551fn push_issue(issues: &mut Vec<DriftIssue>, issue: DriftIssue) {
552 if !issues.contains(&issue) {
553 issues.push(issue);
554 }
555}
556
557fn sort_issues(issues: &mut [DriftIssue]) {
558 issues.sort_by_cached_key(|issue| (issue_rank(issue), issue_path(issue)));
561}
562
563fn issue_rank(issue: &DriftIssue) -> u8 {
564 match issue {
565 DriftIssue::MalformedConfig { .. } | DriftIssue::InvalidConfig { .. } => 0,
566 DriftIssue::MalformedLedger { .. } => 1,
567 DriftIssue::LedgerOnly { .. } => 2,
568 DriftIssue::ConfigOnly { .. } => 3,
569 DriftIssue::OwnerMismatch { .. } => 4,
570 DriftIssue::MultipleEntries { .. } => 5,
571 DriftIssue::UnexpectedDirectoryShape { .. } => 6,
572 DriftIssue::SkillMissingSkillMd { .. } | DriftIssue::SkillIncomplete { .. } => 7,
573 DriftIssue::SkillAssetEscapesRoot { .. } => 8,
574 DriftIssue::InstructionContentDrift { .. } => 9,
575 DriftIssue::BackupCollision { .. } => 10,
576 DriftIssue::MissingBackup { .. } => 11,
577 DriftIssue::StaleBackup { .. } => 12,
578 DriftIssue::UnsupportedButPresent { .. } => 13,
579 }
580}
581
582fn issue_path(issue: &DriftIssue) -> String {
583 let path = match issue {
584 DriftIssue::LedgerOnly { path, .. }
585 | DriftIssue::ConfigOnly { path }
586 | DriftIssue::MalformedConfig { path, .. }
587 | DriftIssue::MalformedLedger { path, .. }
588 | DriftIssue::BackupCollision { path }
589 | DriftIssue::MissingBackup { path }
590 | DriftIssue::StaleBackup { path }
591 | DriftIssue::UnexpectedDirectoryShape { path, .. }
592 | DriftIssue::SkillAssetEscapesRoot { path, .. }
593 | DriftIssue::UnsupportedButPresent { path }
594 | DriftIssue::InstructionContentDrift { path }
595 | DriftIssue::InvalidConfig { path, .. } => path.display().to_string(),
596 DriftIssue::OwnerMismatch { path, .. } => path
597 .as_ref()
598 .map(|p| p.display().to_string())
599 .unwrap_or_default(),
600 DriftIssue::SkillMissingSkillMd { missing, .. }
601 | DriftIssue::SkillIncomplete { missing, .. } => missing.display().to_string(),
602 DriftIssue::MultipleEntries { name, .. } => name.clone(),
603 };
604 path
605}
606
607fn suggested_actions_for(issues: &[DriftIssue]) -> Vec<SuggestedAction> {
608 let mut actions = Vec::new();
609 for issue in issues {
610 match issue {
611 DriftIssue::LedgerOnly { .. } => {
612 push_action(&mut actions, SuggestedAction::Reinstall);
613 push_action(&mut actions, SuggestedAction::RemoveLedgerEntry);
614 }
615 DriftIssue::ConfigOnly { .. } => {
616 push_action(&mut actions, SuggestedAction::RemoveConfigEntry);
617 }
618 DriftIssue::OwnerMismatch { .. } => {
619 push_action(&mut actions, SuggestedAction::UninstallWithOwner);
620 }
621 DriftIssue::BackupCollision { .. }
622 | DriftIssue::MissingBackup { .. }
623 | DriftIssue::StaleBackup { .. } => {
624 push_action(&mut actions, SuggestedAction::RestoreBackup);
625 }
626 DriftIssue::MalformedConfig { .. }
627 | DriftIssue::MalformedLedger { .. }
628 | DriftIssue::UnexpectedDirectoryShape { .. }
629 | DriftIssue::SkillMissingSkillMd { .. }
630 | DriftIssue::SkillAssetEscapesRoot { .. }
631 | DriftIssue::UnsupportedButPresent { .. }
632 | DriftIssue::InvalidConfig { .. }
633 | DriftIssue::SkillIncomplete { .. }
634 | DriftIssue::MultipleEntries { .. }
635 | DriftIssue::InstructionContentDrift { .. } => {
636 push_action(&mut actions, SuggestedAction::ManualReview);
637 }
638 }
639 }
640 if actions.is_empty() {
641 actions.push(SuggestedAction::NoAction);
642 }
643 actions
644}
645
646fn push_action(actions: &mut Vec<SuggestedAction>, action: SuggestedAction) {
647 if !actions.contains(&action) {
648 actions.push(action);
649 }
650}