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
13pub const LOCKFILE_VERSION: u8 = 3;
23pub const OLDEST_READABLE_LOCKFILE_VERSION: u8 = 1;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum WireFormat {
31 Toml,
32 Json,
33}
34
35impl WireFormat {
36 fn detect(raw: &str) -> Self {
37 if raw.trim_start().starts_with('{') {
38 Self::Json
39 } else {
40 Self::Toml
41 }
42 }
43
44 fn for_version(version: u8) -> Self {
46 if version >= 3 { Self::Json } else { Self::Toml }
47 }
48}
49
50impl std::fmt::Display for WireFormat {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(match self {
53 Self::Toml => "TOML",
54 Self::Json => "JSON",
55 })
56 }
57}
58
59#[derive(Debug, Serialize, Deserialize)]
60pub struct Lockfile {
61 pub version: u8,
64 pub capabilities: BTreeMap<String, CapabilityLockEntry>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CapabilityLockEntry {
69 #[serde(rename = "type")]
70 pub capability_type: CapabilityType,
71 pub version: String,
75 #[serde(default)]
76 pub version_scheme: VersionScheme,
77 #[serde(default, skip_serializing_if = "String::is_empty")]
78 pub description: String,
79 pub source: CapabilitySource,
82 pub targets: BTreeMap<String, TargetLockEntry>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub implementation: Option<ImplementationConfig>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub parameters: Option<serde_json::Value>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub workflow: Option<WorkflowConfig>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub server: Option<McpServerConfig>,
99}
100
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum VersionScheme {
105 Semver,
108 #[default]
110 Declared,
111 Sha,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(tag = "kind", rename_all = "lowercase")]
119pub enum CapabilitySource {
120 Local(LocalSource),
121 Git(GitSource),
122 Catalog(CatalogSource),
123 Pack(PackProvenance),
124}
125
126impl CapabilitySource {
127 pub fn local(path: impl Into<String>) -> Self {
128 Self::Local(LocalSource { path: path.into() })
129 }
130
131 pub fn kind(&self) -> &'static str {
133 match self {
134 Self::Local(_) => "local",
135 Self::Git(_) => "git",
136 Self::Catalog(_) => "catalog",
137 Self::Pack(_) => "pack",
138 }
139 }
140
141 pub fn as_git(&self) -> Option<&GitSource> {
142 match self {
143 Self::Git(git) => Some(git),
144 _ => None,
145 }
146 }
147
148 pub fn as_pack(&self) -> Option<&PackProvenance> {
149 match self {
150 Self::Pack(pack) => Some(pack),
151 _ => None,
152 }
153 }
154
155 pub fn local_path(&self) -> Option<&str> {
157 match self {
158 Self::Local(local) => Some(local.path.as_str()),
159 _ => None,
160 }
161 }
162
163 pub fn version_scheme_for(&self, version: &str) -> VersionScheme {
169 match self {
170 Self::Git(git) if git.tag.is_some() => VersionScheme::Semver,
171 Self::Git(git) if git.git_ref == version => VersionScheme::Sha,
172 _ => VersionScheme::Declared,
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct LocalSource {
179 #[serde(default)]
183 pub path: String,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct GitSource {
188 pub url: String,
189 #[serde(default)]
191 pub path: String,
192 #[serde(rename = "ref")]
194 pub git_ref: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub tag: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub requested: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct CatalogSource {
205 pub id: String,
208 pub version: String,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub registry: Option<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct PackProvenance {
223 pub name: String,
224 pub version: String,
225 pub digest: String,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub registry: Option<String>,
237 #[serde(default)]
239 pub path: String,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct TargetLockEntry {
244 #[serde(
245 default,
246 rename = "managedHooks",
247 skip_serializing_if = "Vec::is_empty"
248 )]
249 pub managed_hooks: Vec<ManagedHook>,
250 #[serde(
251 default,
252 rename = "managedMcpEntry",
253 skip_serializing_if = "Option::is_none"
254 )]
255 pub managed_mcp_entry: Option<ManagedMcpEntry>,
256 #[serde(default)]
257 pub ownership: TargetOwnership,
258 #[serde(default)]
259 pub sha256: String,
260 #[serde(default)]
261 pub installed_path: String,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ManagedHook {
266 #[serde(rename = "settingsPath")]
267 pub settings_path: String,
268 pub event: String,
269 #[serde(
270 default,
271 rename = "canonicalEvent",
272 skip_serializing_if = "Option::is_none"
273 )]
274 pub canonical_event: Option<String>,
275 pub command: String,
276 #[serde(rename = "baselineHash")]
277 pub baseline_hash: String,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ManagedMcpEntry {
289 #[serde(rename = "configPath")]
290 pub config_path: String,
291 #[serde(rename = "baselineHash")]
292 pub baseline_hash: String,
293}
294
295pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
299 Ok(hash_bytes(&serde_json::to_vec(entry)?))
300}
301
302pub fn managed_mcp_entry_status(
304 repo_root: &Path,
305 capability_id: &str,
306 entry: &ManagedMcpEntry,
307) -> &'static str {
308 let path = repo_root.join(&entry.config_path);
309 let Ok(raw) = std::fs::read_to_string(path) else {
310 return "missing";
311 };
312 let Ok(config): std::result::Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
313 return "modified";
314 };
315 let Some(current) = config
316 .get("mcpServers")
317 .and_then(|servers| servers.get(capability_id))
318 else {
319 return "missing";
320 };
321 match serde_json::to_vec(current) {
322 Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
323 _ => "modified",
324 }
325}
326
327pub fn managed_hooks_from_fragment(
328 repo_root: &Path,
329 settings_path: &str,
330 fragment: &serde_json::Value,
331) -> Result<Vec<ManagedHook>> {
332 managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
333}
334
335pub fn managed_hooks_from_fragment_with_canonical(
336 _repo_root: &Path,
337 settings_path: &str,
338 fragment: &serde_json::Value,
339 canonical_event: Option<&str>,
340) -> Result<Vec<ManagedHook>> {
341 let mut managed = Vec::new();
342 let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
343 return Ok(managed);
344 };
345
346 for (event, groups) in events {
347 let Some(groups) = groups.as_array() else {
348 continue;
349 };
350 for group in groups {
351 let hooks = group
352 .get("hooks")
353 .and_then(serde_json::Value::as_array)
354 .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
355 for hook in hooks {
356 let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
357 continue;
358 };
359 let baseline = serde_json::to_vec(hook)?;
360 managed.push(ManagedHook {
361 settings_path: settings_path.to_string(),
362 event: event.clone(),
363 canonical_event: canonical_event.map(str::to_owned),
364 command: command.to_string(),
365 baseline_hash: hash_bytes(&baseline),
366 });
367 }
368 }
369 }
370 Ok(managed)
371}
372
373pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
374 let path = repo_root.join(&hook.settings_path);
375 let Ok(settings) = std::fs::read_to_string(path) else {
376 return "missing";
377 };
378 let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
379 else {
380 return "modified";
381 };
382 let Some(groups) = settings
383 .get("hooks")
384 .and_then(|hooks| hooks.get(&hook.event))
385 .and_then(serde_json::Value::as_array)
386 else {
387 return "missing";
388 };
389
390 for group in groups {
391 let entries = group
392 .get("hooks")
393 .and_then(serde_json::Value::as_array)
394 .map_or_else(|| vec![group], |entries| entries.iter().collect());
395 for entry in entries {
396 if entry.get("command").and_then(serde_json::Value::as_str)
397 == Some(hook.command.as_str())
398 {
399 let Ok(content) = serde_json::to_vec(entry) else {
400 return "modified";
401 };
402 return if hash_bytes(&content) == hook.baseline_hash {
403 "clean"
404 } else {
405 "modified"
406 };
407 }
408 }
409 }
410 "missing"
411}
412
413#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
414#[serde(rename_all = "lowercase")]
415pub enum TargetOwnership {
416 #[default]
417 Generated,
418 Imported,
419}
420
421pub fn project_lockfile(repo_root: &Path) -> PathBuf {
424 repo_root.join("tuff.lock")
425}
426
427pub fn scoped_lockfile(scope_root: &Path, scope: crate::resolver::Scope) -> PathBuf {
431 match scope {
432 crate::resolver::Scope::Project => project_lockfile(scope_root),
433 crate::resolver::Scope::Global => crate::paths::global_lockfile(scope_root),
434 }
435}
436
437pub fn require_scoped_lockfile(
438 scope_root: &Path,
439 scope: crate::resolver::Scope,
440) -> Result<Lockfile> {
441 read_lockfile_at(&scoped_lockfile(scope_root, scope))
442}
443
444pub fn write_scoped_lockfile(
445 scope_root: &Path,
446 scope: crate::resolver::Scope,
447 lockfile: &Lockfile,
448) -> Result<()> {
449 write_lockfile_at(&scoped_lockfile(scope_root, scope), lockfile)
450}
451
452pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
453 let lock_path = project_lockfile(repo_root);
454 init_lockfile_at(&lock_path)?;
455 Ok(lock_path)
456}
457
458pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
459 if !lock_path.exists() {
460 write_lockfile_at(
461 lock_path,
462 &Lockfile {
463 version: LOCKFILE_VERSION,
464 capabilities: BTreeMap::new(),
465 },
466 )?;
467 }
468 Ok(())
469}
470
471pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
472 read_lockfile_at(&project_lockfile(repo_root))
473}
474
475pub fn read_optional_lockfile(path: &Path) -> Result<Option<Lockfile>> {
482 match read_lockfile_at(path) {
483 Ok(lockfile) => Ok(Some(lockfile)),
484 Err(error) if error.kind() == crate::error::ErrorKind::NotFound => Ok(None),
485 Err(error) => Err(error),
486 }
487}
488
489pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
495 if !path.exists() {
496 let parent = path.parent().unwrap_or(Path::new("."));
497 return Err(TuffError::not_found(format!(
498 "{} is missing",
499 parent
500 .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
501 .display()
502 ))
503 .with_hint("run 'tuff init' first"));
504 }
505 let raw = std::fs::read_to_string(path)?;
506 let format = WireFormat::detect(&raw);
507 let version = peek_version(&raw, format, path)?;
508 if version > LOCKFILE_VERSION {
509 return Err(TuffError::unsupported(format!(
510 "unsupported lockfile version: {version} ({} was written by a newer tuff; this tuff {} reads versions {OLDEST_READABLE_LOCKFILE_VERSION} to {LOCKFILE_VERSION}, upgrade tuff)",
511 path.display(),
512 env!("CARGO_PKG_VERSION")
513 )));
514 }
515 let expected = WireFormat::for_version(version);
516 if format != expected {
517 return Err(TuffError::corrupt(format!(
518 "{} declares lockfile version {version}, which is {expected}, but the file is {format}",
519 path.display()
520 )));
521 }
522 let rows: Vec<Row> = match version {
523 1 => read_v1_rows(&raw)?,
524 2 => read_v2_rows(&raw)?,
525 _ => read_v3_rows(&raw)?,
526 };
527 let mut capabilities: BTreeMap<String, CapabilityLockEntry> = BTreeMap::new();
528 for row in rows {
529 let Row {
530 name,
531 target,
532 target_entry,
533 entry,
534 } = row;
535 match capabilities.entry(name) {
536 std::collections::btree_map::Entry::Occupied(mut existing) => {
537 existing.get_mut().targets.insert(target, target_entry);
538 }
539 std::collections::btree_map::Entry::Vacant(slot) => {
540 let mut entry = entry;
541 entry.targets.insert(target, target_entry);
542 slot.insert(entry);
543 }
544 }
545 }
546 Ok(Lockfile {
547 version,
548 capabilities,
549 })
550}
551
552struct Row {
554 name: String,
555 target: String,
556 target_entry: TargetLockEntry,
557 entry: CapabilityLockEntry,
558}
559
560fn peek_version(raw: &str, format: WireFormat, path: &Path) -> Result<u8> {
561 #[derive(Deserialize)]
562 struct VersionOnly {
563 version: Option<u8>,
564 }
565 let invalid = |message: String| {
566 TuffError::corrupt(format!(
567 "{} is not a valid lockfile: {message}",
568 path.display()
569 ))
570 };
571 let peek: VersionOnly = match format {
572 WireFormat::Toml => {
573 toml::from_str(raw).map_err(|error| invalid(error.message().to_string()))?
574 }
575 WireFormat::Json => {
576 serde_json::from_str(raw).map_err(|error| invalid(error.to_string()))?
577 }
578 };
579 match peek.version {
580 Some(version) if version >= OLDEST_READABLE_LOCKFILE_VERSION => Ok(version),
581 Some(version) => Err(TuffError::unsupported(format!(
582 "unsupported lockfile version: {version} ({} predates every schema this tuff reads)",
583 path.display()
584 ))),
585 None => Err(TuffError::corrupt(format!(
586 "{} has no version field; it is not a Tuff lockfile or it is corrupt",
587 path.display()
588 ))),
589 }
590}
591
592fn read_v1_rows(raw: &str) -> Result<Vec<Row>> {
594 let wire: WireLockfileV1 = toml::from_str(raw)
595 .map_err(|error| TuffError::corrupt(format!("invalid version 1 lockfile: {error}")))?;
596 Ok(wire
597 .capabilities
598 .into_iter()
599 .map(|item| {
600 let source = match item.pack {
601 Some(pack) => CapabilitySource::Pack(PackProvenance {
606 name: pack.name,
607 version: pack.version,
608 digest: pack.digest,
609 registry: pack.registry,
610 path: item.name.clone(),
611 }),
612 None => match item.source.as_str() {
613 "git" => CapabilitySource::Git(GitSource {
614 url: item.repository,
615 path: item.source_path,
616 git_ref: item.resolved_ref,
617 tag: None,
618 requested: None,
619 }),
620 "catalog" => CapabilitySource::Catalog(CatalogSource {
623 id: item.source_path,
624 version: item.resolved_ref,
625 registry: None,
626 }),
627 _ if item.source_path == "<generated>" => CapabilitySource::local(""),
630 _ => CapabilitySource::local(item.source_path),
631 },
632 };
633 let version_scheme = source.version_scheme_for(&item.version);
634 Row {
635 name: item.name,
636 target: item.target,
637 target_entry: TargetLockEntry {
638 managed_hooks: item.managed_hooks,
639 managed_mcp_entry: item.managed_mcp_entry,
640 ownership: item.ownership,
641 sha256: item.sha256,
642 installed_path: item.installed_path,
643 },
644 entry: CapabilityLockEntry {
645 capability_type: item.capability_type,
646 version: item.version,
647 version_scheme,
648 description: item.description,
649 source,
650 targets: BTreeMap::new(),
651 implementation: item.implementation,
652 parameters: item.parameters,
653 workflow: item.workflow,
654 server: item.server,
655 },
656 }
657 })
658 .collect())
659}
660
661fn read_v2_rows(raw: &str) -> Result<Vec<Row>> {
664 let wire: WireLockfile = toml::from_str(raw)
665 .map_err(|error| TuffError::corrupt(format!("invalid lockfile: {error}")))?;
666 Ok(rows_from_wire(wire))
667}
668
669fn read_v3_rows(raw: &str) -> Result<Vec<Row>> {
671 let wire: WireLockfile = serde_json::from_str(raw)
672 .map_err(|error| TuffError::corrupt(format!("invalid lockfile: {error}")))?;
673 Ok(rows_from_wire(wire))
674}
675
676fn rows_from_wire(wire: WireLockfile) -> Vec<Row> {
677 wire.capabilities
678 .into_iter()
679 .map(|item| Row {
680 name: item.name,
681 target: item.target,
682 target_entry: TargetLockEntry {
683 managed_hooks: item.managed_hooks,
684 managed_mcp_entry: item.managed_mcp_entry,
685 ownership: item.ownership,
686 sha256: item.sha256,
687 installed_path: item.installed_path,
688 },
689 entry: CapabilityLockEntry {
690 capability_type: item.capability_type,
691 version: item.version,
692 version_scheme: item.version_scheme,
693 description: item.description,
694 source: item.source,
695 targets: BTreeMap::new(),
696 implementation: item.implementation,
697 parameters: item.parameters,
698 workflow: item.workflow,
699 server: item.server,
700 },
701 })
702 .collect()
703}
704
705pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
706 write_lockfile_at(&project_lockfile(repo_root), lockfile)
707}
708
709pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
710 if let Some(parent) = path.parent() {
711 std::fs::create_dir_all(parent)?;
712 }
713 let mut capabilities = Vec::new();
714 for (name, entry) in &lockfile.capabilities {
715 for (target, target_entry) in &entry.targets {
716 capabilities.push(WireCapability {
717 name: name.clone(),
718 capability_type: entry.capability_type,
719 version: entry.version.clone(),
720 version_scheme: entry.version_scheme,
721 description: entry.description.clone(),
722 target: target.clone(),
723 installed_path: target_entry.installed_path.clone(),
724 sha256: target_entry.sha256.clone(),
725 ownership: target_entry.ownership,
726 source: entry.source.clone(),
727 managed_hooks: target_entry.managed_hooks.clone(),
728 managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
729 implementation: entry.implementation.clone(),
730 parameters: entry.parameters.clone(),
731 workflow: entry.workflow.clone(),
732 server: entry.server.clone(),
733 });
734 }
735 }
736 capabilities.sort_by(|a, b| {
737 a.name
738 .cmp(&b.name)
739 .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
740 .then_with(|| a.target.cmp(&b.target))
741 .then_with(|| a.installed_path.cmp(&b.installed_path))
742 });
743 let wire = WireLockfile {
744 version: LOCKFILE_VERSION,
745 capabilities,
746 };
747 let mut content = serde_json::to_string_pretty(&wire)?;
751 content.push('\n');
752 std::fs::write(path, content)?;
753 Ok(())
754}
755
756#[derive(Debug, Serialize, Deserialize)]
762struct WireLockfile {
763 version: u8,
764 capabilities: Vec<WireCapability>,
765}
766
767#[derive(Debug, Serialize, Deserialize)]
768struct WireCapability {
769 name: String,
770 #[serde(rename = "type")]
771 capability_type: CapabilityType,
772 #[serde(default)]
773 version: String,
774 #[serde(default)]
775 version_scheme: VersionScheme,
776 #[serde(default, skip_serializing_if = "String::is_empty")]
777 description: String,
778 target: String,
779 installed_path: String,
780 sha256: String,
781 #[serde(default)]
782 ownership: TargetOwnership,
783 source: CapabilitySource,
784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
785 managed_hooks: Vec<ManagedHook>,
786 #[serde(default, skip_serializing_if = "Option::is_none")]
787 managed_mcp_entry: Option<ManagedMcpEntry>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
789 implementation: Option<ImplementationConfig>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 parameters: Option<serde_json::Value>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
793 workflow: Option<WorkflowConfig>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 server: Option<McpServerConfig>,
796}
797
798#[derive(Debug, Deserialize)]
800struct WireLockfileV1 {
801 #[allow(dead_code)]
802 version: u8,
803 capabilities: Vec<WireCapabilityV1>,
804}
805
806#[derive(Debug, Deserialize)]
807struct WireCapabilityV1 {
808 name: String,
809 #[serde(rename = "type")]
810 capability_type: CapabilityType,
811 source: String,
812 #[serde(default)]
813 repository: String,
814 #[serde(default)]
815 source_path: String,
816 #[serde(default)]
817 resolved_ref: String,
818 sha256: String,
819 target: String,
820 installed_path: String,
821 #[serde(default)]
822 version: String,
823 #[serde(default)]
824 description: String,
825 #[serde(default)]
826 ownership: TargetOwnership,
827 #[serde(default)]
828 managed_hooks: Vec<ManagedHook>,
829 #[serde(default)]
830 managed_mcp_entry: Option<ManagedMcpEntry>,
831 #[serde(default)]
832 pack: Option<PackProvenanceV1>,
833 #[serde(default)]
834 implementation: Option<ImplementationConfig>,
835 #[serde(default)]
836 parameters: Option<serde_json::Value>,
837 #[serde(default)]
838 workflow: Option<WorkflowConfig>,
839 #[serde(default)]
840 server: Option<McpServerConfig>,
841}
842
843#[derive(Debug, Deserialize)]
844struct PackProvenanceV1 {
845 name: String,
846 version: String,
847 digest: String,
848 #[serde(default)]
849 registry: Option<String>,
850}
851
852pub fn hash_bytes(content: &[u8]) -> String {
853 let mut hasher = Sha256::new();
854 hasher.update(content);
855 format!("{:x}", hasher.finalize())
856}
857
858pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
859 path.strip_prefix(repo_root)
860 .map(|relative| relative.to_string_lossy().replace('\\', "/"))
861 .unwrap_or_else(|_| path.to_string_lossy().to_string())
862}
863
864pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
865 if path.is_absolute() {
866 path.to_path_buf()
867 } else {
868 repo_root.join(path)
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875 use std::fs;
876 use tempfile::TempDir;
877
878 #[test]
879 fn init_lockfile_at_creates_new_file() {
880 let tmp = TempDir::new().unwrap();
881 let path = tmp.path().join("tuff.lock");
882 init_lockfile_at(&path).unwrap();
883 assert!(path.exists());
884
885 let lf = read_lockfile_at(&path).unwrap();
886 assert_eq!(lf.version, LOCKFILE_VERSION);
887 assert!(lf.capabilities.is_empty());
888 }
889
890 #[test]
891 fn read_lockfile_at_rejects_missing() {
892 let tmp = TempDir::new().unwrap();
893 let path = tmp.path().join("tuff.lock");
894 assert!(read_lockfile_at(&path).is_err());
895 }
896
897 #[test]
898 fn read_lockfile_at_rejects_a_newer_schema_in_either_encoding() {
899 let tmp = TempDir::new().unwrap();
900 let path = tmp.path().join("tuff.lock");
901 for raw in [
902 "{\n \"version\": 4,\n \"capabilities\": []\n}\n",
903 "version = 4\ncapabilities = []\n",
904 ] {
905 fs::write(&path, raw).unwrap();
906 let error = read_lockfile_at(&path).unwrap_err().to_string();
907 assert!(error.contains("unsupported lockfile version: 4"), "{error}");
908 }
909 }
910
911 #[test]
912 fn a_lockfile_in_the_wrong_encoding_for_its_version_is_corrupt() {
913 let tmp = TempDir::new().unwrap();
917 let path = tmp.path().join("tuff.lock");
918 fs::write(&path, "version = 3\ncapabilities = []\n").unwrap();
919 let error = read_lockfile_at(&path).unwrap_err().to_string();
920 assert!(
921 error.contains("declares lockfile version 3, which is JSON, but the file is TOML"),
922 "{error}"
923 );
924 fs::write(&path, "{\"version\": 2, \"capabilities\": []}\n").unwrap();
925 let error = read_lockfile_at(&path).unwrap_err().to_string();
926 assert!(
927 error.contains("declares lockfile version 2, which is TOML, but the file is JSON"),
928 "{error}"
929 );
930 }
931
932 #[test]
933 fn an_empty_lockfile_is_canonical_json() {
934 let tmp = TempDir::new().unwrap();
935 let path = tmp.path().join("tuff.lock");
936 init_lockfile_at(&path).unwrap();
937 assert_eq!(
938 fs::read_to_string(&path).unwrap(),
939 "{\n \"version\": 3,\n \"capabilities\": []\n}\n"
940 );
941 }
942
943 #[test]
944 fn write_and_read_roundtrip() {
945 let tmp = TempDir::new().unwrap();
946 let path = tmp.path().join("tuff.lock");
947 let mut lf = Lockfile {
948 version: LOCKFILE_VERSION,
949 capabilities: BTreeMap::new(),
950 };
951 lf.capabilities.insert(
952 "test".into(),
953 CapabilityLockEntry {
954 capability_type: CapabilityType::Skill,
955 version: "1.0".into(),
956 version_scheme: VersionScheme::Declared,
957 description: "test skill".into(),
958 source: CapabilitySource::local(""),
959 targets: BTreeMap::from([(
960 "open-agents".into(),
961 TargetLockEntry {
962 managed_hooks: Vec::new(),
963 managed_mcp_entry: None,
964 ownership: TargetOwnership::Generated,
965 sha256: hash_bytes(b"content"),
966 installed_path: ".agents/skills/test".into(),
967 },
968 )]),
969 implementation: None,
970 parameters: None,
971 workflow: None,
972 server: None,
973 },
974 );
975 write_lockfile_at(&path, &lf).unwrap();
976 let read = read_lockfile_at(&path).unwrap();
977 assert_eq!(read.capabilities.len(), 1);
978 assert_eq!(read.version, LOCKFILE_VERSION);
979
980 let written = fs::read_to_string(&path).unwrap();
983 assert!(
984 written.starts_with(
985 "{\n \"version\": 3,\n \"capabilities\": [\n {\n \"name\": \"test\",\n"
986 ),
987 "{written}"
988 );
989 assert!(written.ends_with("\n ]\n}\n"), "{written}");
990 for line in written.lines() {
991 let indent = line.len() - line.trim_start_matches(' ').len();
992 assert_eq!(indent % 2, 0, "odd indentation: {line:?}");
993 assert!(!line.contains('\t'), "tab in {line:?}");
994 assert_eq!(line, line.trim_end(), "trailing whitespace in {line:?}");
995 }
996 serde_json::from_str::<serde_json::Value>(&written).unwrap();
997 }
998
999 #[test]
1000 fn missing_target_ownership_defaults_to_generated() {
1001 let tmp = TempDir::new().unwrap();
1002 let path = tmp.path().join("tuff.lock");
1003 fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
1004 let read = read_lockfile_at(&path).unwrap();
1005 assert!(read.capabilities.is_empty());
1006 }
1007
1008 #[test]
1009 fn hash_bytes_produces_consistent_output() {
1010 let h1 = hash_bytes(b"hello");
1011 let h2 = hash_bytes(b"hello");
1012 assert_eq!(h1, h2);
1013 assert_eq!(h1.len(), 64);
1014 assert_ne!(h1, hash_bytes(b"world"));
1015 }
1016
1017 #[test]
1018 fn a_version_1_lockfile_migrates_every_source_kind() {
1019 let tmp = TempDir::new().unwrap();
1020 let path = tmp.path().join("tuff.lock");
1021 fs::write(
1022 &path,
1023 r#"version = 1
1024
1025[[capabilities]]
1026name = "git-skill"
1027type = "skill"
1028source = "git"
1029repository = "https://example.com/skills.git"
1030source_path = "skills/git-skill"
1031resolved_ref = "9b9c499"
1032sha256 = "aa"
1033target = "open-agents"
1034installed_path = ".agents/skills/git-skill"
1035version = "9b9c499"
1036
1037[[capabilities]]
1038name = "memory"
1039type = "mcp-server"
1040source = "catalog"
1041repository = "builtin"
1042source_path = "memory"
1043resolved_ref = "1.0.0"
1044sha256 = "bb"
1045target = "open-agents"
1046installed_path = ".agents/mcp-servers/memory"
1047version = "1.0.0"
1048
1049[[capabilities]]
1050name = "pack-skill"
1051type = "skill"
1052source = "local"
1053source_path = ""
1054resolved_ref = ""
1055sha256 = "cc"
1056target = "open-agents"
1057installed_path = ".agents/skills/pack-skill"
1058version = "1.5.0"
1059
1060[capabilities.pack]
1061name = "com.acme/fixture"
1062version = "1.0.0"
1063digest = "dd"
1064registry = "ghcr.io/acme/fixture"
1065
1066[[capabilities]]
1067name = "local-skill"
1068type = "skill"
1069source = "local"
1070source_path = "sources/local-skill"
1071resolved_ref = ""
1072sha256 = "ee"
1073target = "open-agents"
1074installed_path = ".agents/skills/local-skill"
1075version = "1.0.0"
1076"#,
1077 )
1078 .unwrap();
1079
1080 let lf = read_lockfile_at(&path).unwrap();
1081 assert_eq!(lf.version, 1, "the version read is reported, not rewritten");
1082 assert_eq!(
1083 lf.capabilities["git-skill"].source,
1084 CapabilitySource::Git(GitSource {
1085 url: "https://example.com/skills.git".into(),
1086 path: "skills/git-skill".into(),
1087 git_ref: "9b9c499".into(),
1088 tag: None,
1089 requested: None,
1090 })
1091 );
1092 assert_eq!(
1093 lf.capabilities["git-skill"].version_scheme,
1094 VersionScheme::Sha
1095 );
1096 assert_eq!(
1097 lf.capabilities["memory"].source,
1098 CapabilitySource::Catalog(CatalogSource {
1099 id: "memory".into(),
1100 version: "1.0.0".into(),
1101 registry: None,
1102 })
1103 );
1104 assert_eq!(
1105 lf.capabilities["pack-skill"].source,
1106 CapabilitySource::Pack(PackProvenance {
1107 name: "com.acme/fixture".into(),
1108 version: "1.0.0".into(),
1109 digest: "dd".into(),
1110 registry: Some("ghcr.io/acme/fixture".into()),
1111 path: "pack-skill".into(),
1112 })
1113 );
1114 assert_eq!(
1115 lf.capabilities["local-skill"].source,
1116 CapabilitySource::local("sources/local-skill")
1117 );
1118 assert_eq!(
1119 lf.capabilities["local-skill"].version_scheme,
1120 VersionScheme::Declared
1121 );
1122
1123 write_lockfile_at(&path, &lf).unwrap();
1125 let written = fs::read_to_string(&path).unwrap();
1126 assert!(written.starts_with("{\n \"version\": 3,\n"), "{written}");
1127 assert!(written.contains("\"kind\": \"pack\""), "{written}");
1128 assert!(!written.contains("resolved_ref"));
1129 let again = read_lockfile_at(&path).unwrap();
1130 assert_eq!(again.version, 3);
1131 write_lockfile_at(&path, &again).unwrap();
1132 assert_eq!(fs::read_to_string(&path).unwrap(), written);
1133 }
1134
1135 #[test]
1136 fn a_version_2_lockfile_is_read_as_is_and_written_as_version_3() {
1137 let tmp = TempDir::new().unwrap();
1138 let path = tmp.path().join("tuff.lock");
1139 fs::write(
1140 &path,
1141 r#"version = 2
1142
1143[[capabilities]]
1144name = "git-skill"
1145type = "skill"
1146version = "1.4.0"
1147version_scheme = "semver"
1148target = "open-agents"
1149installed_path = ".agents/skills/git-skill"
1150sha256 = "aa"
1151ownership = "generated"
1152
1153[capabilities.source]
1154kind = "git"
1155url = "https://example.com/skills.git"
1156path = "skills/git-skill"
1157ref = "9b9c499"
1158tag = "v1.4.0"
1159requested = "^1.2"
1160"#,
1161 )
1162 .unwrap();
1163 let lf = read_lockfile_at(&path).unwrap();
1164 assert_eq!(lf.version, 2, "the version read is reported, not rewritten");
1165 let entry = &lf.capabilities["git-skill"];
1166 assert_eq!(entry.version_scheme, VersionScheme::Semver);
1167 assert_eq!(
1168 entry.source,
1169 CapabilitySource::Git(GitSource {
1170 url: "https://example.com/skills.git".into(),
1171 path: "skills/git-skill".into(),
1172 git_ref: "9b9c499".into(),
1173 tag: Some("v1.4.0".into()),
1174 requested: Some("^1.2".into()),
1175 })
1176 );
1177
1178 write_lockfile_at(&path, &lf).unwrap();
1179 let written = fs::read_to_string(&path).unwrap();
1180 assert!(written.starts_with("{\n \"version\": 3,\n"), "{written}");
1181 assert!(written.contains("\"requested\": \"^1.2\""), "{written}");
1182 let again = read_lockfile_at(&path).unwrap();
1183 assert_eq!(again.version, 3);
1184 assert_eq!(again.capabilities["git-skill"].source, entry.source);
1185 }
1186
1187 #[test]
1188 fn a_lockfile_without_a_version_is_corrupt_not_empty() {
1189 let tmp = TempDir::new().unwrap();
1190 let path = tmp.path().join("tuff.lock");
1191 fs::write(&path, "capabilities = []\n").unwrap();
1192 let error = read_lockfile_at(&path).unwrap_err().to_string();
1193 assert!(error.contains("no version field"), "{error}");
1194
1195 fs::write(&path, "version = 2\n[[capabilities]\n").unwrap();
1196 let error = read_lockfile_at(&path).unwrap_err().to_string();
1197 assert!(error.contains("not a valid lockfile"), "{error}");
1198 }
1199
1200 #[test]
1201 fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
1202 let tmp = TempDir::new().unwrap();
1203 let config_path = tmp.path().join("mcp.json");
1204 let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
1205 let both = |neighbour: &str| {
1206 serde_json::to_string_pretty(&serde_json::json!({
1207 "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
1208 }))
1209 .unwrap()
1210 };
1211 fs::write(&config_path, both("hand")).unwrap();
1212 let managed = ManagedMcpEntry {
1213 config_path: "mcp.json".into(),
1214 baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
1215 };
1216
1217 assert_eq!(
1220 managed_mcp_entry_status(tmp.path(), "github", &managed),
1221 "clean"
1222 );
1223 fs::write(&config_path, both("edited")).unwrap();
1224 assert_eq!(
1225 managed_mcp_entry_status(tmp.path(), "github", &managed),
1226 "clean"
1227 );
1228
1229 fs::write(
1231 &config_path,
1232 r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
1233 )
1234 .unwrap();
1235 assert_eq!(
1236 managed_mcp_entry_status(tmp.path(), "github", &managed),
1237 "modified"
1238 );
1239 fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
1240 assert_eq!(
1241 managed_mcp_entry_status(tmp.path(), "github", &managed),
1242 "missing"
1243 );
1244 fs::remove_file(&config_path).unwrap();
1245 assert_eq!(
1246 managed_mcp_entry_status(tmp.path(), "github", &managed),
1247 "missing"
1248 );
1249 }
1250}