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