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