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