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    /// Both hooks.json and inline config.toml hook configurations exist.
224    DualHooksExist {
225        /// Path to the hooks.json file.
226        hooks_json: PathBuf,
227        /// Path to the config.toml file.
228        config_toml: PathBuf,
229    },
230}
231
232/// Per-file check used to populate [`StatusReport::files`].
233#[derive(Debug, Clone, PartialEq, Eq)]
234#[non_exhaustive]
235pub enum PathStatus {
236    /// File or directory does not exist.
237    Missing {
238        /// The probed path.
239        path: PathBuf,
240    },
241    /// File or directory exists.
242    Exists {
243        /// The probed path.
244        path: PathBuf,
245    },
246    /// File exists but is not in a valid shape (parse failure, wrong type,
247    /// missing required member, etc.).
248    Invalid {
249        /// The probed path.
250        path: PathBuf,
251        /// Human-readable reason.
252        reason: String,
253    },
254}
255
256/// Full report returned by `Integration::status`, `McpSurface::mcp_status`,
257/// and `SkillSurface::skill_status`.
258#[must_use]
259#[derive(Debug, Clone)]
260#[non_exhaustive]
261pub struct StatusReport {
262    /// What the report describes.
263    pub target: PlanTarget,
264    /// High-level state — the field most callers will branch on.
265    pub status: InstallStatus,
266    /// Path to the harness config the agent inspects (e.g.
267    /// `~/.claude/settings.json`, `~/.codex/config.toml`). `None` for
268    /// surfaces with no single canonical config file.
269    pub config_path: Option<PathBuf>,
270    /// Path to the agent-config ownership ledger that backs this surface.
271    /// `None` for hook surfaces that embed `_agent_config_tag` markers
272    /// directly into the harness config (no separate ledger).
273    pub ledger_path: Option<PathBuf>,
274    /// Per-file status for paths the report inspected. Useful for rendering
275    /// "what would change" planners without re-probing.
276    pub files: Vec<PathStatus>,
277    /// Advisory observations.
278    pub warnings: Vec<StatusWarning>,
279}
280
281/// Internal: presence of a single named entry in a harness config file.
282///
283/// Each `*_status` probe maps the underlying file format (JSON, JSONC, JSON5,
284/// TOML, YAML) into this enum so the [`StatusReport`] assembly logic stays
285/// uniform.
286#[derive(Debug, Clone)]
287pub(crate) enum ConfigPresence {
288    /// File missing or entry not present.
289    Absent,
290    /// Entry present exactly once.
291    Single,
292    /// Entry present more than once (some formats permit duplicates).
293    Duplicate {
294        /// Number of occurrences.
295        count: usize,
296    },
297    /// Config exists but cannot be parsed. Carries the parser message so the
298    /// caller can surface it via [`DriftIssue::InvalidConfig`].
299    Invalid {
300        /// Human-readable parse error.
301        reason: String,
302    },
303}
304
305impl StatusReport {
306    /// Build a report for an MCP server. `recorded_owner` should be
307    /// `ownership::owner_of(ledger_path, name)` from the agent.
308    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    /// Build a report for a hook entry that embeds `_agent_config_tag` into the
331    /// harness config (no ledger). This shape covers
332    /// claude/cursor/gemini/codex/copilot/opencode/windsurf hooks.
333    ///
334    /// `present_in_config` is true when the array contains an object with
335    /// `_agent_config_tag` equal to the caller's tag. Parse failures should be
336    /// passed via [`ConfigPresence::Invalid`].
337    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        // For tagged hooks, the tag is the owner; treat presence as
346        // owned-by-tag, absence as Absent.
347        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    /// Build a report for a hook surface backed by a per-tag file
406    /// (`<root>/<rules_dir>/<tag>.md`, used by cline/roo/antigravity rules).
407    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    /// Build a report for a prompt-rule hook stored as an AGENT-CONFIG fenced
439    /// markdown block inside a shared file.
440    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    /// Build a report for a skill. The skill is "present in config" when its
495    /// directory exists; the report adds [`DriftIssue::SkillIncomplete`] when
496    /// the directory exists but lacks `SKILL.md`.
497    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        // Replace the auto-generated PathStatus for skill_dir with finer-grained
534        // entries, and add a manifest-level entry.
535        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    /// Build a report for an instruction. The instruction is "present in
559    /// config" when its file exists on disk.
560    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    /// Common assembly for ledger-backed surfaces (MCP, skills).
583    /// `extra_drift` is folded into a `Drifted` status when non-empty,
584    /// otherwise the ledger/config combination determines the variant.
585    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        // Map (presence, recorded_owner) into InstallStatus, deferring drift
613        // when callers have already accumulated reasons (e.g. SkillIncomplete).
614        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        // Fold any accumulated extra drift into a Drifted status that
664        // wasn't already escalated by the match arms above.
665        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
692/// Push a `BackupExists` warning if `<path>.bak` is on disk.
693fn 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}