Skip to main content

agent_config/
status.rs

1//! Richer install-state reporting for hooks, MCP servers, and skills.
2//!
3//! Where `is_installed` returns a single boolean, [`StatusReport`] captures the
4//! distinct realities a planner needs to act on: present-and-owned-by-us,
5//! present-but-owned-by-someone-else, present-without-any-ledger-claim,
6//! ledger-claim-without-any-on-disk-presence, parse-failed config, etc.
7//!
8//! Each [`Integration`](crate::Integration), [`McpSurface`](crate::McpSurface),
9//! and [`SkillSurface`](crate::SkillSurface) provides a `*_status` method that
10//! returns one of these reports. The legacy `is_*_installed` methods are kept
11//! as compatibility wrappers — they collapse to `true` for both
12//! [`InstallStatus::InstalledOwned`] and [`InstallStatus::InstalledOtherOwner`].
13
14use std::path::{Path, PathBuf};
15
16use crate::error::AgentConfigError;
17use crate::util::{fs_atomic, md_block};
18
19/// What kind of install the [`StatusReport`] describes.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum PlanTarget {
23    /// A hook entry identified by its consumer tag.
24    Hook {
25        /// The consumer tag the report was queried for.
26        tag: String,
27    },
28    /// An MCP server identified by name.
29    Mcp {
30        /// The MCP server name the report was queried for.
31        name: String,
32    },
33    /// A skill identified by name.
34    Skill {
35        /// The skill name the report was queried for.
36        name: String,
37    },
38    /// An instruction identified by name.
39    Instruction {
40        /// The instruction name the report was queried for.
41        name: String,
42    },
43}
44
45/// High-level installation state.
46///
47/// Each variant maps to a single concrete combination of (harness-config
48/// presence, agent-config ledger ownership). Callers can match on this directly
49/// to choose between install, repair, or skip.
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum InstallStatus {
53    /// Not present in either the harness config or the ownership ledger.
54    Absent,
55    /// Recorded under the caller's owner tag and present in the harness config.
56    InstalledOwned {
57        /// Owner tag recorded in the ledger. Equals the caller's expected
58        /// owner.
59        owner: String,
60    },
61    /// Recorded under a different owner. The caller cannot uninstall this
62    /// without forcing a steal; new installs must use a different name.
63    InstalledOtherOwner {
64        /// Owner tag recorded in the ledger.
65        owner: String,
66    },
67    /// Present in the harness config but no ledger entry claims it. Likely
68    /// hand-installed by the user or installed by a tool that does not
69    /// participate in the agent-config ownership protocol.
70    PresentUnowned,
71    /// Recorded in the ledger but missing from the harness config. The most
72    /// common cause is that the user (or another tool) deleted the entry
73    /// directly without going through `uninstall`.
74    LedgerOnly {
75        /// Owner tag recorded in the ledger.
76        owner: String,
77    },
78    /// On-disk state is structurally inconsistent in a way that does not fit
79    /// the other variants — duplicate entries, an unparseable config, an
80    /// incomplete skill directory, etc. Callers should typically treat this
81    /// as "needs manual repair before install".
82    Drifted {
83        /// One or more concrete drift reasons.
84        issues: Vec<DriftIssue>,
85    },
86    /// State could not be determined (e.g., a probe encountered a soft I/O
87    /// failure). Reserved for non-fatal cases. Hard errors propagate as
88    /// [`AgentConfigError`] instead.
89    Unknown,
90}
91
92/// One concrete reason a [`StatusReport`] is in the
93/// [`InstallStatus::Drifted`] state.
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum DriftIssue {
97    /// Ownership ledger contains an entry, but the corresponding config,
98    /// directory, or file entry is missing.
99    LedgerOnly {
100        /// The ledger path that contains the stale entry.
101        path: PathBuf,
102        /// Owner recorded for the entry, when available.
103        owner: Option<String>,
104    },
105    /// Config, directory, or file entry exists without a matching ownership
106    /// ledger entry.
107    ConfigOnly {
108        /// The unowned config, directory, or file path.
109        path: PathBuf,
110    },
111    /// The ledger owner does not match the owner the caller asked to validate
112    /// against.
113    OwnerMismatch {
114        /// Expected owner tag.
115        expected: String,
116        /// Actual owner tag from the ledger.
117        actual: Option<String>,
118        /// Ledger path that recorded the owner.
119        path: Option<PathBuf>,
120    },
121    /// The harness config exists but cannot be parsed or has an unsupported
122    /// shape for validation.
123    MalformedConfig {
124        /// The malformed config path.
125        path: PathBuf,
126        /// Parser or shape error.
127        reason: String,
128    },
129    /// The agent-config ownership ledger exists but is not valid ledger JSON.
130    MalformedLedger {
131        /// The malformed ledger path.
132        path: PathBuf,
133        /// Parser or shape error.
134        reason: String,
135    },
136    /// A backup already exists from an earlier first-touch write.
137    BackupCollision {
138        /// The existing backup path.
139        path: PathBuf,
140    },
141    /// A backup that validation expected to be present is missing.
142    MissingBackup {
143        /// The expected backup path.
144        path: PathBuf,
145    },
146    /// A backup exists even though the validated install state does not need
147    /// it.
148    StaleBackup {
149        /// The stale backup path.
150        path: PathBuf,
151    },
152    /// A directory-backed surface is not laid out as expected.
153    UnexpectedDirectoryShape {
154        /// The path with the unexpected shape.
155        path: PathBuf,
156        /// Human-readable shape problem.
157        reason: String,
158    },
159    /// A skill directory exists but the required `SKILL.md` manifest is
160    /// missing.
161    SkillMissingSkillMd {
162        /// The skill directory.
163        dir: PathBuf,
164        /// The expected manifest path.
165        missing: PathBuf,
166    },
167    /// A skill file or symlink resolves outside the skill directory.
168    SkillAssetEscapesRoot {
169        /// The escaping asset path.
170        path: PathBuf,
171        /// The skill directory that should contain all assets.
172        root: PathBuf,
173    },
174    /// A known unsupported surface has files present on disk.
175    UnsupportedButPresent {
176        /// The unsupported path that exists.
177        path: PathBuf,
178    },
179    /// A skill directory exists but the required `SKILL.md` manifest is
180    /// missing.
181    SkillIncomplete {
182        /// The skill directory.
183        dir: PathBuf,
184        /// The expected manifest path.
185        missing: PathBuf,
186    },
187    /// An instruction file exists but the content hash does not match the
188    /// ledger record.
189    InstructionContentDrift {
190        /// The instruction file path.
191        path: PathBuf,
192    },
193    /// The harness config exists but cannot be parsed (malformed JSON, JSONC,
194    /// JSON5, TOML, or YAML).
195    InvalidConfig {
196        /// The unparseable file.
197        path: PathBuf,
198        /// The parser error message.
199        reason: String,
200    },
201    /// The same name appears in the harness config more than once.
202    /// Defensive — most config formats reject this on parse, but YAML and
203    /// JSONC accept duplicate keys silently in some readers.
204    MultipleEntries {
205        /// The duplicated name.
206        name: String,
207        /// The number of times it appears.
208        count: usize,
209    },
210}
211
212/// Advisory observations that do not change the [`InstallStatus`] but may
213/// matter to a planner.
214#[derive(Debug, Clone, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum StatusWarning {
217    /// A `<config>.bak` file exists. Usually means a previous install was
218    /// rolled back or interrupted; the backup is still on disk.
219    BackupExists {
220        /// The backup path.
221        path: PathBuf,
222    },
223}
224
225/// Per-file check used to populate [`StatusReport::files`].
226#[derive(Debug, Clone, PartialEq, Eq)]
227#[non_exhaustive]
228pub enum PathStatus {
229    /// File or directory does not exist.
230    Missing {
231        /// The probed path.
232        path: PathBuf,
233    },
234    /// File or directory exists.
235    Exists {
236        /// The probed path.
237        path: PathBuf,
238    },
239    /// File exists but is not in a valid shape (parse failure, wrong type,
240    /// missing required member, etc.).
241    Invalid {
242        /// The probed path.
243        path: PathBuf,
244        /// Human-readable reason.
245        reason: String,
246    },
247}
248
249/// Full report returned by `Integration::status`, `McpSurface::mcp_status`,
250/// and `SkillSurface::skill_status`.
251#[must_use]
252#[derive(Debug, Clone)]
253#[non_exhaustive]
254pub struct StatusReport {
255    /// What the report describes.
256    pub target: PlanTarget,
257    /// High-level state — the field most callers will branch on.
258    pub status: InstallStatus,
259    /// Path to the harness config the agent inspects (e.g.
260    /// `~/.claude/settings.json`, `~/.codex/config.toml`). `None` for
261    /// surfaces with no single canonical config file.
262    pub config_path: Option<PathBuf>,
263    /// Path to the agent-config ownership ledger that backs this surface.
264    /// `None` for hook surfaces that embed `_agent_config_tag` markers
265    /// directly into the harness config (no separate ledger).
266    pub ledger_path: Option<PathBuf>,
267    /// Per-file status for paths the report inspected. Useful for rendering
268    /// "what would change" planners without re-probing.
269    pub files: Vec<PathStatus>,
270    /// Advisory observations.
271    pub warnings: Vec<StatusWarning>,
272}
273
274/// Internal: presence of a single named entry in a harness config file.
275///
276/// Each `*_status` probe maps the underlying file format (JSON, JSONC, JSON5,
277/// TOML, YAML) into this enum so the [`StatusReport`] assembly logic stays
278/// uniform.
279#[derive(Debug, Clone)]
280pub(crate) enum ConfigPresence {
281    /// File missing or entry not present.
282    Absent,
283    /// Entry present exactly once.
284    Single,
285    /// Entry present more than once (some formats permit duplicates).
286    Duplicate {
287        /// Number of occurrences.
288        count: usize,
289    },
290    /// Config exists but cannot be parsed. Carries the parser message so the
291    /// caller can surface it via [`DriftIssue::InvalidConfig`].
292    Invalid {
293        /// Human-readable parse error.
294        reason: String,
295    },
296}
297
298impl StatusReport {
299    /// Build a report for an MCP server. `recorded_owner` should be
300    /// `ownership::owner_of(ledger_path, name)` from the agent.
301    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    /// Build a report for a hook entry that embeds `_agent_config_tag` into the
324    /// harness config (no ledger). This shape covers
325    /// claude/cursor/gemini/codex/copilot/opencode/windsurf hooks.
326    ///
327    /// `present_in_config` is true when the array contains an object with
328    /// `_agent_config_tag` equal to the caller's tag. Parse failures should be
329    /// passed via [`ConfigPresence::Invalid`].
330    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        // For tagged hooks, the tag is the owner; treat presence as
339        // owned-by-tag, absence as Absent.
340        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    /// Build a report for a hook surface backed by a per-tag file
399    /// (`<root>/<rules_dir>/<tag>.md`, used by cline/roo/antigravity rules).
400    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    /// Build a report for a prompt-rule hook stored as an AGENT-CONFIG fenced
432    /// markdown block inside a shared file.
433    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    /// Build a report for a skill. The skill is "present in config" when its
488    /// directory exists; the report adds [`DriftIssue::SkillIncomplete`] when
489    /// the directory exists but lacks `SKILL.md`.
490    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        // Replace the auto-generated PathStatus for skill_dir with finer-grained
527        // entries, and add a manifest-level entry.
528        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    /// Build a report for an instruction. The instruction is "present in
552    /// config" when its file exists on disk.
553    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    /// Common assembly for ledger-backed surfaces (MCP, skills).
576    /// `extra_drift` is folded into a `Drifted` status when non-empty,
577    /// otherwise the ledger/config combination determines the variant.
578    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        // Map (presence, recorded_owner) into InstallStatus, deferring drift
606        // when callers have already accumulated reasons (e.g. SkillIncomplete).
607        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        // Fold any accumulated extra drift into a Drifted status that
657        // wasn't already escalated by the match arms above.
658        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
685/// Push a `BackupExists` warning if `<path>.bak` is on disk.
686fn 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}