1use std::path::{Path, PathBuf};
15
16use crate::error::AgentConfigError;
17use crate::util::{fs_atomic, md_block};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum PlanTarget {
23 Hook {
25 tag: String,
27 },
28 Mcp {
30 name: String,
32 },
33 Skill {
35 name: String,
37 },
38 Instruction {
40 name: String,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum InstallStatus {
53 Absent,
55 InstalledOwned {
57 owner: String,
60 },
61 InstalledOtherOwner {
64 owner: String,
66 },
67 PresentUnowned,
71 LedgerOnly {
75 owner: String,
77 },
78 Drifted {
83 issues: Vec<DriftIssue>,
85 },
86 Unknown,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum DriftIssue {
97 LedgerOnly {
100 path: PathBuf,
102 owner: Option<String>,
104 },
105 ConfigOnly {
108 path: PathBuf,
110 },
111 OwnerMismatch {
114 expected: String,
116 actual: Option<String>,
118 path: Option<PathBuf>,
120 },
121 MalformedConfig {
124 path: PathBuf,
126 reason: String,
128 },
129 MalformedLedger {
131 path: PathBuf,
133 reason: String,
135 },
136 BackupCollision {
138 path: PathBuf,
140 },
141 MissingBackup {
143 path: PathBuf,
145 },
146 StaleBackup {
149 path: PathBuf,
151 },
152 UnexpectedDirectoryShape {
154 path: PathBuf,
156 reason: String,
158 },
159 SkillMissingSkillMd {
162 dir: PathBuf,
164 missing: PathBuf,
166 },
167 SkillAssetEscapesRoot {
169 path: PathBuf,
171 root: PathBuf,
173 },
174 UnsupportedButPresent {
176 path: PathBuf,
178 },
179 SkillIncomplete {
182 dir: PathBuf,
184 missing: PathBuf,
186 },
187 InstructionContentDrift {
190 path: PathBuf,
192 },
193 InvalidConfig {
196 path: PathBuf,
198 reason: String,
200 },
201 MultipleEntries {
205 name: String,
207 count: usize,
209 },
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum StatusWarning {
217 BackupExists {
220 path: PathBuf,
222 },
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227#[non_exhaustive]
228pub enum PathStatus {
229 Missing {
231 path: PathBuf,
233 },
234 Exists {
236 path: PathBuf,
238 },
239 Invalid {
242 path: PathBuf,
244 reason: String,
246 },
247}
248
249#[must_use]
252#[derive(Debug, Clone)]
253#[non_exhaustive]
254pub struct StatusReport {
255 pub target: PlanTarget,
257 pub status: InstallStatus,
259 pub config_path: Option<PathBuf>,
263 pub ledger_path: Option<PathBuf>,
267 pub files: Vec<PathStatus>,
270 pub warnings: Vec<StatusWarning>,
272}
273
274#[derive(Debug, Clone)]
280pub(crate) enum ConfigPresence {
281 Absent,
283 Single,
285 Duplicate {
287 count: usize,
289 },
290 Invalid {
293 reason: String,
295 },
296}
297
298impl StatusReport {
299 pub(crate) fn for_mcp(
302 name: &str,
303 config_path: PathBuf,
304 ledger_path: PathBuf,
305 presence: ConfigPresence,
306 expected_owner: &str,
307 recorded_owner: Option<String>,
308 ) -> Self {
309 let target = PlanTarget::Mcp {
310 name: name.to_string(),
311 };
312 Self::assemble(
313 target,
314 Some(config_path),
315 Some(ledger_path),
316 presence,
317 expected_owner,
318 recorded_owner,
319 Vec::new(),
320 )
321 }
322
323 pub(crate) fn for_tagged_hook(
331 tag: &str,
332 config_path: PathBuf,
333 presence: ConfigPresence,
334 ) -> Self {
335 let target = PlanTarget::Hook {
336 tag: tag.to_string(),
337 };
338 let mut files = Vec::new();
341 let mut warnings = Vec::new();
342 let status = match presence {
343 ConfigPresence::Single => {
344 files.push(PathStatus::Exists {
345 path: config_path.clone(),
346 });
347 InstallStatus::InstalledOwned {
348 owner: tag.to_string(),
349 }
350 }
351 ConfigPresence::Duplicate { count } => {
352 files.push(PathStatus::Exists {
353 path: config_path.clone(),
354 });
355 InstallStatus::Drifted {
356 issues: vec![DriftIssue::MultipleEntries {
357 name: tag.to_string(),
358 count,
359 }],
360 }
361 }
362 ConfigPresence::Invalid { reason } => {
363 files.push(PathStatus::Invalid {
364 path: config_path.clone(),
365 reason: reason.clone(),
366 });
367 InstallStatus::Drifted {
368 issues: vec![DriftIssue::InvalidConfig {
369 path: config_path.clone(),
370 reason,
371 }],
372 }
373 }
374 ConfigPresence::Absent => {
375 if config_path.exists() {
376 files.push(PathStatus::Exists {
377 path: config_path.clone(),
378 });
379 } else {
380 files.push(PathStatus::Missing {
381 path: config_path.clone(),
382 });
383 }
384 check_backup(&config_path, &mut warnings);
385 InstallStatus::Absent
386 }
387 };
388 Self {
389 target,
390 status,
391 config_path: Some(config_path),
392 ledger_path: None,
393 files,
394 warnings,
395 }
396 }
397
398 pub(crate) fn for_file_hook(tag: &str, file_path: PathBuf) -> Self {
401 let target = PlanTarget::Hook {
402 tag: tag.to_string(),
403 };
404 let exists = file_path.exists();
405 let mut files = Vec::new();
406 let mut warnings = Vec::new();
407 let status = if exists {
408 files.push(PathStatus::Exists {
409 path: file_path.clone(),
410 });
411 InstallStatus::InstalledOwned {
412 owner: tag.to_string(),
413 }
414 } else {
415 files.push(PathStatus::Missing {
416 path: file_path.clone(),
417 });
418 check_backup(&file_path, &mut warnings);
419 InstallStatus::Absent
420 };
421 Self {
422 target,
423 status,
424 config_path: Some(file_path),
425 ledger_path: None,
426 files,
427 warnings,
428 }
429 }
430
431 pub(crate) fn for_markdown_block_hook(
434 tag: &str,
435 file_path: PathBuf,
436 ) -> Result<Self, AgentConfigError> {
437 let target = PlanTarget::Hook {
438 tag: tag.to_string(),
439 };
440 let exists = file_path.exists();
441 let mut files = Vec::new();
442 let mut warnings = Vec::new();
443
444 let status = if exists {
445 let host = fs_atomic::read_to_string_or_empty(&file_path)?;
446 if md_block::malformed(&host, tag) {
447 files.push(PathStatus::Invalid {
448 path: file_path.clone(),
449 reason: "malformed agent-config markdown fence".into(),
450 });
451 InstallStatus::Drifted {
452 issues: vec![DriftIssue::MalformedConfig {
453 path: file_path.clone(),
454 reason: "malformed agent-config markdown fence".into(),
455 }],
456 }
457 } else {
458 files.push(PathStatus::Exists {
459 path: file_path.clone(),
460 });
461 if md_block::contains(&host, tag) {
462 InstallStatus::InstalledOwned {
463 owner: tag.to_string(),
464 }
465 } else {
466 InstallStatus::Absent
467 }
468 }
469 } else {
470 files.push(PathStatus::Missing {
471 path: file_path.clone(),
472 });
473 check_backup(&file_path, &mut warnings);
474 InstallStatus::Absent
475 };
476
477 Ok(Self {
478 target,
479 status,
480 config_path: Some(file_path),
481 ledger_path: None,
482 files,
483 warnings,
484 })
485 }
486
487 pub(crate) fn for_skill(
491 name: &str,
492 skill_dir: PathBuf,
493 manifest_path: PathBuf,
494 ledger_path: PathBuf,
495 expected_owner: &str,
496 recorded_owner: Option<String>,
497 ) -> Self {
498 let target = PlanTarget::Skill {
499 name: name.to_string(),
500 };
501 let dir_exists = skill_dir.exists();
502 let manifest_exists = manifest_path.exists();
503 let mut extra_drift = Vec::new();
504 let presence = if dir_exists {
505 if !manifest_exists {
506 extra_drift.push(DriftIssue::SkillIncomplete {
507 dir: skill_dir.clone(),
508 missing: manifest_path.clone(),
509 });
510 }
511 ConfigPresence::Single
512 } else {
513 ConfigPresence::Absent
514 };
515
516 let mut report = Self::assemble(
517 target,
518 Some(skill_dir.clone()),
519 Some(ledger_path),
520 presence,
521 expected_owner,
522 recorded_owner,
523 extra_drift,
524 );
525
526 report.files.clear();
529 if dir_exists {
530 report.files.push(PathStatus::Exists {
531 path: skill_dir.clone(),
532 });
533 report.files.push(if manifest_exists {
534 PathStatus::Exists {
535 path: manifest_path,
536 }
537 } else {
538 PathStatus::Missing {
539 path: manifest_path,
540 }
541 });
542 } else {
543 report.files.push(PathStatus::Missing { path: skill_dir });
544 report.files.push(PathStatus::Missing {
545 path: manifest_path,
546 });
547 }
548 report
549 }
550
551 pub(crate) fn for_instruction(
554 name: &str,
555 instruction_path: PathBuf,
556 ledger_path: PathBuf,
557 presence: ConfigPresence,
558 expected_owner: &str,
559 recorded_owner: Option<String>,
560 ) -> Self {
561 let target = PlanTarget::Instruction {
562 name: name.to_string(),
563 };
564 Self::assemble(
565 target,
566 Some(instruction_path),
567 Some(ledger_path),
568 presence,
569 expected_owner,
570 recorded_owner,
571 Vec::new(),
572 )
573 }
574
575 fn assemble(
579 target: PlanTarget,
580 config_path: Option<PathBuf>,
581 ledger_path: Option<PathBuf>,
582 presence: ConfigPresence,
583 expected_owner: &str,
584 recorded_owner: Option<String>,
585 mut extra_drift: Vec<DriftIssue>,
586 ) -> Self {
587 let mut files = Vec::new();
588 let mut warnings = Vec::new();
589
590 if let Some(p) = config_path.as_ref() {
591 files.push(if p.exists() {
592 PathStatus::Exists { path: p.clone() }
593 } else {
594 PathStatus::Missing { path: p.clone() }
595 });
596 }
597 if let Some(p) = ledger_path.as_ref() {
598 files.push(if p.exists() {
599 PathStatus::Exists { path: p.clone() }
600 } else {
601 PathStatus::Missing { path: p.clone() }
602 });
603 }
604
605 let mut status = match (&presence, recorded_owner.as_deref()) {
608 (ConfigPresence::Invalid { reason }, _) => {
609 if let Some(p) = config_path.as_ref() {
610 if let Some(slot) = files
611 .iter_mut()
612 .find(|f| matches!(f, PathStatus::Exists { path } if path == p))
613 {
614 *slot = PathStatus::Invalid {
615 path: p.clone(),
616 reason: reason.clone(),
617 };
618 }
619 }
620 let mut issues = std::mem::take(&mut extra_drift);
621 issues.push(DriftIssue::InvalidConfig {
622 path: config_path.clone().unwrap_or_default(),
623 reason: reason.clone(),
624 });
625 InstallStatus::Drifted { issues }
626 }
627 (ConfigPresence::Duplicate { count }, _) => {
628 let target_name = match &target {
629 PlanTarget::Hook { tag } => tag.clone(),
630 PlanTarget::Mcp { name }
631 | PlanTarget::Skill { name }
632 | PlanTarget::Instruction { name } => name.clone(),
633 };
634 let mut issues = std::mem::take(&mut extra_drift);
635 issues.push(DriftIssue::MultipleEntries {
636 name: target_name,
637 count: *count,
638 });
639 InstallStatus::Drifted { issues }
640 }
641 (ConfigPresence::Single, Some(owner)) if owner == expected_owner => {
642 InstallStatus::InstalledOwned {
643 owner: owner.to_string(),
644 }
645 }
646 (ConfigPresence::Single, Some(owner)) => InstallStatus::InstalledOtherOwner {
647 owner: owner.to_string(),
648 },
649 (ConfigPresence::Single, None) => InstallStatus::PresentUnowned,
650 (ConfigPresence::Absent, Some(owner)) => InstallStatus::LedgerOnly {
651 owner: owner.to_string(),
652 },
653 (ConfigPresence::Absent, None) => InstallStatus::Absent,
654 };
655
656 if !extra_drift.is_empty() {
659 let mut issues = extra_drift;
660 if let InstallStatus::Drifted { issues: existing } = &mut status {
661 std::mem::swap(existing, &mut issues);
662 existing.extend(issues);
663 } else {
664 status = InstallStatus::Drifted { issues };
665 }
666 }
667
668 if matches!(status, InstallStatus::Absent) {
669 if let Some(p) = config_path.as_ref() {
670 check_backup(p, &mut warnings);
671 }
672 }
673
674 Self {
675 target,
676 status,
677 config_path,
678 ledger_path,
679 files,
680 warnings,
681 }
682 }
683}
684
685fn check_backup(path: &Path, warnings: &mut Vec<StatusWarning>) {
687 let mut bak = path.to_path_buf();
688 let name = bak
689 .file_name()
690 .map(|n| n.to_os_string())
691 .unwrap_or_default();
692 let mut name = name.into_string().unwrap_or_default();
693 if name.is_empty() {
694 return;
695 }
696 name.push_str(".bak");
697 bak.set_file_name(name);
698 if bak.exists() {
699 warnings.push(StatusWarning::BackupExists { path: bak });
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use tempfile::tempdir;
707
708 #[test]
709 fn for_mcp_owned_when_owner_matches() {
710 let dir = tempdir().unwrap();
711 let cfg = dir.path().join("mcp.json");
712 let led = dir.path().join(".agent-config-mcp.json");
713 std::fs::write(&cfg, b"{}").unwrap();
714 std::fs::write(&led, b"{}").unwrap();
715 let r = StatusReport::for_mcp(
716 "github",
717 cfg.clone(),
718 led,
719 ConfigPresence::Single,
720 "myapp",
721 Some("myapp".into()),
722 );
723 assert!(matches!(
724 r.status,
725 InstallStatus::InstalledOwned { ref owner } if owner == "myapp"
726 ));
727 assert_eq!(
728 r.target,
729 PlanTarget::Mcp {
730 name: "github".into()
731 }
732 );
733 }
734
735 #[test]
736 fn for_mcp_other_owner_when_recorded_differs() {
737 let dir = tempdir().unwrap();
738 let cfg = dir.path().join("mcp.json");
739 let led = dir.path().join(".agent-config-mcp.json");
740 let r = StatusReport::for_mcp(
741 "github",
742 cfg,
743 led,
744 ConfigPresence::Single,
745 "myapp",
746 Some("otherapp".into()),
747 );
748 assert!(matches!(
749 r.status,
750 InstallStatus::InstalledOtherOwner { ref owner } if owner == "otherapp"
751 ));
752 }
753
754 #[test]
755 fn for_mcp_present_unowned_when_no_ledger_record() {
756 let dir = tempdir().unwrap();
757 let r = StatusReport::for_mcp(
758 "github",
759 dir.path().join("mcp.json"),
760 dir.path().join("ledger.json"),
761 ConfigPresence::Single,
762 "myapp",
763 None,
764 );
765 assert!(matches!(r.status, InstallStatus::PresentUnowned));
766 }
767
768 #[test]
769 fn for_mcp_ledger_only_when_config_absent() {
770 let dir = tempdir().unwrap();
771 let r = StatusReport::for_mcp(
772 "github",
773 dir.path().join("mcp.json"),
774 dir.path().join("ledger.json"),
775 ConfigPresence::Absent,
776 "myapp",
777 Some("myapp".into()),
778 );
779 assert!(matches!(
780 r.status,
781 InstallStatus::LedgerOnly { ref owner } if owner == "myapp"
782 ));
783 }
784
785 #[test]
786 fn for_mcp_absent_when_neither_present() {
787 let dir = tempdir().unwrap();
788 let r = StatusReport::for_mcp(
789 "github",
790 dir.path().join("mcp.json"),
791 dir.path().join("ledger.json"),
792 ConfigPresence::Absent,
793 "myapp",
794 None,
795 );
796 assert!(matches!(r.status, InstallStatus::Absent));
797 }
798
799 #[test]
800 fn for_mcp_drifted_on_invalid_config() {
801 let dir = tempdir().unwrap();
802 let cfg = dir.path().join("mcp.json");
803 std::fs::write(&cfg, b"{not valid").unwrap();
804 let r = StatusReport::for_mcp(
805 "github",
806 cfg.clone(),
807 dir.path().join("ledger.json"),
808 ConfigPresence::Invalid {
809 reason: "expected `:` at line 1".into(),
810 },
811 "myapp",
812 None,
813 );
814 let issues = match &r.status {
815 InstallStatus::Drifted { issues } => issues,
816 other => panic!("expected Drifted, got {other:?}"),
817 };
818 assert!(matches!(issues[0], DriftIssue::InvalidConfig { .. }));
819 }
820
821 #[test]
822 fn for_skill_incomplete_when_manifest_missing() {
823 let dir = tempdir().unwrap();
824 let skill_dir = dir.path().join("alpha");
825 std::fs::create_dir_all(&skill_dir).unwrap();
826 let manifest = skill_dir.join("SKILL.md");
827 let r = StatusReport::for_skill(
828 "alpha",
829 skill_dir,
830 manifest,
831 dir.path().join("ledger.json"),
832 "myapp",
833 Some("myapp".into()),
834 );
835 let issues = match &r.status {
836 InstallStatus::Drifted { issues } => issues,
837 other => panic!("expected Drifted, got {other:?}"),
838 };
839 assert!(matches!(issues[0], DriftIssue::SkillIncomplete { .. }));
840 }
841
842 #[test]
843 fn backup_warning_emitted_when_bak_exists() {
844 let dir = tempdir().unwrap();
845 let cfg = dir.path().join("mcp.json");
846 std::fs::write(dir.path().join("mcp.json.bak"), b"{}").unwrap();
847 let r = StatusReport::for_mcp(
848 "github",
849 cfg,
850 dir.path().join("ledger.json"),
851 ConfigPresence::Absent,
852 "myapp",
853 None,
854 );
855 assert!(r
856 .warnings
857 .iter()
858 .any(|w| matches!(w, StatusWarning::BackupExists { .. })));
859 }
860
861 #[test]
862 fn tagged_hook_owned_when_present() {
863 let dir = tempdir().unwrap();
864 let cfg = dir.path().join("settings.json");
865 std::fs::write(&cfg, b"{}").unwrap();
866 let r = StatusReport::for_tagged_hook("alpha", cfg, ConfigPresence::Single);
867 assert!(matches!(
868 r.status,
869 InstallStatus::InstalledOwned { ref owner } if owner == "alpha"
870 ));
871 assert!(r.ledger_path.is_none());
872 }
873
874 #[test]
875 fn tagged_hook_drifted_on_invalid_config() {
876 let dir = tempdir().unwrap();
877 let r = StatusReport::for_tagged_hook(
878 "alpha",
879 dir.path().join("settings.json"),
880 ConfigPresence::Invalid {
881 reason: "broken".into(),
882 },
883 );
884 assert!(matches!(r.status, InstallStatus::Drifted { .. }));
885 }
886
887 #[test]
888 fn file_hook_present_when_path_exists() {
889 let dir = tempdir().unwrap();
890 let p = dir.path().join("alpha.md");
891 std::fs::write(&p, b"x").unwrap();
892 let r = StatusReport::for_file_hook("alpha", p);
893 assert!(matches!(r.status, InstallStatus::InstalledOwned { .. }));
894 }
895
896 #[test]
897 fn file_hook_absent_when_missing() {
898 let dir = tempdir().unwrap();
899 let r = StatusReport::for_file_hook("alpha", dir.path().join("alpha.md"));
900 assert!(matches!(r.status, InstallStatus::Absent));
901 }
902}