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