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