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 of any supported schema version into the current model.
423///
424/// The version is read before anything else is deserialised, so a file from
425/// a newer tuff fails with a message about versions rather than a shape
426/// error naming some field the reader has never heard of.
427pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
428    if !path.exists() {
429        let parent = path.parent().unwrap_or(Path::new("."));
430        return Err(TuffError::new(format!(
431            "{} is missing; run 'tuff init' first",
432            parent
433                .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
434                .display()
435        )));
436    }
437    let raw = std::fs::read_to_string(path)?;
438    let version = peek_version(&raw, path)?;
439    let rows: Vec<Row> = match version {
440        1 => read_v1_rows(&raw)?,
441        2 => read_v2_rows(&raw)?,
442        newer => {
443            return Err(TuffError::new(format!(
444                "unsupported lockfile version: {newer} ({} was written by a newer tuff; this tuff {} reads versions {OLDEST_READABLE_LOCKFILE_VERSION} to {LOCKFILE_VERSION}, upgrade tuff)",
445                path.display(),
446                env!("CARGO_PKG_VERSION")
447            )));
448        }
449    };
450    let mut capabilities: BTreeMap<String, CapabilityLockEntry> = BTreeMap::new();
451    for row in rows {
452        let Row {
453            name,
454            target,
455            target_entry,
456            entry,
457        } = row;
458        match capabilities.entry(name) {
459            std::collections::btree_map::Entry::Occupied(mut existing) => {
460                existing.get_mut().targets.insert(target, target_entry);
461            }
462            std::collections::btree_map::Entry::Vacant(slot) => {
463                let mut entry = entry;
464                entry.targets.insert(target, target_entry);
465                slot.insert(entry);
466            }
467        }
468    }
469    Ok(Lockfile {
470        version,
471        capabilities,
472    })
473}
474
475/// One wire row folded to its capability entry plus its target.
476struct Row {
477    name: String,
478    target: String,
479    target_entry: TargetLockEntry,
480    entry: CapabilityLockEntry,
481}
482
483fn peek_version(raw: &str, path: &Path) -> Result<u8> {
484    #[derive(Deserialize)]
485    struct VersionOnly {
486        version: Option<u8>,
487    }
488    let peek: VersionOnly = toml::from_str(raw).map_err(|error| {
489        TuffError::new(format!(
490            "{} is not a valid lockfile: {}",
491            path.display(),
492            error.message()
493        ))
494    })?;
495    match peek.version {
496        Some(version) if version >= OLDEST_READABLE_LOCKFILE_VERSION => Ok(version),
497        Some(version) => Err(TuffError::new(format!(
498            "unsupported lockfile version: {version} ({} predates every schema this tuff reads)",
499            path.display()
500        ))),
501        None => Err(TuffError::new(format!(
502            "{} has no version field; it is not a Tuff lockfile or it is corrupt",
503            path.display()
504        ))),
505    }
506}
507
508/// Schema version 1, read for migration only (RFC-105 D5). Never written.
509fn read_v1_rows(raw: &str) -> Result<Vec<Row>> {
510    let wire: WireLockfileV1 = toml::from_str(raw)
511        .map_err(|error| TuffError::new(format!("invalid version 1 lockfile: {error}")))?;
512    Ok(wire
513        .capabilities
514        .into_iter()
515        .map(|item| {
516            let source = match item.pack {
517                // A pack member was written as "local" with an empty path
518                // plus a pack table; the pack is the real origin. The member
519                // path inside the pack was not recorded in v1, and the member
520                // id is what `tuff add pack` used, so it is the best backfill.
521                Some(pack) => CapabilitySource::Pack(PackProvenance {
522                    name: pack.name,
523                    version: pack.version,
524                    digest: pack.digest,
525                    registry: pack.registry,
526                    path: item.name.clone(),
527                }),
528                None => match item.source.as_str() {
529                    "git" => CapabilitySource::Git(GitSource {
530                        url: item.repository,
531                        path: item.source_path,
532                        git_ref: item.resolved_ref,
533                        tag: None,
534                        requested: None,
535                    }),
536                    "catalog" => CapabilitySource::Catalog(CatalogSource {
537                        id: item.source_path,
538                        version: item.resolved_ref,
539                    }),
540                    // The generated capability index wrote a sentinel path
541                    // in v1; it has no source tree and v2 says so plainly.
542                    _ if item.source_path == "<generated>" => CapabilitySource::local(""),
543                    _ => CapabilitySource::local(item.source_path),
544                },
545            };
546            let version_scheme = source.default_version_scheme();
547            Row {
548                name: item.name,
549                target: item.target,
550                target_entry: TargetLockEntry {
551                    managed_hooks: item.managed_hooks,
552                    managed_mcp_entry: item.managed_mcp_entry,
553                    ownership: item.ownership,
554                    sha256: item.sha256,
555                    installed_path: item.installed_path,
556                },
557                entry: CapabilityLockEntry {
558                    capability_type: item.capability_type,
559                    version: item.version,
560                    version_scheme,
561                    description: item.description,
562                    source,
563                    targets: BTreeMap::new(),
564                    implementation: item.implementation,
565                    parameters: item.parameters,
566                    workflow: item.workflow,
567                    server: item.server,
568                },
569            }
570        })
571        .collect())
572}
573
574fn read_v2_rows(raw: &str) -> Result<Vec<Row>> {
575    let wire: WireLockfile = toml::from_str(raw)
576        .map_err(|error| TuffError::new(format!("invalid lockfile: {error}")))?;
577    Ok(wire
578        .capabilities
579        .into_iter()
580        .map(|item| Row {
581            name: item.name,
582            target: item.target,
583            target_entry: TargetLockEntry {
584                managed_hooks: item.managed_hooks,
585                managed_mcp_entry: item.managed_mcp_entry,
586                ownership: item.ownership,
587                sha256: item.sha256,
588                installed_path: item.installed_path,
589            },
590            entry: CapabilityLockEntry {
591                capability_type: item.capability_type,
592                version: item.version,
593                version_scheme: item.version_scheme,
594                description: item.description,
595                source: item.source,
596                targets: BTreeMap::new(),
597                implementation: item.implementation,
598                parameters: item.parameters,
599                workflow: item.workflow,
600                server: item.server,
601            },
602        })
603        .collect())
604}
605
606pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
607    write_lockfile_at(&project_lockfile(repo_root), lockfile)
608}
609
610pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
611    if let Some(parent) = path.parent() {
612        std::fs::create_dir_all(parent)?;
613    }
614    let mut capabilities = Vec::new();
615    for (name, entry) in &lockfile.capabilities {
616        for (target, target_entry) in &entry.targets {
617            capabilities.push(WireCapability {
618                name: name.clone(),
619                capability_type: entry.capability_type,
620                version: entry.version.clone(),
621                version_scheme: entry.version_scheme,
622                description: entry.description.clone(),
623                target: target.clone(),
624                installed_path: target_entry.installed_path.clone(),
625                sha256: target_entry.sha256.clone(),
626                ownership: target_entry.ownership,
627                source: entry.source.clone(),
628                managed_hooks: target_entry.managed_hooks.clone(),
629                managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
630                implementation: entry.implementation.clone(),
631                parameters: entry.parameters.clone(),
632                workflow: entry.workflow.clone(),
633                server: entry.server.clone(),
634            });
635        }
636    }
637    capabilities.sort_by(|a, b| {
638        a.name
639            .cmp(&b.name)
640            .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
641            .then_with(|| a.target.cmp(&b.target))
642            .then_with(|| a.installed_path.cmp(&b.installed_path))
643    });
644    let wire = WireLockfile {
645        version: LOCKFILE_VERSION,
646        capabilities,
647    };
648    let content = format!(
649        "# Tuff lockfile. Each entry records one capability installation target.\n{}\n",
650        toml::to_string_pretty(&wire)?
651    );
652    std::fs::write(path, content)?;
653    Ok(())
654}
655
656/// Schema version 2 (RFC-105 D1). Scalars first, tables after, so the TOML
657/// serializer never has to emit a value beneath a table.
658#[derive(Debug, Serialize, Deserialize)]
659struct WireLockfile {
660    version: u8,
661    capabilities: Vec<WireCapability>,
662}
663
664#[derive(Debug, Serialize, Deserialize)]
665struct WireCapability {
666    name: String,
667    #[serde(rename = "type")]
668    capability_type: CapabilityType,
669    #[serde(default)]
670    version: String,
671    #[serde(default)]
672    version_scheme: VersionScheme,
673    #[serde(default, skip_serializing_if = "String::is_empty")]
674    description: String,
675    target: String,
676    installed_path: String,
677    sha256: String,
678    #[serde(default)]
679    ownership: TargetOwnership,
680    source: CapabilitySource,
681    #[serde(default, skip_serializing_if = "Vec::is_empty")]
682    managed_hooks: Vec<ManagedHook>,
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    managed_mcp_entry: Option<ManagedMcpEntry>,
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    implementation: Option<ImplementationConfig>,
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    parameters: Option<serde_json::Value>,
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    workflow: Option<WorkflowConfig>,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    server: Option<McpServerConfig>,
693}
694
695/// Schema version 1 as tuff 0.1.x wrote it. Read-only; see `read_v1_rows`.
696#[derive(Debug, Deserialize)]
697struct WireLockfileV1 {
698    #[allow(dead_code)]
699    version: u8,
700    capabilities: Vec<WireCapabilityV1>,
701}
702
703#[derive(Debug, Deserialize)]
704struct WireCapabilityV1 {
705    name: String,
706    #[serde(rename = "type")]
707    capability_type: CapabilityType,
708    source: String,
709    #[serde(default)]
710    repository: String,
711    #[serde(default)]
712    source_path: String,
713    #[serde(default)]
714    resolved_ref: String,
715    sha256: String,
716    target: String,
717    installed_path: String,
718    #[serde(default)]
719    version: String,
720    #[serde(default)]
721    description: String,
722    #[serde(default)]
723    ownership: TargetOwnership,
724    #[serde(default)]
725    managed_hooks: Vec<ManagedHook>,
726    #[serde(default)]
727    managed_mcp_entry: Option<ManagedMcpEntry>,
728    #[serde(default)]
729    pack: Option<PackProvenanceV1>,
730    #[serde(default)]
731    implementation: Option<ImplementationConfig>,
732    #[serde(default)]
733    parameters: Option<serde_json::Value>,
734    #[serde(default)]
735    workflow: Option<WorkflowConfig>,
736    #[serde(default)]
737    server: Option<McpServerConfig>,
738}
739
740#[derive(Debug, Deserialize)]
741struct PackProvenanceV1 {
742    name: String,
743    version: String,
744    digest: String,
745    #[serde(default)]
746    registry: Option<String>,
747}
748
749pub fn hash_bytes(content: &[u8]) -> String {
750    let mut hasher = Sha256::new();
751    hasher.update(content);
752    format!("{:x}", hasher.finalize())
753}
754
755pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
756    path.strip_prefix(repo_root)
757        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
758        .unwrap_or_else(|_| path.to_string_lossy().to_string())
759}
760
761pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
762    if path.is_absolute() {
763        path.to_path_buf()
764    } else {
765        repo_root.join(path)
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use std::fs;
773    use tempfile::TempDir;
774
775    #[test]
776    fn init_lockfile_at_creates_new_file() {
777        let tmp = TempDir::new().unwrap();
778        let path = tmp.path().join("tuff.lock");
779        init_lockfile_at(&path).unwrap();
780        assert!(path.exists());
781
782        let lf = read_lockfile_at(&path).unwrap();
783        assert_eq!(lf.version, LOCKFILE_VERSION);
784        assert!(lf.capabilities.is_empty());
785    }
786
787    #[test]
788    fn read_lockfile_at_rejects_missing() {
789        let tmp = TempDir::new().unwrap();
790        let path = tmp.path().join("tuff.lock");
791        assert!(read_lockfile_at(&path).is_err());
792    }
793
794    #[test]
795    fn read_lockfile_at_rejects_v4_schema() {
796        let tmp = TempDir::new().unwrap();
797        let path = tmp.path().join("tuff.lock");
798        fs::write(&path, "version = 4\ncapabilities = []\n").unwrap();
799
800        let error = read_lockfile_at(&path).unwrap_err();
801        assert!(
802            error
803                .to_string()
804                .contains("unsupported lockfile version: 4")
805        );
806    }
807
808    #[test]
809    fn write_and_read_roundtrip() {
810        let tmp = TempDir::new().unwrap();
811        let path = tmp.path().join("tuff.lock");
812        let mut lf = Lockfile {
813            version: LOCKFILE_VERSION,
814            capabilities: BTreeMap::new(),
815        };
816        lf.capabilities.insert(
817            "test".into(),
818            CapabilityLockEntry {
819                capability_type: CapabilityType::Skill,
820                version: "1.0".into(),
821                version_scheme: VersionScheme::Declared,
822                description: "test skill".into(),
823                source: CapabilitySource::local(""),
824                targets: BTreeMap::from([(
825                    "open-agents".into(),
826                    TargetLockEntry {
827                        managed_hooks: Vec::new(),
828                        managed_mcp_entry: None,
829                        ownership: TargetOwnership::Generated,
830                        sha256: hash_bytes(b"content"),
831                        installed_path: ".agents/skills/test".into(),
832                    },
833                )]),
834                implementation: None,
835                parameters: None,
836                workflow: None,
837                server: None,
838            },
839        );
840        write_lockfile_at(&path, &lf).unwrap();
841        let read = read_lockfile_at(&path).unwrap();
842        assert_eq!(read.capabilities.len(), 1);
843    }
844
845    #[test]
846    fn missing_target_ownership_defaults_to_generated() {
847        let tmp = TempDir::new().unwrap();
848        let path = tmp.path().join("tuff.lock");
849        fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
850        let read = read_lockfile_at(&path).unwrap();
851        assert!(read.capabilities.is_empty());
852    }
853
854    #[test]
855    fn hash_bytes_produces_consistent_output() {
856        let h1 = hash_bytes(b"hello");
857        let h2 = hash_bytes(b"hello");
858        assert_eq!(h1, h2);
859        assert_eq!(h1.len(), 64);
860        assert_ne!(h1, hash_bytes(b"world"));
861    }
862
863    #[test]
864    fn a_version_1_lockfile_migrates_every_source_kind() {
865        let tmp = TempDir::new().unwrap();
866        let path = tmp.path().join("tuff.lock");
867        fs::write(
868            &path,
869            r#"version = 1
870
871[[capabilities]]
872name = "git-skill"
873type = "skill"
874source = "git"
875repository = "https://example.com/skills.git"
876source_path = "skills/git-skill"
877resolved_ref = "9b9c499"
878sha256 = "aa"
879target = "open-agents"
880installed_path = ".agents/skills/git-skill"
881version = "9b9c499"
882
883[[capabilities]]
884name = "memory"
885type = "mcp-server"
886source = "catalog"
887repository = "builtin"
888source_path = "memory"
889resolved_ref = "1.0.0"
890sha256 = "bb"
891target = "open-agents"
892installed_path = ".agents/mcp-servers/memory"
893version = "1.0.0"
894
895[[capabilities]]
896name = "pack-skill"
897type = "skill"
898source = "local"
899source_path = ""
900resolved_ref = ""
901sha256 = "cc"
902target = "open-agents"
903installed_path = ".agents/skills/pack-skill"
904version = "1.5.0"
905
906[capabilities.pack]
907name = "com.acme/fixture"
908version = "1.0.0"
909digest = "dd"
910registry = "ghcr.io/acme/fixture"
911
912[[capabilities]]
913name = "local-skill"
914type = "skill"
915source = "local"
916source_path = "sources/local-skill"
917resolved_ref = ""
918sha256 = "ee"
919target = "open-agents"
920installed_path = ".agents/skills/local-skill"
921version = "1.0.0"
922"#,
923        )
924        .unwrap();
925
926        let lf = read_lockfile_at(&path).unwrap();
927        assert_eq!(lf.version, 1, "the version read is reported, not rewritten");
928        assert_eq!(
929            lf.capabilities["git-skill"].source,
930            CapabilitySource::Git(GitSource {
931                url: "https://example.com/skills.git".into(),
932                path: "skills/git-skill".into(),
933                git_ref: "9b9c499".into(),
934                tag: None,
935                requested: None,
936            })
937        );
938        assert_eq!(
939            lf.capabilities["git-skill"].version_scheme,
940            VersionScheme::Sha
941        );
942        assert_eq!(
943            lf.capabilities["memory"].source,
944            CapabilitySource::Catalog(CatalogSource {
945                id: "memory".into(),
946                version: "1.0.0".into(),
947            })
948        );
949        assert_eq!(
950            lf.capabilities["pack-skill"].source,
951            CapabilitySource::Pack(PackProvenance {
952                name: "com.acme/fixture".into(),
953                version: "1.0.0".into(),
954                digest: "dd".into(),
955                registry: Some("ghcr.io/acme/fixture".into()),
956                path: "pack-skill".into(),
957            })
958        );
959        assert_eq!(
960            lf.capabilities["local-skill"].source,
961            CapabilitySource::local("sources/local-skill")
962        );
963        assert_eq!(
964            lf.capabilities["local-skill"].version_scheme,
965            VersionScheme::Declared
966        );
967
968        // Writing produces v2, and v2 round-trips byte for byte.
969        write_lockfile_at(&path, &lf).unwrap();
970        let written = fs::read_to_string(&path).unwrap();
971        assert!(written.contains("version = 2\n"));
972        assert!(written.contains("kind = \"pack\""));
973        assert!(!written.contains("resolved_ref"));
974        let again = read_lockfile_at(&path).unwrap();
975        assert_eq!(again.version, 2);
976        write_lockfile_at(&path, &again).unwrap();
977        assert_eq!(fs::read_to_string(&path).unwrap(), written);
978    }
979
980    #[test]
981    fn a_lockfile_without_a_version_is_corrupt_not_empty() {
982        let tmp = TempDir::new().unwrap();
983        let path = tmp.path().join("tuff.lock");
984        fs::write(&path, "capabilities = []\n").unwrap();
985        let error = read_lockfile_at(&path).unwrap_err().to_string();
986        assert!(error.contains("no version field"), "{error}");
987
988        fs::write(&path, "version = 2\n[[capabilities]\n").unwrap();
989        let error = read_lockfile_at(&path).unwrap_err().to_string();
990        assert!(error.contains("not a valid lockfile"), "{error}");
991    }
992
993    #[test]
994    fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
995        let tmp = TempDir::new().unwrap();
996        let config_path = tmp.path().join("mcp.json");
997        let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
998        let both = |neighbour: &str| {
999            serde_json::to_string_pretty(&serde_json::json!({
1000                "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
1001            }))
1002            .unwrap()
1003        };
1004        fs::write(&config_path, both("hand")).unwrap();
1005        let managed = ManagedMcpEntry {
1006            config_path: "mcp.json".into(),
1007            baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
1008        };
1009
1010        // Pretty-printing and neighbouring hand-written entries never matter,
1011        // and editing the neighbour leaves ours clean.
1012        assert_eq!(
1013            managed_mcp_entry_status(tmp.path(), "github", &managed),
1014            "clean"
1015        );
1016        fs::write(&config_path, both("edited")).unwrap();
1017        assert_eq!(
1018            managed_mcp_entry_status(tmp.path(), "github", &managed),
1019            "clean"
1020        );
1021
1022        // Editing our entry is modified; removing it, or the file, is missing.
1023        fs::write(
1024            &config_path,
1025            r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
1026        )
1027        .unwrap();
1028        assert_eq!(
1029            managed_mcp_entry_status(tmp.path(), "github", &managed),
1030            "modified"
1031        );
1032        fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
1033        assert_eq!(
1034            managed_mcp_entry_status(tmp.path(), "github", &managed),
1035            "missing"
1036        );
1037        fs::remove_file(&config_path).unwrap();
1038        assert_eq!(
1039            managed_mcp_entry_status(tmp.path(), "github", &managed),
1040            "missing"
1041        );
1042    }
1043}