Skip to main content

tuff_core/
lockfile.rs

1use std::{
2    collections::BTreeMap,
3    ffi::OsStr,
4    path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::adapter::EmittedFile;
11use crate::error::{Result, TuffError};
12use crate::manifest::{CapabilityType, ImplementationConfig, McpServerConfig, WorkflowConfig};
13
14pub const LOCKFILE_VERSION: u8 = 1;
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct Lockfile {
18    pub version: u8,
19    #[serde(rename = "capabilities")]
20    pub capabilities: BTreeMap<String, CapabilityLockEntry>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CapabilityLockEntry {
25    #[serde(rename = "type")]
26    pub capability_type: CapabilityType,
27    #[serde(rename = "installedVersion")]
28    pub installed_version: String,
29    #[serde(default, skip_serializing_if = "String::is_empty")]
30    pub description: String,
31    #[serde(rename = "sourcePath")]
32    pub source_path: String,
33    pub targets: BTreeMap<String, TargetLockEntry>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub source: Option<SourceMetadata>,
36    #[serde(default = "default_scope")]
37    pub scope: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub pack: Option<PackProvenance>,
40    /// Cached from the manifest at install/update time, the same way
41    /// `description` is: after install, only the `files` a manifest declares
42    /// get copied to disk — `tuff.toml` itself does not — so this is the
43    /// only durable record of how a tool is invoked. Consumed by the
44    /// generated capability-index skill (RFC-103 tier 1).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub implementation: Option<ImplementationConfig>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub parameters: Option<serde_json::Value>,
49    /// Same rationale as `implementation`/`parameters`: a workflow's
50    /// `requires` list lives only in its manifest, which isn't copied to the
51    /// installed target directory.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub workflow: Option<WorkflowConfig>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub server: Option<McpServerConfig>,
56}
57
58fn default_scope() -> String {
59    "project".to_string()
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SourceMetadata {
64    #[serde(rename = "type")]
65    pub source_type: String,
66    pub url: String,
67    #[serde(rename = "ref")]
68    pub source_ref: String,
69    pub skill: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73/// Immutable pack release that delivered a capability entry.
74pub struct PackProvenance {
75    pub name: String,
76    pub version: String,
77    pub digest: String,
78    /// The OCI registry and repository this pack was pulled from
79    /// ("registry/repository", no tag), when known.
80    ///
81    /// `tuff add pack` only ever sees a local artifact file; it has no way to
82    /// know where that file came from unless the caller says so with
83    /// `--reference`. Absent, `tuff outdated` cannot check this capability
84    /// against anything and reports it as such rather than guessing.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub registry: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct TargetLockEntry {
91    #[serde(rename = "emittedFiles")]
92    pub emitted_files: Vec<EmittedFile>,
93    #[serde(
94        default,
95        rename = "managedHooks",
96        skip_serializing_if = "Vec::is_empty"
97    )]
98    pub managed_hooks: Vec<ManagedHook>,
99    #[serde(
100        default,
101        rename = "managedMcpEntry",
102        skip_serializing_if = "Option::is_none"
103    )]
104    pub managed_mcp_entry: Option<ManagedMcpEntry>,
105    #[serde(default)]
106    pub ownership: TargetOwnership,
107    #[serde(default)]
108    pub sha256: String,
109    #[serde(default)]
110    pub installed_path: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ManagedHook {
115    #[serde(rename = "settingsPath")]
116    pub settings_path: String,
117    pub event: String,
118    #[serde(
119        default,
120        rename = "canonicalEvent",
121        skip_serializing_if = "Option::is_none"
122    )]
123    pub canonical_event: Option<String>,
124    pub command: String,
125    #[serde(rename = "baselineHash")]
126    pub baseline_hash: String,
127}
128
129/// Baseline for one Tuff-managed `mcpServers.<id>` entry (RFC-102 stage b).
130///
131/// MCP config files are shared ground that users hand-edit, so the entry
132/// gets the managed-hook treatment: a content hash recorded at registration
133/// time, compared on every `check`/`list`, never whole-file ownership. The
134/// entry's key is the capability id, so only the file path and hash are
135/// stored.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ManagedMcpEntry {
138    #[serde(rename = "configPath")]
139    pub config_path: String,
140    #[serde(rename = "baselineHash")]
141    pub baseline_hash: String,
142}
143
144/// Hash an MCP entry value exactly as `managed_mcp_entry_status` will when
145/// it re-reads the file: canonical `serde_json` bytes, so on-disk pretty-
146/// printing never matters.
147pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
148    Ok(hash_bytes(&serde_json::to_vec(entry)?))
149}
150
151/// `"clean"`, `"modified"`, or `"missing"` for a managed MCP entry.
152pub fn managed_mcp_entry_status(
153    repo_root: &Path,
154    capability_id: &str,
155    entry: &ManagedMcpEntry,
156) -> &'static str {
157    let path = repo_root.join(&entry.config_path);
158    let Ok(raw) = std::fs::read_to_string(path) else {
159        return "missing";
160    };
161    let Ok(config): std::result::Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
162        return "modified";
163    };
164    let Some(current) = config
165        .get("mcpServers")
166        .and_then(|servers| servers.get(capability_id))
167    else {
168        return "missing";
169    };
170    match serde_json::to_vec(current) {
171        Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
172        _ => "modified",
173    }
174}
175
176pub fn managed_hooks_from_fragment(
177    repo_root: &Path,
178    settings_path: &str,
179    fragment: &serde_json::Value,
180) -> Result<Vec<ManagedHook>> {
181    managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
182}
183
184pub fn managed_hooks_from_fragment_with_canonical(
185    repo_root: &Path,
186    settings_path: &str,
187    fragment: &serde_json::Value,
188    canonical_event: Option<&str>,
189) -> Result<Vec<ManagedHook>> {
190    let mut managed = Vec::new();
191    let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
192        return Ok(managed);
193    };
194
195    for (event, groups) in events {
196        let Some(groups) = groups.as_array() else {
197            continue;
198        };
199        for group in groups {
200            let hooks = group
201                .get("hooks")
202                .and_then(serde_json::Value::as_array)
203                .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
204            for hook in hooks {
205                let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
206                    continue;
207                };
208                let baseline = serde_json::to_vec(hook)?;
209                managed.push(ManagedHook {
210                    settings_path: settings_path.to_string(),
211                    event: event.clone(),
212                    canonical_event: canonical_event.map(str::to_owned),
213                    command: command.to_string(),
214                    baseline_hash: write_baseline_object(repo_root, &baseline)?,
215                });
216            }
217        }
218    }
219    Ok(managed)
220}
221
222pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
223    let path = repo_root.join(&hook.settings_path);
224    let Ok(settings) = std::fs::read_to_string(path) else {
225        return "missing";
226    };
227    let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
228    else {
229        return "modified";
230    };
231    let Some(groups) = settings
232        .get("hooks")
233        .and_then(|hooks| hooks.get(&hook.event))
234        .and_then(serde_json::Value::as_array)
235    else {
236        return "missing";
237    };
238
239    for group in groups {
240        let entries = group
241            .get("hooks")
242            .and_then(serde_json::Value::as_array)
243            .map_or_else(|| vec![group], |entries| entries.iter().collect());
244        for entry in entries {
245            if entry.get("command").and_then(serde_json::Value::as_str)
246                == Some(hook.command.as_str())
247            {
248                let Ok(content) = serde_json::to_vec(entry) else {
249                    return "modified";
250                };
251                return if hash_bytes(&content) == hook.baseline_hash {
252                    "clean"
253                } else {
254                    "modified"
255                };
256            }
257        }
258    }
259    "missing"
260}
261
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum TargetOwnership {
265    #[default]
266    Generated,
267    Imported,
268}
269
270pub fn lockfile_path(repo_root: &Path) -> PathBuf {
271    let global = crate::paths::global_lockfile(repo_root);
272    if global.exists() {
273        global
274    } else {
275        repo_root.join("tuff.lock")
276    }
277}
278
279pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
280    let lock_path = repo_root.join("tuff.lock");
281    init_lockfile_at(&lock_path)?;
282    Ok(lock_path)
283}
284
285pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
286    if !lock_path.exists() {
287        write_lockfile_at(
288            lock_path,
289            &Lockfile {
290                version: LOCKFILE_VERSION,
291                capabilities: BTreeMap::new(),
292            },
293        )?;
294    }
295    Ok(())
296}
297
298pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
299    let lock_path = lockfile_path(repo_root);
300    read_lockfile_at(&lock_path)
301}
302
303pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
304    if !path.exists() {
305        let parent = path.parent().unwrap_or(Path::new("."));
306        return Err(TuffError::new(format!(
307            "{} is missing; run 'tuff init' first",
308            parent
309                .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
310                .display()
311        )));
312    }
313
314    let wire: WireLockfile = toml::from_str(&std::fs::read_to_string(path)?)?;
315    let mut capabilities = BTreeMap::new();
316    for item in wire.capabilities {
317        let target = item.target.clone();
318        let mut targets = BTreeMap::new();
319        targets.insert(
320            target,
321            TargetLockEntry {
322                emitted_files: Vec::new(),
323                managed_hooks: item.managed_hooks,
324                managed_mcp_entry: item.managed_mcp_entry,
325                ownership: item.ownership,
326                sha256: item.sha256,
327                installed_path: item.installed_path,
328            },
329        );
330        capabilities
331            .entry(item.name.clone())
332            .and_modify(|entry: &mut CapabilityLockEntry| {
333                entry.targets.extend(targets.clone());
334            })
335            .or_insert_with(|| CapabilityLockEntry {
336                capability_type: item.capability_type,
337                installed_version: item.version,
338                description: item.description,
339                source_path: item.source_path.clone(),
340                targets,
341                // Anything that isn't "local" is a remote source ("git",
342                // "catalog", ...). The type is recorded verbatim so each
343                // lifecycle verb can dispatch on it.
344                source: (!item.source.is_empty() && item.source != "local").then_some(
345                    SourceMetadata {
346                        source_type: item.source.clone(),
347                        url: item.repository,
348                        source_ref: item.resolved_ref,
349                        skill: item.source_path,
350                    },
351                ),
352                scope: "project".to_string(),
353                pack: item.pack,
354                implementation: item.implementation,
355                parameters: item.parameters,
356                workflow: item.workflow,
357                server: item.server,
358            });
359    }
360    let lockfile = Lockfile {
361        version: wire.version,
362        capabilities,
363    };
364    if lockfile.version != LOCKFILE_VERSION {
365        return Err(TuffError::new(format!(
366            "unsupported lockfile version: {}",
367            lockfile.version
368        )));
369    }
370    Ok(lockfile)
371}
372
373pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
374    let lock_path = lockfile_path(repo_root);
375    write_lockfile_at(&lock_path, lockfile)
376}
377
378pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
379    if let Some(parent) = path.parent() {
380        std::fs::create_dir_all(parent)?;
381    }
382    let mut capabilities = Vec::new();
383    for (name, entry) in &lockfile.capabilities {
384        for (target, target_entry) in &entry.targets {
385            let (source, repository, source_path, resolved_ref) = match &entry.source {
386                Some(source) => (
387                    source.source_type.clone(),
388                    source.url.clone(),
389                    source.skill.clone(),
390                    source.source_ref.clone(),
391                ),
392                None => (
393                    "local".to_string(),
394                    String::new(),
395                    entry.source_path.clone(),
396                    String::new(),
397                ),
398            };
399            capabilities.push(WireCapability {
400                name: name.clone(),
401                capability_type: entry.capability_type,
402                source,
403                repository,
404                source_path,
405                resolved_ref,
406                sha256: target_entry.sha256.clone(),
407                target: target.clone(),
408                installed_path: target_entry.installed_path.clone(),
409                version: entry.installed_version.clone(),
410                description: entry.description.clone(),
411                ownership: target_entry.ownership,
412                managed_hooks: target_entry.managed_hooks.clone(),
413                managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
414                pack: entry.pack.clone(),
415                implementation: entry.implementation.clone(),
416                parameters: entry.parameters.clone(),
417                workflow: entry.workflow.clone(),
418                server: entry.server.clone(),
419            });
420        }
421    }
422    capabilities.sort_by(|a, b| {
423        a.name
424            .cmp(&b.name)
425            .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
426            .then_with(|| a.target.cmp(&b.target))
427            .then_with(|| a.installed_path.cmp(&b.installed_path))
428    });
429    let wire = WireLockfile {
430        version: LOCKFILE_VERSION,
431        capabilities,
432    };
433    let content = format!(
434        "# Tuff lockfile. Each entry records one capability installation target.\n{}\n",
435        toml::to_string_pretty(&wire)?
436    );
437    std::fs::write(path, content)?;
438    Ok(())
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442struct WireLockfile {
443    version: u8,
444    #[serde(rename = "capabilities")]
445    capabilities: Vec<WireCapability>,
446}
447
448#[derive(Debug, Serialize, Deserialize)]
449struct WireCapability {
450    name: String,
451    #[serde(rename = "type")]
452    capability_type: CapabilityType,
453    source: String,
454    #[serde(default, skip_serializing_if = "String::is_empty")]
455    repository: String,
456    source_path: String,
457    #[serde(default)]
458    resolved_ref: String,
459    sha256: String,
460    target: String,
461    installed_path: String,
462    #[serde(default)]
463    version: String,
464    #[serde(default)]
465    description: String,
466    #[serde(default)]
467    ownership: TargetOwnership,
468    #[serde(default, skip_serializing_if = "Vec::is_empty")]
469    managed_hooks: Vec<ManagedHook>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    managed_mcp_entry: Option<ManagedMcpEntry>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pack: Option<PackProvenance>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    implementation: Option<ImplementationConfig>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    parameters: Option<serde_json::Value>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    workflow: Option<WorkflowConfig>,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    server: Option<McpServerConfig>,
482}
483
484pub fn hash_bytes(content: &[u8]) -> String {
485    let mut hasher = Sha256::new();
486    hasher.update(content);
487    format!("{:x}", hasher.finalize())
488}
489
490pub fn write_baseline_object(_repo_root: &Path, content: &[u8]) -> Result<String> {
491    // Kept temporarily as an internal compatibility helper for lifecycle code;
492    // baseline content is now stored only as a verified materialized tree.
493    Ok(hash_bytes(content))
494}
495
496pub fn prune_unreferenced_baseline_objects(
497    _repo_root: &Path,
498    _lockfile: &Lockfile,
499) -> Result<usize> {
500    Ok(0)
501}
502
503pub fn drift_status(repo_root: &Path, emitted_file: &EmittedFile) -> &'static str {
504    let target_path = repo_root.join(&emitted_file.path);
505    if !target_path.exists() {
506        return "missing";
507    }
508
509    let Ok(content) = std::fs::read(&target_path) else {
510        return "missing";
511    };
512
513    if hash_bytes(&content) == emitted_file.hash {
514        "clean"
515    } else {
516        "modified"
517    }
518}
519
520pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
521    path.strip_prefix(repo_root)
522        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
523        .unwrap_or_else(|_| path.to_string_lossy().to_string())
524}
525
526pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
527    if path.is_absolute() {
528        path.to_path_buf()
529    } else {
530        repo_root.join(path)
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use std::fs;
538    use tempfile::TempDir;
539
540    #[test]
541    fn init_lockfile_at_creates_new_file() {
542        let tmp = TempDir::new().unwrap();
543        let path = tmp.path().join("tuff.lock");
544        init_lockfile_at(&path).unwrap();
545        assert!(path.exists());
546
547        let lf = read_lockfile_at(&path).unwrap();
548        assert_eq!(lf.version, 1);
549        assert!(lf.capabilities.is_empty());
550    }
551
552    #[test]
553    fn read_lockfile_at_rejects_missing() {
554        let tmp = TempDir::new().unwrap();
555        let path = tmp.path().join("tuff.lock");
556        assert!(read_lockfile_at(&path).is_err());
557    }
558
559    #[test]
560    fn read_lockfile_at_rejects_v4_schema() {
561        let tmp = TempDir::new().unwrap();
562        let path = tmp.path().join("tuff.lock");
563        fs::write(&path, "version = 4\ncapabilities = []\n").unwrap();
564
565        let error = read_lockfile_at(&path).unwrap_err();
566        assert!(
567            error
568                .to_string()
569                .contains("unsupported lockfile version: 4")
570        );
571    }
572
573    #[test]
574    fn write_and_read_roundtrip() {
575        let tmp = TempDir::new().unwrap();
576        let path = tmp.path().join("tuff.lock");
577        let mut lf = Lockfile {
578            version: LOCKFILE_VERSION,
579            capabilities: BTreeMap::new(),
580        };
581        lf.capabilities.insert(
582            "test".into(),
583            CapabilityLockEntry {
584                capability_type: CapabilityType::Skill,
585                installed_version: "1.0".into(),
586                description: "test skill".into(),
587                source_path: "".into(),
588                targets: BTreeMap::from([(
589                    "open-agents".into(),
590                    TargetLockEntry {
591                        emitted_files: Vec::new(),
592                        managed_hooks: Vec::new(),
593                        managed_mcp_entry: None,
594                        ownership: TargetOwnership::Generated,
595                        sha256: hash_bytes(b"content"),
596                        installed_path: ".agents/skills/test".into(),
597                    },
598                )]),
599                source: None,
600                scope: "project".into(),
601                pack: None,
602                implementation: None,
603                parameters: None,
604                workflow: None,
605                server: None,
606            },
607        );
608        write_lockfile_at(&path, &lf).unwrap();
609        let read = read_lockfile_at(&path).unwrap();
610        assert_eq!(read.capabilities.len(), 1);
611    }
612
613    #[test]
614    fn missing_target_ownership_defaults_to_generated() {
615        let tmp = TempDir::new().unwrap();
616        let path = tmp.path().join("tuff.lock");
617        fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
618        let read = read_lockfile_at(&path).unwrap();
619        assert!(read.capabilities.is_empty());
620    }
621
622    #[test]
623    fn hash_bytes_produces_consistent_output() {
624        let h1 = hash_bytes(b"hello");
625        let h2 = hash_bytes(b"hello");
626        assert_eq!(h1, h2);
627        assert_eq!(h1.len(), 64);
628        assert_ne!(h1, hash_bytes(b"world"));
629    }
630
631    #[test]
632    fn drift_status_reports_clean() {
633        let tmp = TempDir::new().unwrap();
634        let file = tmp.path().join("test.md");
635        fs::write(&file, "content").unwrap();
636
637        let emitted = crate::adapter::EmittedFile {
638            path: file.file_name().unwrap().to_string_lossy().to_string(),
639            hash: hash_bytes(b"content"),
640            baseline_hash: hash_bytes(b"content"),
641        };
642        assert_eq!(drift_status(tmp.path(), &emitted), "clean");
643    }
644
645    #[test]
646    fn drift_status_reports_modified() {
647        let tmp = TempDir::new().unwrap();
648        let file = tmp.path().join("test.md");
649        fs::write(&file, "different").unwrap();
650
651        let emitted = crate::adapter::EmittedFile {
652            path: file.file_name().unwrap().to_string_lossy().to_string(),
653            hash: hash_bytes(b"original"),
654            baseline_hash: hash_bytes(b"original"),
655        };
656        assert_eq!(drift_status(tmp.path(), &emitted), "modified");
657    }
658
659    #[test]
660    fn drift_status_reports_missing() {
661        let tmp = TempDir::new().unwrap();
662        let emitted = crate::adapter::EmittedFile {
663            path: "nonexistent.md".into(),
664            hash: "abc".into(),
665            baseline_hash: "abc".into(),
666        };
667        assert_eq!(drift_status(tmp.path(), &emitted), "missing");
668    }
669
670    #[test]
671    fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
672        let tmp = TempDir::new().unwrap();
673        let config_path = tmp.path().join("mcp.json");
674        let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
675        let both = |neighbour: &str| {
676            serde_json::to_string_pretty(&serde_json::json!({
677                "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
678            }))
679            .unwrap()
680        };
681        fs::write(&config_path, both("hand")).unwrap();
682        let managed = ManagedMcpEntry {
683            config_path: "mcp.json".into(),
684            baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
685        };
686
687        // Pretty-printing and neighbouring hand-written entries never matter,
688        // and editing the neighbour leaves ours clean.
689        assert_eq!(
690            managed_mcp_entry_status(tmp.path(), "github", &managed),
691            "clean"
692        );
693        fs::write(&config_path, both("edited")).unwrap();
694        assert_eq!(
695            managed_mcp_entry_status(tmp.path(), "github", &managed),
696            "clean"
697        );
698
699        // Editing our entry is modified; removing it, or the file, is missing.
700        fs::write(
701            &config_path,
702            r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
703        )
704        .unwrap();
705        assert_eq!(
706            managed_mcp_entry_status(tmp.path(), "github", &managed),
707            "modified"
708        );
709        fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
710        assert_eq!(
711            managed_mcp_entry_status(tmp.path(), "github", &managed),
712            "missing"
713        );
714        fs::remove_file(&config_path).unwrap();
715        assert_eq!(
716            managed_mcp_entry_status(tmp.path(), "github", &managed),
717            "missing"
718        );
719    }
720}