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 = 2;
16pub const OLDEST_READABLE_LOCKFILE_VERSION: u8 = 1;
18
19#[derive(Debug, Serialize, Deserialize)]
20pub struct Lockfile {
21 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 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 pub source: CapabilitySource,
42 pub targets: BTreeMap<String, TargetLockEntry>,
43 #[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 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum VersionScheme {
65 Semver,
67 #[default]
69 Declared,
70 Sha,
72}
73
74#[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 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 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 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 #[serde(default)]
139 pub path: String,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct GitSource {
144 pub url: String,
145 #[serde(default)]
147 pub path: String,
148 #[serde(rename = "ref")]
150 pub git_ref: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub tag: Option<String>,
154 #[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 pub id: String,
163 pub version: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct PackProvenance {
170 pub name: String,
171 pub version: String,
172 pub digest: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub registry: Option<String>,
184 #[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#[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
242pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
246 Ok(hash_bytes(&serde_json::to_vec(entry)?))
247}
248
249pub 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
368pub fn project_lockfile(repo_root: &Path) -> PathBuf {
371 repo_root.join("tuff.lock")
372}
373
374pub 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
422pub 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
475struct 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
508fn 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 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 _ 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#[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#[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 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 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 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}