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 DualHooksExist {
225 hooks_json: PathBuf,
227 config_toml: PathBuf,
229 },
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234#[non_exhaustive]
235pub enum PathStatus {
236 Missing {
238 path: PathBuf,
240 },
241 Exists {
243 path: PathBuf,
245 },
246 Invalid {
249 path: PathBuf,
251 reason: String,
253 },
254}
255
256#[must_use]
259#[derive(Debug, Clone)]
260#[non_exhaustive]
261pub struct StatusReport {
262 pub target: PlanTarget,
264 pub status: InstallStatus,
266 pub config_path: Option<PathBuf>,
270 pub ledger_path: Option<PathBuf>,
274 pub files: Vec<PathStatus>,
277 pub warnings: Vec<StatusWarning>,
279}
280
281#[derive(Debug, Clone)]
287pub(crate) enum ConfigPresence {
288 Absent,
290 Single,
292 Duplicate {
294 count: usize,
296 },
297 Invalid {
300 reason: String,
302 },
303}
304
305impl StatusReport {
306 pub(crate) fn for_mcp(
309 name: &str,
310 config_path: PathBuf,
311 ledger_path: PathBuf,
312 presence: ConfigPresence,
313 expected_owner: &str,
314 recorded_owner: Option<String>,
315 ) -> Self {
316 let target = PlanTarget::Mcp {
317 name: name.to_string(),
318 };
319 Self::assemble(
320 target,
321 Some(config_path),
322 Some(ledger_path),
323 presence,
324 expected_owner,
325 recorded_owner,
326 Vec::new(),
327 )
328 }
329
330 pub(crate) fn for_tagged_hook(
338 tag: &str,
339 config_path: PathBuf,
340 presence: ConfigPresence,
341 ) -> Self {
342 let target = PlanTarget::Hook {
343 tag: tag.to_string(),
344 };
345 let mut files = Vec::new();
348 let mut warnings = Vec::new();
349 let status = match presence {
350 ConfigPresence::Single => {
351 files.push(PathStatus::Exists {
352 path: config_path.clone(),
353 });
354 InstallStatus::InstalledOwned {
355 owner: tag.to_string(),
356 }
357 }
358 ConfigPresence::Duplicate { count } => {
359 files.push(PathStatus::Exists {
360 path: config_path.clone(),
361 });
362 InstallStatus::Drifted {
363 issues: vec![DriftIssue::MultipleEntries {
364 name: tag.to_string(),
365 count,
366 }],
367 }
368 }
369 ConfigPresence::Invalid { reason } => {
370 files.push(PathStatus::Invalid {
371 path: config_path.clone(),
372 reason: reason.clone(),
373 });
374 InstallStatus::Drifted {
375 issues: vec![DriftIssue::InvalidConfig {
376 path: config_path.clone(),
377 reason,
378 }],
379 }
380 }
381 ConfigPresence::Absent => {
382 if config_path.exists() {
383 files.push(PathStatus::Exists {
384 path: config_path.clone(),
385 });
386 } else {
387 files.push(PathStatus::Missing {
388 path: config_path.clone(),
389 });
390 }
391 check_backup(&config_path, &mut warnings);
392 InstallStatus::Absent
393 }
394 };
395 Self {
396 target,
397 status,
398 config_path: Some(config_path),
399 ledger_path: None,
400 files,
401 warnings,
402 }
403 }
404
405 pub(crate) fn for_file_hook(tag: &str, file_path: PathBuf) -> Self {
408 let target = PlanTarget::Hook {
409 tag: tag.to_string(),
410 };
411 let exists = file_path.exists();
412 let mut files = Vec::new();
413 let mut warnings = Vec::new();
414 let status = if exists {
415 files.push(PathStatus::Exists {
416 path: file_path.clone(),
417 });
418 InstallStatus::InstalledOwned {
419 owner: tag.to_string(),
420 }
421 } else {
422 files.push(PathStatus::Missing {
423 path: file_path.clone(),
424 });
425 check_backup(&file_path, &mut warnings);
426 InstallStatus::Absent
427 };
428 Self {
429 target,
430 status,
431 config_path: Some(file_path),
432 ledger_path: None,
433 files,
434 warnings,
435 }
436 }
437
438 pub(crate) fn for_markdown_block_hook(
441 tag: &str,
442 file_path: PathBuf,
443 ) -> Result<Self, AgentConfigError> {
444 let target = PlanTarget::Hook {
445 tag: tag.to_string(),
446 };
447 let exists = file_path.exists();
448 let mut files = Vec::new();
449 let mut warnings = Vec::new();
450
451 let status = if exists {
452 let host = fs_atomic::read_to_string_or_empty(&file_path)?;
453 if md_block::malformed(&host, tag) {
454 files.push(PathStatus::Invalid {
455 path: file_path.clone(),
456 reason: "malformed agent-config markdown fence".into(),
457 });
458 InstallStatus::Drifted {
459 issues: vec![DriftIssue::MalformedConfig {
460 path: file_path.clone(),
461 reason: "malformed agent-config markdown fence".into(),
462 }],
463 }
464 } else {
465 files.push(PathStatus::Exists {
466 path: file_path.clone(),
467 });
468 if md_block::contains(&host, tag) {
469 InstallStatus::InstalledOwned {
470 owner: tag.to_string(),
471 }
472 } else {
473 InstallStatus::Absent
474 }
475 }
476 } else {
477 files.push(PathStatus::Missing {
478 path: file_path.clone(),
479 });
480 check_backup(&file_path, &mut warnings);
481 InstallStatus::Absent
482 };
483
484 Ok(Self {
485 target,
486 status,
487 config_path: Some(file_path),
488 ledger_path: None,
489 files,
490 warnings,
491 })
492 }
493
494 pub(crate) fn for_skill(
498 name: &str,
499 skill_dir: PathBuf,
500 manifest_path: PathBuf,
501 ledger_path: PathBuf,
502 expected_owner: &str,
503 recorded_owner: Option<String>,
504 ) -> Self {
505 let target = PlanTarget::Skill {
506 name: name.to_string(),
507 };
508 let dir_exists = skill_dir.exists();
509 let manifest_exists = manifest_path.exists();
510 let mut extra_drift = Vec::new();
511 let presence = if dir_exists {
512 if !manifest_exists {
513 extra_drift.push(DriftIssue::SkillIncomplete {
514 dir: skill_dir.clone(),
515 missing: manifest_path.clone(),
516 });
517 }
518 ConfigPresence::Single
519 } else {
520 ConfigPresence::Absent
521 };
522
523 let mut report = Self::assemble(
524 target,
525 Some(skill_dir.clone()),
526 Some(ledger_path),
527 presence,
528 expected_owner,
529 recorded_owner,
530 extra_drift,
531 );
532
533 report.files.clear();
536 if dir_exists {
537 report.files.push(PathStatus::Exists {
538 path: skill_dir.clone(),
539 });
540 report.files.push(if manifest_exists {
541 PathStatus::Exists {
542 path: manifest_path,
543 }
544 } else {
545 PathStatus::Missing {
546 path: manifest_path,
547 }
548 });
549 } else {
550 report.files.push(PathStatus::Missing { path: skill_dir });
551 report.files.push(PathStatus::Missing {
552 path: manifest_path,
553 });
554 }
555 report
556 }
557
558 pub(crate) fn for_instruction(
561 name: &str,
562 instruction_path: PathBuf,
563 ledger_path: PathBuf,
564 presence: ConfigPresence,
565 expected_owner: &str,
566 recorded_owner: Option<String>,
567 ) -> Self {
568 let target = PlanTarget::Instruction {
569 name: name.to_string(),
570 };
571 Self::assemble(
572 target,
573 Some(instruction_path),
574 Some(ledger_path),
575 presence,
576 expected_owner,
577 recorded_owner,
578 Vec::new(),
579 )
580 }
581
582 fn assemble(
586 target: PlanTarget,
587 config_path: Option<PathBuf>,
588 ledger_path: Option<PathBuf>,
589 presence: ConfigPresence,
590 expected_owner: &str,
591 recorded_owner: Option<String>,
592 mut extra_drift: Vec<DriftIssue>,
593 ) -> Self {
594 let mut files = Vec::new();
595 let mut warnings = Vec::new();
596
597 if let Some(p) = config_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 if let Some(p) = ledger_path.as_ref() {
605 files.push(if p.exists() {
606 PathStatus::Exists { path: p.clone() }
607 } else {
608 PathStatus::Missing { path: p.clone() }
609 });
610 }
611
612 let mut status = match (&presence, recorded_owner.as_deref()) {
615 (ConfigPresence::Invalid { reason }, _) => {
616 if let Some(p) = config_path.as_ref() {
617 if let Some(slot) = files
618 .iter_mut()
619 .find(|f| matches!(f, PathStatus::Exists { path } if path == p))
620 {
621 *slot = PathStatus::Invalid {
622 path: p.clone(),
623 reason: reason.clone(),
624 };
625 }
626 }
627 let mut issues = std::mem::take(&mut extra_drift);
628 issues.push(DriftIssue::InvalidConfig {
629 path: config_path.clone().unwrap_or_default(),
630 reason: reason.clone(),
631 });
632 InstallStatus::Drifted { issues }
633 }
634 (ConfigPresence::Duplicate { count }, _) => {
635 let target_name = match &target {
636 PlanTarget::Hook { tag } => tag.clone(),
637 PlanTarget::Mcp { name }
638 | PlanTarget::Skill { name }
639 | PlanTarget::Instruction { name } => name.clone(),
640 };
641 let mut issues = std::mem::take(&mut extra_drift);
642 issues.push(DriftIssue::MultipleEntries {
643 name: target_name,
644 count: *count,
645 });
646 InstallStatus::Drifted { issues }
647 }
648 (ConfigPresence::Single, Some(owner)) if owner == expected_owner => {
649 InstallStatus::InstalledOwned {
650 owner: owner.to_string(),
651 }
652 }
653 (ConfigPresence::Single, Some(owner)) => InstallStatus::InstalledOtherOwner {
654 owner: owner.to_string(),
655 },
656 (ConfigPresence::Single, None) => InstallStatus::PresentUnowned,
657 (ConfigPresence::Absent, Some(owner)) => InstallStatus::LedgerOnly {
658 owner: owner.to_string(),
659 },
660 (ConfigPresence::Absent, None) => InstallStatus::Absent,
661 };
662
663 if !extra_drift.is_empty() {
666 let mut issues = extra_drift;
667 if let InstallStatus::Drifted { issues: existing } = &mut status {
668 std::mem::swap(existing, &mut issues);
669 existing.extend(issues);
670 } else {
671 status = InstallStatus::Drifted { issues };
672 }
673 }
674
675 if matches!(status, InstallStatus::Absent) {
676 if let Some(p) = config_path.as_ref() {
677 check_backup(p, &mut warnings);
678 }
679 }
680
681 Self {
682 target,
683 status,
684 config_path,
685 ledger_path,
686 files,
687 warnings,
688 }
689 }
690}
691
692fn check_backup(path: &Path, warnings: &mut Vec<StatusWarning>) {
694 let mut bak = path.to_path_buf();
695 let name = bak
696 .file_name()
697 .map(|n| n.to_os_string())
698 .unwrap_or_default();
699 let mut name = name.into_string().unwrap_or_default();
700 if name.is_empty() {
701 return;
702 }
703 name.push_str(".bak");
704 bak.set_file_name(name);
705 if bak.exists() {
706 warnings.push(StatusWarning::BackupExists { path: bak });
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use tempfile::tempdir;
714
715 #[test]
716 fn for_mcp_owned_when_owner_matches() {
717 let dir = tempdir().unwrap();
718 let cfg = dir.path().join("mcp.json");
719 let led = dir.path().join(".agent-config-mcp.json");
720 std::fs::write(&cfg, b"{}").unwrap();
721 std::fs::write(&led, b"{}").unwrap();
722 let r = StatusReport::for_mcp(
723 "github",
724 cfg.clone(),
725 led,
726 ConfigPresence::Single,
727 "myapp",
728 Some("myapp".into()),
729 );
730 assert!(matches!(
731 r.status,
732 InstallStatus::InstalledOwned { ref owner } if owner == "myapp"
733 ));
734 assert_eq!(
735 r.target,
736 PlanTarget::Mcp {
737 name: "github".into()
738 }
739 );
740 }
741
742 #[test]
743 fn for_mcp_other_owner_when_recorded_differs() {
744 let dir = tempdir().unwrap();
745 let cfg = dir.path().join("mcp.json");
746 let led = dir.path().join(".agent-config-mcp.json");
747 let r = StatusReport::for_mcp(
748 "github",
749 cfg,
750 led,
751 ConfigPresence::Single,
752 "myapp",
753 Some("otherapp".into()),
754 );
755 assert!(matches!(
756 r.status,
757 InstallStatus::InstalledOtherOwner { ref owner } if owner == "otherapp"
758 ));
759 }
760
761 #[test]
762 fn for_mcp_present_unowned_when_no_ledger_record() {
763 let dir = tempdir().unwrap();
764 let r = StatusReport::for_mcp(
765 "github",
766 dir.path().join("mcp.json"),
767 dir.path().join("ledger.json"),
768 ConfigPresence::Single,
769 "myapp",
770 None,
771 );
772 assert!(matches!(r.status, InstallStatus::PresentUnowned));
773 }
774
775 #[test]
776 fn for_mcp_ledger_only_when_config_absent() {
777 let dir = tempdir().unwrap();
778 let r = StatusReport::for_mcp(
779 "github",
780 dir.path().join("mcp.json"),
781 dir.path().join("ledger.json"),
782 ConfigPresence::Absent,
783 "myapp",
784 Some("myapp".into()),
785 );
786 assert!(matches!(
787 r.status,
788 InstallStatus::LedgerOnly { ref owner } if owner == "myapp"
789 ));
790 }
791
792 #[test]
793 fn for_mcp_absent_when_neither_present() {
794 let dir = tempdir().unwrap();
795 let r = StatusReport::for_mcp(
796 "github",
797 dir.path().join("mcp.json"),
798 dir.path().join("ledger.json"),
799 ConfigPresence::Absent,
800 "myapp",
801 None,
802 );
803 assert!(matches!(r.status, InstallStatus::Absent));
804 }
805
806 #[test]
807 fn for_mcp_drifted_on_invalid_config() {
808 let dir = tempdir().unwrap();
809 let cfg = dir.path().join("mcp.json");
810 std::fs::write(&cfg, b"{not valid").unwrap();
811 let r = StatusReport::for_mcp(
812 "github",
813 cfg.clone(),
814 dir.path().join("ledger.json"),
815 ConfigPresence::Invalid {
816 reason: "expected `:` at line 1".into(),
817 },
818 "myapp",
819 None,
820 );
821 let issues = match &r.status {
822 InstallStatus::Drifted { issues } => issues,
823 other => panic!("expected Drifted, got {other:?}"),
824 };
825 assert!(matches!(issues[0], DriftIssue::InvalidConfig { .. }));
826 }
827
828 #[test]
829 fn for_skill_incomplete_when_manifest_missing() {
830 let dir = tempdir().unwrap();
831 let skill_dir = dir.path().join("alpha");
832 std::fs::create_dir_all(&skill_dir).unwrap();
833 let manifest = skill_dir.join("SKILL.md");
834 let r = StatusReport::for_skill(
835 "alpha",
836 skill_dir,
837 manifest,
838 dir.path().join("ledger.json"),
839 "myapp",
840 Some("myapp".into()),
841 );
842 let issues = match &r.status {
843 InstallStatus::Drifted { issues } => issues,
844 other => panic!("expected Drifted, got {other:?}"),
845 };
846 assert!(matches!(issues[0], DriftIssue::SkillIncomplete { .. }));
847 }
848
849 #[test]
850 fn backup_warning_emitted_when_bak_exists() {
851 let dir = tempdir().unwrap();
852 let cfg = dir.path().join("mcp.json");
853 std::fs::write(dir.path().join("mcp.json.bak"), b"{}").unwrap();
854 let r = StatusReport::for_mcp(
855 "github",
856 cfg,
857 dir.path().join("ledger.json"),
858 ConfigPresence::Absent,
859 "myapp",
860 None,
861 );
862 assert!(r
863 .warnings
864 .iter()
865 .any(|w| matches!(w, StatusWarning::BackupExists { .. })));
866 }
867
868 #[test]
869 fn tagged_hook_owned_when_present() {
870 let dir = tempdir().unwrap();
871 let cfg = dir.path().join("settings.json");
872 std::fs::write(&cfg, b"{}").unwrap();
873 let r = StatusReport::for_tagged_hook("alpha", cfg, ConfigPresence::Single);
874 assert!(matches!(
875 r.status,
876 InstallStatus::InstalledOwned { ref owner } if owner == "alpha"
877 ));
878 assert!(r.ledger_path.is_none());
879 }
880
881 #[test]
882 fn tagged_hook_drifted_on_invalid_config() {
883 let dir = tempdir().unwrap();
884 let r = StatusReport::for_tagged_hook(
885 "alpha",
886 dir.path().join("settings.json"),
887 ConfigPresence::Invalid {
888 reason: "broken".into(),
889 },
890 );
891 assert!(matches!(r.status, InstallStatus::Drifted { .. }));
892 }
893
894 #[test]
895 fn file_hook_present_when_path_exists() {
896 let dir = tempdir().unwrap();
897 let p = dir.path().join("alpha.md");
898 std::fs::write(&p, b"x").unwrap();
899 let r = StatusReport::for_file_hook("alpha", p);
900 assert!(matches!(r.status, InstallStatus::InstalledOwned { .. }));
901 }
902
903 #[test]
904 fn file_hook_absent_when_missing() {
905 let dir = tempdir().unwrap();
906 let r = StatusReport::for_file_hook("alpha", dir.path().join("alpha.md"));
907 assert!(matches!(r.status, InstallStatus::Absent));
908 }
909}