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::error::{Result, TuffError};
11use crate::manifest::{CapabilityType, ImplementationConfig, McpServerConfig, WorkflowConfig};
12
13/// Current on-disk schema. Older readable versions are migrated in memory
14/// by `read_lockfile_at`; writers always emit this version.
15pub const LOCKFILE_VERSION: u8 = 2;
16/// Oldest schema this build still reads.
17pub const OLDEST_READABLE_LOCKFILE_VERSION: u8 = 1;
18
19#[derive(Debug, Serialize, Deserialize)]
20pub struct Lockfile {
21    /// The schema version the file was read as, or `LOCKFILE_VERSION` for a
22    /// lockfile built in memory. Writers ignore it and emit the current one.
23    pub version: u8,
24    pub capabilities: BTreeMap<String, CapabilityLockEntry>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CapabilityLockEntry {
29    #[serde(rename = "type")]
30    pub capability_type: CapabilityType,
31    /// The capability's own version: a declared manifest version, or the
32    /// commit that was installed when nothing better exists. Which one is
33    /// recorded in `version_scheme`, never guessed from the string.
34    pub version: String,
35    #[serde(default)]
36    pub version_scheme: VersionScheme,
37    #[serde(default, skip_serializing_if = "String::is_empty")]
38    pub description: String,
39    /// Where this capability came from. One typed value, so every lifecycle
40    /// verb dispatches on `match` instead of comparing strings.
41    pub source: CapabilitySource,
42    pub targets: BTreeMap<String, TargetLockEntry>,
43    /// Cached from the manifest at install/update time, the same way
44    /// `description` is: after install, only the `files` a manifest declares
45    /// get copied to disk, `tuff.toml` itself does not, so this is the only
46    /// durable record of how a tool is invoked. Consumed by the generated
47    /// capability-index skill (RFC-103 tier 1).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub implementation: Option<ImplementationConfig>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub parameters: Option<serde_json::Value>,
52    /// Same rationale as `implementation`/`parameters`: a workflow's
53    /// `requires` list lives only in its manifest, which isn't copied to the
54    /// installed target directory.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub workflow: Option<WorkflowConfig>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub server: Option<McpServerConfig>,
59}
60
61/// What kind of string `CapabilityLockEntry::version` holds (RFC-105 D4).
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum VersionScheme {
65    /// A release chosen by tag resolution (RFC-101): `version` is the tag's
66    /// semver and `source.tag` names the tag.
67    Semver,
68    /// The version the manifest declares. Says nothing about releases.
69    #[default]
70    Declared,
71    /// A commit SHA: content-exact, semantically silent.
72    Sha,
73}
74
75/// The origin of an installed capability. Internally tagged as `kind` on
76/// the wire, so a lockfile row reads `[capabilities.source] kind = "git"`.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "kind", rename_all = "lowercase")]
79pub enum CapabilitySource {
80    Local(LocalSource),
81    Git(GitSource),
82    Catalog(CatalogSource),
83    Pack(PackProvenance),
84}
85
86impl CapabilitySource {
87    pub fn local(path: impl Into<String>) -> Self {
88        Self::Local(LocalSource { path: path.into() })
89    }
90
91    /// The `kind` string as written to the lockfile.
92    pub fn kind(&self) -> &'static str {
93        match self {
94            Self::Local(_) => "local",
95            Self::Git(_) => "git",
96            Self::Catalog(_) => "catalog",
97            Self::Pack(_) => "pack",
98        }
99    }
100
101    pub fn as_git(&self) -> Option<&GitSource> {
102        match self {
103            Self::Git(git) => Some(git),
104            _ => None,
105        }
106    }
107
108    pub fn as_pack(&self) -> Option<&PackProvenance> {
109        match self {
110            Self::Pack(pack) => Some(pack),
111            _ => None,
112        }
113    }
114
115    /// The local path a capability was installed from, when it has one.
116    pub fn local_path(&self) -> Option<&str> {
117        match self {
118            Self::Local(local) => Some(local.path.as_str()),
119            _ => None,
120        }
121    }
122
123    /// What kind of string `version` is, given where it came from (RFC-101).
124    /// A git install chosen by a release tag is `semver`; one whose version
125    /// is the pinned commit itself is `sha`; anything else, including a git
126    /// install carrying the version its manifest or frontmatter declared,
127    /// is `declared`.
128    pub fn version_scheme_for(&self, version: &str) -> VersionScheme {
129        match self {
130            Self::Git(git) if git.tag.is_some() => VersionScheme::Semver,
131            Self::Git(git) if git.git_ref == version => VersionScheme::Sha,
132            _ => VersionScheme::Declared,
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct LocalSource {
139    /// Path to the source directory, relative to the lockfile's root when it
140    /// lies inside it, absolute otherwise. Empty for an adopted capability
141    /// whose only copy is the installed tree.
142    #[serde(default)]
143    pub path: String,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct GitSource {
148    pub url: String,
149    /// Subdirectory within the repository holding the capability.
150    #[serde(default)]
151    pub path: String,
152    /// The commit that was installed. Always present.
153    #[serde(rename = "ref")]
154    pub git_ref: String,
155    /// The tag that chose `ref`, when one did (RFC-101).
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub tag: Option<String>,
158    /// The range the user asked for, when they did (RFC-101).
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub requested: Option<String>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct CatalogSource {
165    /// The catalog entry id: a built-in id, or the server's full registry
166    /// name when `registry` is set.
167    pub id: String,
168    /// That entry's version at install time.
169    pub version: String,
170    /// The MCP registry this entry came from, when it did not come from the
171    /// catalog compiled into the binary.
172    ///
173    /// Optional so a built-in install writes exactly what it always wrote:
174    /// an older Tuff reading a newer lockfile ignores the field rather than
175    /// failing to parse the row.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub registry: Option<String>,
178}
179
180/// Immutable pack release that delivered a capability entry.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct PackProvenance {
183    pub name: String,
184    pub version: String,
185    /// Artifact digest, bare lowercase hex. `sha256:` prefixes exist only at
186    /// the OCI boundary.
187    pub digest: String,
188    /// The OCI registry and repository this pack was pulled from
189    /// ("registry/repository", no tag), when known.
190    ///
191    /// `tuff add pack` only ever sees a local artifact file; it has no way to
192    /// know where that file came from unless the caller says so with
193    /// `--reference`. Absent, `tuff outdated` cannot check this capability
194    /// against anything and reports it as such rather than guessing.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub registry: Option<String>,
197    /// The member's path inside the pack's `sources/` tree.
198    #[serde(default)]
199    pub path: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct TargetLockEntry {
204    #[serde(
205        default,
206        rename = "managedHooks",
207        skip_serializing_if = "Vec::is_empty"
208    )]
209    pub managed_hooks: Vec<ManagedHook>,
210    #[serde(
211        default,
212        rename = "managedMcpEntry",
213        skip_serializing_if = "Option::is_none"
214    )]
215    pub managed_mcp_entry: Option<ManagedMcpEntry>,
216    #[serde(default)]
217    pub ownership: TargetOwnership,
218    #[serde(default)]
219    pub sha256: String,
220    #[serde(default)]
221    pub installed_path: String,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ManagedHook {
226    #[serde(rename = "settingsPath")]
227    pub settings_path: String,
228    pub event: String,
229    #[serde(
230        default,
231        rename = "canonicalEvent",
232        skip_serializing_if = "Option::is_none"
233    )]
234    pub canonical_event: Option<String>,
235    pub command: String,
236    #[serde(rename = "baselineHash")]
237    pub baseline_hash: String,
238}
239
240/// Baseline for one Tuff-managed `mcpServers.<id>` entry (RFC-102 stage b).
241///
242/// MCP config files are shared ground that users hand-edit, so the entry
243/// gets the managed-hook treatment: a content hash recorded at registration
244/// time, compared on every `check`/`list`, never whole-file ownership. The
245/// entry's key is the capability id, so only the file path and hash are
246/// stored.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ManagedMcpEntry {
249    #[serde(rename = "configPath")]
250    pub config_path: String,
251    #[serde(rename = "baselineHash")]
252    pub baseline_hash: String,
253}
254
255/// Hash an MCP entry value exactly as `managed_mcp_entry_status` will when
256/// it re-reads the file: canonical `serde_json` bytes, so on-disk pretty-
257/// printing never matters.
258pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
259    Ok(hash_bytes(&serde_json::to_vec(entry)?))
260}
261
262/// `"clean"`, `"modified"`, or `"missing"` for a managed MCP entry.
263pub fn managed_mcp_entry_status(
264    repo_root: &Path,
265    capability_id: &str,
266    entry: &ManagedMcpEntry,
267) -> &'static str {
268    let path = repo_root.join(&entry.config_path);
269    let Ok(raw) = std::fs::read_to_string(path) else {
270        return "missing";
271    };
272    let Ok(config): std::result::Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
273        return "modified";
274    };
275    let Some(current) = config
276        .get("mcpServers")
277        .and_then(|servers| servers.get(capability_id))
278    else {
279        return "missing";
280    };
281    match serde_json::to_vec(current) {
282        Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
283        _ => "modified",
284    }
285}
286
287pub fn managed_hooks_from_fragment(
288    repo_root: &Path,
289    settings_path: &str,
290    fragment: &serde_json::Value,
291) -> Result<Vec<ManagedHook>> {
292    managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
293}
294
295pub fn managed_hooks_from_fragment_with_canonical(
296    _repo_root: &Path,
297    settings_path: &str,
298    fragment: &serde_json::Value,
299    canonical_event: Option<&str>,
300) -> Result<Vec<ManagedHook>> {
301    let mut managed = Vec::new();
302    let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
303        return Ok(managed);
304    };
305
306    for (event, groups) in events {
307        let Some(groups) = groups.as_array() else {
308            continue;
309        };
310        for group in groups {
311            let hooks = group
312                .get("hooks")
313                .and_then(serde_json::Value::as_array)
314                .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
315            for hook in hooks {
316                let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
317                    continue;
318                };
319                let baseline = serde_json::to_vec(hook)?;
320                managed.push(ManagedHook {
321                    settings_path: settings_path.to_string(),
322                    event: event.clone(),
323                    canonical_event: canonical_event.map(str::to_owned),
324                    command: command.to_string(),
325                    baseline_hash: hash_bytes(&baseline),
326                });
327            }
328        }
329    }
330    Ok(managed)
331}
332
333pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
334    let path = repo_root.join(&hook.settings_path);
335    let Ok(settings) = std::fs::read_to_string(path) else {
336        return "missing";
337    };
338    let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
339    else {
340        return "modified";
341    };
342    let Some(groups) = settings
343        .get("hooks")
344        .and_then(|hooks| hooks.get(&hook.event))
345        .and_then(serde_json::Value::as_array)
346    else {
347        return "missing";
348    };
349
350    for group in groups {
351        let entries = group
352            .get("hooks")
353            .and_then(serde_json::Value::as_array)
354            .map_or_else(|| vec![group], |entries| entries.iter().collect());
355        for entry in entries {
356            if entry.get("command").and_then(serde_json::Value::as_str)
357                == Some(hook.command.as_str())
358            {
359                let Ok(content) = serde_json::to_vec(entry) else {
360                    return "modified";
361                };
362                return if hash_bytes(&content) == hook.baseline_hash {
363                    "clean"
364                } else {
365                    "modified"
366                };
367            }
368        }
369    }
370    "missing"
371}
372
373#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "lowercase")]
375pub enum TargetOwnership {
376    #[default]
377    Generated,
378    Imported,
379}
380
381/// The project-scope lockfile. Never falls through to the global one: the
382/// caller resolved a scope and this is the file for it (RFC-105 D3).
383pub fn project_lockfile(repo_root: &Path) -> PathBuf {
384    repo_root.join("tuff.lock")
385}
386
387/// The lockfile for a resolved scope: `<root>/tuff.lock` for a project,
388/// the XDG state file for the global scope (where `scope_root` is the home
389/// directory). The scope is always passed, never inferred from the path.
390pub fn scoped_lockfile(scope_root: &Path, scope: crate::resolver::Scope) -> PathBuf {
391    match scope {
392        crate::resolver::Scope::Project => project_lockfile(scope_root),
393        crate::resolver::Scope::Global => crate::paths::global_lockfile(scope_root),
394    }
395}
396
397pub fn require_scoped_lockfile(
398    scope_root: &Path,
399    scope: crate::resolver::Scope,
400) -> Result<Lockfile> {
401    read_lockfile_at(&scoped_lockfile(scope_root, scope))
402}
403
404pub fn write_scoped_lockfile(
405    scope_root: &Path,
406    scope: crate::resolver::Scope,
407    lockfile: &Lockfile,
408) -> Result<()> {
409    write_lockfile_at(&scoped_lockfile(scope_root, scope), lockfile)
410}
411
412pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
413    let lock_path = project_lockfile(repo_root);
414    init_lockfile_at(&lock_path)?;
415    Ok(lock_path)
416}
417
418pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
419    if !lock_path.exists() {
420        write_lockfile_at(
421            lock_path,
422            &Lockfile {
423                version: LOCKFILE_VERSION,
424                capabilities: BTreeMap::new(),
425            },
426        )?;
427    }
428    Ok(())
429}
430
431pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
432    read_lockfile_at(&project_lockfile(repo_root))
433}
434
435/// Read a lockfile that may legitimately not exist.
436///
437/// `Ok(None)` means "no lockfile here", which is normal for the global
438/// scope on a machine that has never used `--global`. Anything else, in
439/// particular a corrupt or too-new file, is an error: reporting it as
440/// "nothing installed" would be a confident wrong answer.
441pub fn read_optional_lockfile(path: &Path) -> Result<Option<Lockfile>> {
442    match read_lockfile_at(path) {
443        Ok(lockfile) => Ok(Some(lockfile)),
444        Err(error) if error.kind() == crate::error::ErrorKind::NotFound => Ok(None),
445        Err(error) => Err(error),
446    }
447}
448
449/// Read a lockfile of any supported schema version into the current model.
450///
451/// The version is read before anything else is deserialised, so a file from
452/// a newer tuff fails with a message about versions rather than a shape
453/// error naming some field the reader has never heard of.
454pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
455    if !path.exists() {
456        let parent = path.parent().unwrap_or(Path::new("."));
457        return Err(TuffError::not_found(format!(
458            "{} is missing",
459            parent
460                .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
461                .display()
462        ))
463        .with_hint("run 'tuff init' first"));
464    }
465    let raw = std::fs::read_to_string(path)?;
466    let version = peek_version(&raw, path)?;
467    let rows: Vec<Row> = match version {
468        1 => read_v1_rows(&raw)?,
469        2 => read_v2_rows(&raw)?,
470        newer => {
471            return Err(TuffError::unsupported(format!(
472                "unsupported lockfile version: {newer} ({} was written by a newer tuff; this tuff {} reads versions {OLDEST_READABLE_LOCKFILE_VERSION} to {LOCKFILE_VERSION}, upgrade tuff)",
473                path.display(),
474                env!("CARGO_PKG_VERSION")
475            )));
476        }
477    };
478    let mut capabilities: BTreeMap<String, CapabilityLockEntry> = BTreeMap::new();
479    for row in rows {
480        let Row {
481            name,
482            target,
483            target_entry,
484            entry,
485        } = row;
486        match capabilities.entry(name) {
487            std::collections::btree_map::Entry::Occupied(mut existing) => {
488                existing.get_mut().targets.insert(target, target_entry);
489            }
490            std::collections::btree_map::Entry::Vacant(slot) => {
491                let mut entry = entry;
492                entry.targets.insert(target, target_entry);
493                slot.insert(entry);
494            }
495        }
496    }
497    Ok(Lockfile {
498        version,
499        capabilities,
500    })
501}
502
503/// One wire row folded to its capability entry plus its target.
504struct Row {
505    name: String,
506    target: String,
507    target_entry: TargetLockEntry,
508    entry: CapabilityLockEntry,
509}
510
511fn peek_version(raw: &str, path: &Path) -> Result<u8> {
512    #[derive(Deserialize)]
513    struct VersionOnly {
514        version: Option<u8>,
515    }
516    let peek: VersionOnly = toml::from_str(raw).map_err(|error| {
517        TuffError::corrupt(format!(
518            "{} is not a valid lockfile: {}",
519            path.display(),
520            error.message()
521        ))
522    })?;
523    match peek.version {
524        Some(version) if version >= OLDEST_READABLE_LOCKFILE_VERSION => Ok(version),
525        Some(version) => Err(TuffError::unsupported(format!(
526            "unsupported lockfile version: {version} ({} predates every schema this tuff reads)",
527            path.display()
528        ))),
529        None => Err(TuffError::corrupt(format!(
530            "{} has no version field; it is not a Tuff lockfile or it is corrupt",
531            path.display()
532        ))),
533    }
534}
535
536/// Schema version 1, read for migration only (RFC-105 D5). Never written.
537fn read_v1_rows(raw: &str) -> Result<Vec<Row>> {
538    let wire: WireLockfileV1 = toml::from_str(raw)
539        .map_err(|error| TuffError::corrupt(format!("invalid version 1 lockfile: {error}")))?;
540    Ok(wire
541        .capabilities
542        .into_iter()
543        .map(|item| {
544            let source = match item.pack {
545                // A pack member was written as "local" with an empty path
546                // plus a pack table; the pack is the real origin. The member
547                // path inside the pack was not recorded in v1, and the member
548                // id is what `tuff add pack` used, so it is the best backfill.
549                Some(pack) => CapabilitySource::Pack(PackProvenance {
550                    name: pack.name,
551                    version: pack.version,
552                    digest: pack.digest,
553                    registry: pack.registry,
554                    path: item.name.clone(),
555                }),
556                None => match item.source.as_str() {
557                    "git" => CapabilitySource::Git(GitSource {
558                        url: item.repository,
559                        path: item.source_path,
560                        git_ref: item.resolved_ref,
561                        tag: None,
562                        requested: None,
563                    }),
564                    // A v1 lockfile predates registry installs, so every
565                    // catalog row in one came from the built-in catalog.
566                    "catalog" => CapabilitySource::Catalog(CatalogSource {
567                        id: item.source_path,
568                        version: item.resolved_ref,
569                        registry: None,
570                    }),
571                    // The generated capability index wrote a sentinel path
572                    // in v1; it has no source tree and v2 says so plainly.
573                    _ if item.source_path == "<generated>" => CapabilitySource::local(""),
574                    _ => CapabilitySource::local(item.source_path),
575                },
576            };
577            let version_scheme = source.version_scheme_for(&item.version);
578            Row {
579                name: item.name,
580                target: item.target,
581                target_entry: TargetLockEntry {
582                    managed_hooks: item.managed_hooks,
583                    managed_mcp_entry: item.managed_mcp_entry,
584                    ownership: item.ownership,
585                    sha256: item.sha256,
586                    installed_path: item.installed_path,
587                },
588                entry: CapabilityLockEntry {
589                    capability_type: item.capability_type,
590                    version: item.version,
591                    version_scheme,
592                    description: item.description,
593                    source,
594                    targets: BTreeMap::new(),
595                    implementation: item.implementation,
596                    parameters: item.parameters,
597                    workflow: item.workflow,
598                    server: item.server,
599                },
600            }
601        })
602        .collect())
603}
604
605fn read_v2_rows(raw: &str) -> Result<Vec<Row>> {
606    let wire: WireLockfile = toml::from_str(raw)
607        .map_err(|error| TuffError::corrupt(format!("invalid lockfile: {error}")))?;
608    Ok(wire
609        .capabilities
610        .into_iter()
611        .map(|item| Row {
612            name: item.name,
613            target: item.target,
614            target_entry: TargetLockEntry {
615                managed_hooks: item.managed_hooks,
616                managed_mcp_entry: item.managed_mcp_entry,
617                ownership: item.ownership,
618                sha256: item.sha256,
619                installed_path: item.installed_path,
620            },
621            entry: CapabilityLockEntry {
622                capability_type: item.capability_type,
623                version: item.version,
624                version_scheme: item.version_scheme,
625                description: item.description,
626                source: item.source,
627                targets: BTreeMap::new(),
628                implementation: item.implementation,
629                parameters: item.parameters,
630                workflow: item.workflow,
631                server: item.server,
632            },
633        })
634        .collect())
635}
636
637pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
638    write_lockfile_at(&project_lockfile(repo_root), lockfile)
639}
640
641pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
642    if let Some(parent) = path.parent() {
643        std::fs::create_dir_all(parent)?;
644    }
645    let mut capabilities = Vec::new();
646    for (name, entry) in &lockfile.capabilities {
647        for (target, target_entry) in &entry.targets {
648            capabilities.push(WireCapability {
649                name: name.clone(),
650                capability_type: entry.capability_type,
651                version: entry.version.clone(),
652                version_scheme: entry.version_scheme,
653                description: entry.description.clone(),
654                target: target.clone(),
655                installed_path: target_entry.installed_path.clone(),
656                sha256: target_entry.sha256.clone(),
657                ownership: target_entry.ownership,
658                source: entry.source.clone(),
659                managed_hooks: target_entry.managed_hooks.clone(),
660                managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
661                implementation: entry.implementation.clone(),
662                parameters: entry.parameters.clone(),
663                workflow: entry.workflow.clone(),
664                server: entry.server.clone(),
665            });
666        }
667    }
668    capabilities.sort_by(|a, b| {
669        a.name
670            .cmp(&b.name)
671            .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
672            .then_with(|| a.target.cmp(&b.target))
673            .then_with(|| a.installed_path.cmp(&b.installed_path))
674    });
675    let wire = WireLockfile {
676        version: LOCKFILE_VERSION,
677        capabilities,
678    };
679    let content = format!(
680        "# Tuff lockfile. Each entry records one capability installation target.\n{}\n",
681        toml::to_string_pretty(&wire)?
682    );
683    std::fs::write(path, content)?;
684    Ok(())
685}
686
687/// Schema version 2 (RFC-105 D1). Scalars first, tables after, so the TOML
688/// serializer never has to emit a value beneath a table.
689#[derive(Debug, Serialize, Deserialize)]
690struct WireLockfile {
691    version: u8,
692    capabilities: Vec<WireCapability>,
693}
694
695#[derive(Debug, Serialize, Deserialize)]
696struct WireCapability {
697    name: String,
698    #[serde(rename = "type")]
699    capability_type: CapabilityType,
700    #[serde(default)]
701    version: String,
702    #[serde(default)]
703    version_scheme: VersionScheme,
704    #[serde(default, skip_serializing_if = "String::is_empty")]
705    description: String,
706    target: String,
707    installed_path: String,
708    sha256: String,
709    #[serde(default)]
710    ownership: TargetOwnership,
711    source: CapabilitySource,
712    #[serde(default, skip_serializing_if = "Vec::is_empty")]
713    managed_hooks: Vec<ManagedHook>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    managed_mcp_entry: Option<ManagedMcpEntry>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    implementation: Option<ImplementationConfig>,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    parameters: Option<serde_json::Value>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    workflow: Option<WorkflowConfig>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    server: Option<McpServerConfig>,
724}
725
726/// Schema version 1 as tuff 0.1.x wrote it. Read-only; see `read_v1_rows`.
727#[derive(Debug, Deserialize)]
728struct WireLockfileV1 {
729    #[allow(dead_code)]
730    version: u8,
731    capabilities: Vec<WireCapabilityV1>,
732}
733
734#[derive(Debug, Deserialize)]
735struct WireCapabilityV1 {
736    name: String,
737    #[serde(rename = "type")]
738    capability_type: CapabilityType,
739    source: String,
740    #[serde(default)]
741    repository: String,
742    #[serde(default)]
743    source_path: String,
744    #[serde(default)]
745    resolved_ref: String,
746    sha256: String,
747    target: String,
748    installed_path: String,
749    #[serde(default)]
750    version: String,
751    #[serde(default)]
752    description: String,
753    #[serde(default)]
754    ownership: TargetOwnership,
755    #[serde(default)]
756    managed_hooks: Vec<ManagedHook>,
757    #[serde(default)]
758    managed_mcp_entry: Option<ManagedMcpEntry>,
759    #[serde(default)]
760    pack: Option<PackProvenanceV1>,
761    #[serde(default)]
762    implementation: Option<ImplementationConfig>,
763    #[serde(default)]
764    parameters: Option<serde_json::Value>,
765    #[serde(default)]
766    workflow: Option<WorkflowConfig>,
767    #[serde(default)]
768    server: Option<McpServerConfig>,
769}
770
771#[derive(Debug, Deserialize)]
772struct PackProvenanceV1 {
773    name: String,
774    version: String,
775    digest: String,
776    #[serde(default)]
777    registry: Option<String>,
778}
779
780pub fn hash_bytes(content: &[u8]) -> String {
781    let mut hasher = Sha256::new();
782    hasher.update(content);
783    format!("{:x}", hasher.finalize())
784}
785
786pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
787    path.strip_prefix(repo_root)
788        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
789        .unwrap_or_else(|_| path.to_string_lossy().to_string())
790}
791
792pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
793    if path.is_absolute() {
794        path.to_path_buf()
795    } else {
796        repo_root.join(path)
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use std::fs;
804    use tempfile::TempDir;
805
806    #[test]
807    fn init_lockfile_at_creates_new_file() {
808        let tmp = TempDir::new().unwrap();
809        let path = tmp.path().join("tuff.lock");
810        init_lockfile_at(&path).unwrap();
811        assert!(path.exists());
812
813        let lf = read_lockfile_at(&path).unwrap();
814        assert_eq!(lf.version, LOCKFILE_VERSION);
815        assert!(lf.capabilities.is_empty());
816    }
817
818    #[test]
819    fn read_lockfile_at_rejects_missing() {
820        let tmp = TempDir::new().unwrap();
821        let path = tmp.path().join("tuff.lock");
822        assert!(read_lockfile_at(&path).is_err());
823    }
824
825    #[test]
826    fn read_lockfile_at_rejects_v4_schema() {
827        let tmp = TempDir::new().unwrap();
828        let path = tmp.path().join("tuff.lock");
829        fs::write(&path, "version = 4\ncapabilities = []\n").unwrap();
830
831        let error = read_lockfile_at(&path).unwrap_err();
832        assert!(
833            error
834                .to_string()
835                .contains("unsupported lockfile version: 4")
836        );
837    }
838
839    #[test]
840    fn write_and_read_roundtrip() {
841        let tmp = TempDir::new().unwrap();
842        let path = tmp.path().join("tuff.lock");
843        let mut lf = Lockfile {
844            version: LOCKFILE_VERSION,
845            capabilities: BTreeMap::new(),
846        };
847        lf.capabilities.insert(
848            "test".into(),
849            CapabilityLockEntry {
850                capability_type: CapabilityType::Skill,
851                version: "1.0".into(),
852                version_scheme: VersionScheme::Declared,
853                description: "test skill".into(),
854                source: CapabilitySource::local(""),
855                targets: BTreeMap::from([(
856                    "open-agents".into(),
857                    TargetLockEntry {
858                        managed_hooks: Vec::new(),
859                        managed_mcp_entry: None,
860                        ownership: TargetOwnership::Generated,
861                        sha256: hash_bytes(b"content"),
862                        installed_path: ".agents/skills/test".into(),
863                    },
864                )]),
865                implementation: None,
866                parameters: None,
867                workflow: None,
868                server: None,
869            },
870        );
871        write_lockfile_at(&path, &lf).unwrap();
872        let read = read_lockfile_at(&path).unwrap();
873        assert_eq!(read.capabilities.len(), 1);
874    }
875
876    #[test]
877    fn missing_target_ownership_defaults_to_generated() {
878        let tmp = TempDir::new().unwrap();
879        let path = tmp.path().join("tuff.lock");
880        fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
881        let read = read_lockfile_at(&path).unwrap();
882        assert!(read.capabilities.is_empty());
883    }
884
885    #[test]
886    fn hash_bytes_produces_consistent_output() {
887        let h1 = hash_bytes(b"hello");
888        let h2 = hash_bytes(b"hello");
889        assert_eq!(h1, h2);
890        assert_eq!(h1.len(), 64);
891        assert_ne!(h1, hash_bytes(b"world"));
892    }
893
894    #[test]
895    fn a_version_1_lockfile_migrates_every_source_kind() {
896        let tmp = TempDir::new().unwrap();
897        let path = tmp.path().join("tuff.lock");
898        fs::write(
899            &path,
900            r#"version = 1
901
902[[capabilities]]
903name = "git-skill"
904type = "skill"
905source = "git"
906repository = "https://example.com/skills.git"
907source_path = "skills/git-skill"
908resolved_ref = "9b9c499"
909sha256 = "aa"
910target = "open-agents"
911installed_path = ".agents/skills/git-skill"
912version = "9b9c499"
913
914[[capabilities]]
915name = "memory"
916type = "mcp-server"
917source = "catalog"
918repository = "builtin"
919source_path = "memory"
920resolved_ref = "1.0.0"
921sha256 = "bb"
922target = "open-agents"
923installed_path = ".agents/mcp-servers/memory"
924version = "1.0.0"
925
926[[capabilities]]
927name = "pack-skill"
928type = "skill"
929source = "local"
930source_path = ""
931resolved_ref = ""
932sha256 = "cc"
933target = "open-agents"
934installed_path = ".agents/skills/pack-skill"
935version = "1.5.0"
936
937[capabilities.pack]
938name = "com.acme/fixture"
939version = "1.0.0"
940digest = "dd"
941registry = "ghcr.io/acme/fixture"
942
943[[capabilities]]
944name = "local-skill"
945type = "skill"
946source = "local"
947source_path = "sources/local-skill"
948resolved_ref = ""
949sha256 = "ee"
950target = "open-agents"
951installed_path = ".agents/skills/local-skill"
952version = "1.0.0"
953"#,
954        )
955        .unwrap();
956
957        let lf = read_lockfile_at(&path).unwrap();
958        assert_eq!(lf.version, 1, "the version read is reported, not rewritten");
959        assert_eq!(
960            lf.capabilities["git-skill"].source,
961            CapabilitySource::Git(GitSource {
962                url: "https://example.com/skills.git".into(),
963                path: "skills/git-skill".into(),
964                git_ref: "9b9c499".into(),
965                tag: None,
966                requested: None,
967            })
968        );
969        assert_eq!(
970            lf.capabilities["git-skill"].version_scheme,
971            VersionScheme::Sha
972        );
973        assert_eq!(
974            lf.capabilities["memory"].source,
975            CapabilitySource::Catalog(CatalogSource {
976                id: "memory".into(),
977                version: "1.0.0".into(),
978                registry: None,
979            })
980        );
981        assert_eq!(
982            lf.capabilities["pack-skill"].source,
983            CapabilitySource::Pack(PackProvenance {
984                name: "com.acme/fixture".into(),
985                version: "1.0.0".into(),
986                digest: "dd".into(),
987                registry: Some("ghcr.io/acme/fixture".into()),
988                path: "pack-skill".into(),
989            })
990        );
991        assert_eq!(
992            lf.capabilities["local-skill"].source,
993            CapabilitySource::local("sources/local-skill")
994        );
995        assert_eq!(
996            lf.capabilities["local-skill"].version_scheme,
997            VersionScheme::Declared
998        );
999
1000        // Writing produces v2, and v2 round-trips byte for byte.
1001        write_lockfile_at(&path, &lf).unwrap();
1002        let written = fs::read_to_string(&path).unwrap();
1003        assert!(written.contains("version = 2\n"));
1004        assert!(written.contains("kind = \"pack\""));
1005        assert!(!written.contains("resolved_ref"));
1006        let again = read_lockfile_at(&path).unwrap();
1007        assert_eq!(again.version, 2);
1008        write_lockfile_at(&path, &again).unwrap();
1009        assert_eq!(fs::read_to_string(&path).unwrap(), written);
1010    }
1011
1012    #[test]
1013    fn a_lockfile_without_a_version_is_corrupt_not_empty() {
1014        let tmp = TempDir::new().unwrap();
1015        let path = tmp.path().join("tuff.lock");
1016        fs::write(&path, "capabilities = []\n").unwrap();
1017        let error = read_lockfile_at(&path).unwrap_err().to_string();
1018        assert!(error.contains("no version field"), "{error}");
1019
1020        fs::write(&path, "version = 2\n[[capabilities]\n").unwrap();
1021        let error = read_lockfile_at(&path).unwrap_err().to_string();
1022        assert!(error.contains("not a valid lockfile"), "{error}");
1023    }
1024
1025    #[test]
1026    fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
1027        let tmp = TempDir::new().unwrap();
1028        let config_path = tmp.path().join("mcp.json");
1029        let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
1030        let both = |neighbour: &str| {
1031            serde_json::to_string_pretty(&serde_json::json!({
1032                "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
1033            }))
1034            .unwrap()
1035        };
1036        fs::write(&config_path, both("hand")).unwrap();
1037        let managed = ManagedMcpEntry {
1038            config_path: "mcp.json".into(),
1039            baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
1040        };
1041
1042        // Pretty-printing and neighbouring hand-written entries never matter,
1043        // and editing the neighbour leaves ours clean.
1044        assert_eq!(
1045            managed_mcp_entry_status(tmp.path(), "github", &managed),
1046            "clean"
1047        );
1048        fs::write(&config_path, both("edited")).unwrap();
1049        assert_eq!(
1050            managed_mcp_entry_status(tmp.path(), "github", &managed),
1051            "clean"
1052        );
1053
1054        // Editing our entry is modified; removing it, or the file, is missing.
1055        fs::write(
1056            &config_path,
1057            r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
1058        )
1059        .unwrap();
1060        assert_eq!(
1061            managed_mcp_entry_status(tmp.path(), "github", &managed),
1062            "modified"
1063        );
1064        fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
1065        assert_eq!(
1066            managed_mcp_entry_status(tmp.path(), "github", &managed),
1067            "missing"
1068        );
1069        fs::remove_file(&config_path).unwrap();
1070        assert_eq!(
1071            managed_mcp_entry_status(tmp.path(), "github", &managed),
1072            "missing"
1073        );
1074    }
1075}