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::adapter::EmittedFile;
11use crate::error::{Result, TuffError};
12use crate::manifest::{CapabilityType, ImplementationConfig, McpServerConfig, WorkflowConfig};
13
14pub const LOCKFILE_VERSION: u8 = 1;
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct Lockfile {
18 pub version: u8,
19 #[serde(rename = "capabilities")]
20 pub capabilities: BTreeMap<String, CapabilityLockEntry>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CapabilityLockEntry {
25 #[serde(rename = "type")]
26 pub capability_type: CapabilityType,
27 #[serde(rename = "installedVersion")]
28 pub installed_version: String,
29 #[serde(default, skip_serializing_if = "String::is_empty")]
30 pub description: String,
31 #[serde(rename = "sourcePath")]
32 pub source_path: String,
33 pub targets: BTreeMap<String, TargetLockEntry>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub source: Option<SourceMetadata>,
36 #[serde(default = "default_scope")]
37 pub scope: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub pack: Option<PackProvenance>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub implementation: Option<ImplementationConfig>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub parameters: Option<serde_json::Value>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub workflow: Option<WorkflowConfig>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub server: Option<McpServerConfig>,
56}
57
58fn default_scope() -> String {
59 "project".to_string()
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SourceMetadata {
64 #[serde(rename = "type")]
65 pub source_type: String,
66 pub url: String,
67 #[serde(rename = "ref")]
68 pub source_ref: String,
69 pub skill: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PackProvenance {
75 pub name: String,
76 pub version: String,
77 pub digest: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub registry: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct TargetLockEntry {
91 #[serde(rename = "emittedFiles")]
92 pub emitted_files: Vec<EmittedFile>,
93 #[serde(
94 default,
95 rename = "managedHooks",
96 skip_serializing_if = "Vec::is_empty"
97 )]
98 pub managed_hooks: Vec<ManagedHook>,
99 #[serde(
100 default,
101 rename = "managedMcpEntry",
102 skip_serializing_if = "Option::is_none"
103 )]
104 pub managed_mcp_entry: Option<ManagedMcpEntry>,
105 #[serde(default)]
106 pub ownership: TargetOwnership,
107 #[serde(default)]
108 pub sha256: String,
109 #[serde(default)]
110 pub installed_path: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ManagedHook {
115 #[serde(rename = "settingsPath")]
116 pub settings_path: String,
117 pub event: String,
118 #[serde(
119 default,
120 rename = "canonicalEvent",
121 skip_serializing_if = "Option::is_none"
122 )]
123 pub canonical_event: Option<String>,
124 pub command: String,
125 #[serde(rename = "baselineHash")]
126 pub baseline_hash: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ManagedMcpEntry {
138 #[serde(rename = "configPath")]
139 pub config_path: String,
140 #[serde(rename = "baselineHash")]
141 pub baseline_hash: String,
142}
143
144pub fn managed_mcp_entry_baseline(entry: &serde_json::Value) -> Result<String> {
148 Ok(hash_bytes(&serde_json::to_vec(entry)?))
149}
150
151pub fn managed_mcp_entry_status(
153 repo_root: &Path,
154 capability_id: &str,
155 entry: &ManagedMcpEntry,
156) -> &'static str {
157 let path = repo_root.join(&entry.config_path);
158 let Ok(raw) = std::fs::read_to_string(path) else {
159 return "missing";
160 };
161 let Ok(config): std::result::Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
162 return "modified";
163 };
164 let Some(current) = config
165 .get("mcpServers")
166 .and_then(|servers| servers.get(capability_id))
167 else {
168 return "missing";
169 };
170 match serde_json::to_vec(current) {
171 Ok(bytes) if hash_bytes(&bytes) == entry.baseline_hash => "clean",
172 _ => "modified",
173 }
174}
175
176pub fn managed_hooks_from_fragment(
177 repo_root: &Path,
178 settings_path: &str,
179 fragment: &serde_json::Value,
180) -> Result<Vec<ManagedHook>> {
181 managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
182}
183
184pub fn managed_hooks_from_fragment_with_canonical(
185 repo_root: &Path,
186 settings_path: &str,
187 fragment: &serde_json::Value,
188 canonical_event: Option<&str>,
189) -> Result<Vec<ManagedHook>> {
190 let mut managed = Vec::new();
191 let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
192 return Ok(managed);
193 };
194
195 for (event, groups) in events {
196 let Some(groups) = groups.as_array() else {
197 continue;
198 };
199 for group in groups {
200 let hooks = group
201 .get("hooks")
202 .and_then(serde_json::Value::as_array)
203 .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
204 for hook in hooks {
205 let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
206 continue;
207 };
208 let baseline = serde_json::to_vec(hook)?;
209 managed.push(ManagedHook {
210 settings_path: settings_path.to_string(),
211 event: event.clone(),
212 canonical_event: canonical_event.map(str::to_owned),
213 command: command.to_string(),
214 baseline_hash: write_baseline_object(repo_root, &baseline)?,
215 });
216 }
217 }
218 }
219 Ok(managed)
220}
221
222pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
223 let path = repo_root.join(&hook.settings_path);
224 let Ok(settings) = std::fs::read_to_string(path) else {
225 return "missing";
226 };
227 let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
228 else {
229 return "modified";
230 };
231 let Some(groups) = settings
232 .get("hooks")
233 .and_then(|hooks| hooks.get(&hook.event))
234 .and_then(serde_json::Value::as_array)
235 else {
236 return "missing";
237 };
238
239 for group in groups {
240 let entries = group
241 .get("hooks")
242 .and_then(serde_json::Value::as_array)
243 .map_or_else(|| vec![group], |entries| entries.iter().collect());
244 for entry in entries {
245 if entry.get("command").and_then(serde_json::Value::as_str)
246 == Some(hook.command.as_str())
247 {
248 let Ok(content) = serde_json::to_vec(entry) else {
249 return "modified";
250 };
251 return if hash_bytes(&content) == hook.baseline_hash {
252 "clean"
253 } else {
254 "modified"
255 };
256 }
257 }
258 }
259 "missing"
260}
261
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum TargetOwnership {
265 #[default]
266 Generated,
267 Imported,
268}
269
270pub fn lockfile_path(repo_root: &Path) -> PathBuf {
271 let global = crate::paths::global_lockfile(repo_root);
272 if global.exists() {
273 global
274 } else {
275 repo_root.join("tuff.lock")
276 }
277}
278
279pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
280 let lock_path = repo_root.join("tuff.lock");
281 init_lockfile_at(&lock_path)?;
282 Ok(lock_path)
283}
284
285pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
286 if !lock_path.exists() {
287 write_lockfile_at(
288 lock_path,
289 &Lockfile {
290 version: LOCKFILE_VERSION,
291 capabilities: BTreeMap::new(),
292 },
293 )?;
294 }
295 Ok(())
296}
297
298pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
299 let lock_path = lockfile_path(repo_root);
300 read_lockfile_at(&lock_path)
301}
302
303pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
304 if !path.exists() {
305 let parent = path.parent().unwrap_or(Path::new("."));
306 return Err(TuffError::new(format!(
307 "{} is missing; run 'tuff init' first",
308 parent
309 .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
310 .display()
311 )));
312 }
313
314 let wire: WireLockfile = toml::from_str(&std::fs::read_to_string(path)?)?;
315 let mut capabilities = BTreeMap::new();
316 for item in wire.capabilities {
317 let target = item.target.clone();
318 let mut targets = BTreeMap::new();
319 targets.insert(
320 target,
321 TargetLockEntry {
322 emitted_files: Vec::new(),
323 managed_hooks: item.managed_hooks,
324 managed_mcp_entry: item.managed_mcp_entry,
325 ownership: item.ownership,
326 sha256: item.sha256,
327 installed_path: item.installed_path,
328 },
329 );
330 capabilities
331 .entry(item.name.clone())
332 .and_modify(|entry: &mut CapabilityLockEntry| {
333 entry.targets.extend(targets.clone());
334 })
335 .or_insert_with(|| CapabilityLockEntry {
336 capability_type: item.capability_type,
337 installed_version: item.version,
338 description: item.description,
339 source_path: item.source_path.clone(),
340 targets,
341 source: (!item.source.is_empty() && item.source != "local").then_some(
345 SourceMetadata {
346 source_type: item.source.clone(),
347 url: item.repository,
348 source_ref: item.resolved_ref,
349 skill: item.source_path,
350 },
351 ),
352 scope: "project".to_string(),
353 pack: item.pack,
354 implementation: item.implementation,
355 parameters: item.parameters,
356 workflow: item.workflow,
357 server: item.server,
358 });
359 }
360 let lockfile = Lockfile {
361 version: wire.version,
362 capabilities,
363 };
364 if lockfile.version != LOCKFILE_VERSION {
365 return Err(TuffError::new(format!(
366 "unsupported lockfile version: {}",
367 lockfile.version
368 )));
369 }
370 Ok(lockfile)
371}
372
373pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
374 let lock_path = lockfile_path(repo_root);
375 write_lockfile_at(&lock_path, lockfile)
376}
377
378pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
379 if let Some(parent) = path.parent() {
380 std::fs::create_dir_all(parent)?;
381 }
382 let mut capabilities = Vec::new();
383 for (name, entry) in &lockfile.capabilities {
384 for (target, target_entry) in &entry.targets {
385 let (source, repository, source_path, resolved_ref) = match &entry.source {
386 Some(source) => (
387 source.source_type.clone(),
388 source.url.clone(),
389 source.skill.clone(),
390 source.source_ref.clone(),
391 ),
392 None => (
393 "local".to_string(),
394 String::new(),
395 entry.source_path.clone(),
396 String::new(),
397 ),
398 };
399 capabilities.push(WireCapability {
400 name: name.clone(),
401 capability_type: entry.capability_type,
402 source,
403 repository,
404 source_path,
405 resolved_ref,
406 sha256: target_entry.sha256.clone(),
407 target: target.clone(),
408 installed_path: target_entry.installed_path.clone(),
409 version: entry.installed_version.clone(),
410 description: entry.description.clone(),
411 ownership: target_entry.ownership,
412 managed_hooks: target_entry.managed_hooks.clone(),
413 managed_mcp_entry: target_entry.managed_mcp_entry.clone(),
414 pack: entry.pack.clone(),
415 implementation: entry.implementation.clone(),
416 parameters: entry.parameters.clone(),
417 workflow: entry.workflow.clone(),
418 server: entry.server.clone(),
419 });
420 }
421 }
422 capabilities.sort_by(|a, b| {
423 a.name
424 .cmp(&b.name)
425 .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
426 .then_with(|| a.target.cmp(&b.target))
427 .then_with(|| a.installed_path.cmp(&b.installed_path))
428 });
429 let wire = WireLockfile {
430 version: LOCKFILE_VERSION,
431 capabilities,
432 };
433 let content = format!(
434 "# Tuff lockfile. Each entry records one capability installation target.\n{}\n",
435 toml::to_string_pretty(&wire)?
436 );
437 std::fs::write(path, content)?;
438 Ok(())
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442struct WireLockfile {
443 version: u8,
444 #[serde(rename = "capabilities")]
445 capabilities: Vec<WireCapability>,
446}
447
448#[derive(Debug, Serialize, Deserialize)]
449struct WireCapability {
450 name: String,
451 #[serde(rename = "type")]
452 capability_type: CapabilityType,
453 source: String,
454 #[serde(default, skip_serializing_if = "String::is_empty")]
455 repository: String,
456 source_path: String,
457 #[serde(default)]
458 resolved_ref: String,
459 sha256: String,
460 target: String,
461 installed_path: String,
462 #[serde(default)]
463 version: String,
464 #[serde(default)]
465 description: String,
466 #[serde(default)]
467 ownership: TargetOwnership,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
469 managed_hooks: Vec<ManagedHook>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 managed_mcp_entry: Option<ManagedMcpEntry>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pack: Option<PackProvenance>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 implementation: Option<ImplementationConfig>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 parameters: Option<serde_json::Value>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 workflow: Option<WorkflowConfig>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 server: Option<McpServerConfig>,
482}
483
484pub fn hash_bytes(content: &[u8]) -> String {
485 let mut hasher = Sha256::new();
486 hasher.update(content);
487 format!("{:x}", hasher.finalize())
488}
489
490pub fn write_baseline_object(_repo_root: &Path, content: &[u8]) -> Result<String> {
491 Ok(hash_bytes(content))
494}
495
496pub fn prune_unreferenced_baseline_objects(
497 _repo_root: &Path,
498 _lockfile: &Lockfile,
499) -> Result<usize> {
500 Ok(0)
501}
502
503pub fn drift_status(repo_root: &Path, emitted_file: &EmittedFile) -> &'static str {
504 let target_path = repo_root.join(&emitted_file.path);
505 if !target_path.exists() {
506 return "missing";
507 }
508
509 let Ok(content) = std::fs::read(&target_path) else {
510 return "missing";
511 };
512
513 if hash_bytes(&content) == emitted_file.hash {
514 "clean"
515 } else {
516 "modified"
517 }
518}
519
520pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
521 path.strip_prefix(repo_root)
522 .map(|relative| relative.to_string_lossy().replace('\\', "/"))
523 .unwrap_or_else(|_| path.to_string_lossy().to_string())
524}
525
526pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
527 if path.is_absolute() {
528 path.to_path_buf()
529 } else {
530 repo_root.join(path)
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use std::fs;
538 use tempfile::TempDir;
539
540 #[test]
541 fn init_lockfile_at_creates_new_file() {
542 let tmp = TempDir::new().unwrap();
543 let path = tmp.path().join("tuff.lock");
544 init_lockfile_at(&path).unwrap();
545 assert!(path.exists());
546
547 let lf = read_lockfile_at(&path).unwrap();
548 assert_eq!(lf.version, 1);
549 assert!(lf.capabilities.is_empty());
550 }
551
552 #[test]
553 fn read_lockfile_at_rejects_missing() {
554 let tmp = TempDir::new().unwrap();
555 let path = tmp.path().join("tuff.lock");
556 assert!(read_lockfile_at(&path).is_err());
557 }
558
559 #[test]
560 fn read_lockfile_at_rejects_v4_schema() {
561 let tmp = TempDir::new().unwrap();
562 let path = tmp.path().join("tuff.lock");
563 fs::write(&path, "version = 4\ncapabilities = []\n").unwrap();
564
565 let error = read_lockfile_at(&path).unwrap_err();
566 assert!(
567 error
568 .to_string()
569 .contains("unsupported lockfile version: 4")
570 );
571 }
572
573 #[test]
574 fn write_and_read_roundtrip() {
575 let tmp = TempDir::new().unwrap();
576 let path = tmp.path().join("tuff.lock");
577 let mut lf = Lockfile {
578 version: LOCKFILE_VERSION,
579 capabilities: BTreeMap::new(),
580 };
581 lf.capabilities.insert(
582 "test".into(),
583 CapabilityLockEntry {
584 capability_type: CapabilityType::Skill,
585 installed_version: "1.0".into(),
586 description: "test skill".into(),
587 source_path: "".into(),
588 targets: BTreeMap::from([(
589 "open-agents".into(),
590 TargetLockEntry {
591 emitted_files: Vec::new(),
592 managed_hooks: Vec::new(),
593 managed_mcp_entry: None,
594 ownership: TargetOwnership::Generated,
595 sha256: hash_bytes(b"content"),
596 installed_path: ".agents/skills/test".into(),
597 },
598 )]),
599 source: None,
600 scope: "project".into(),
601 pack: None,
602 implementation: None,
603 parameters: None,
604 workflow: None,
605 server: None,
606 },
607 );
608 write_lockfile_at(&path, &lf).unwrap();
609 let read = read_lockfile_at(&path).unwrap();
610 assert_eq!(read.capabilities.len(), 1);
611 }
612
613 #[test]
614 fn missing_target_ownership_defaults_to_generated() {
615 let tmp = TempDir::new().unwrap();
616 let path = tmp.path().join("tuff.lock");
617 fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
618 let read = read_lockfile_at(&path).unwrap();
619 assert!(read.capabilities.is_empty());
620 }
621
622 #[test]
623 fn hash_bytes_produces_consistent_output() {
624 let h1 = hash_bytes(b"hello");
625 let h2 = hash_bytes(b"hello");
626 assert_eq!(h1, h2);
627 assert_eq!(h1.len(), 64);
628 assert_ne!(h1, hash_bytes(b"world"));
629 }
630
631 #[test]
632 fn drift_status_reports_clean() {
633 let tmp = TempDir::new().unwrap();
634 let file = tmp.path().join("test.md");
635 fs::write(&file, "content").unwrap();
636
637 let emitted = crate::adapter::EmittedFile {
638 path: file.file_name().unwrap().to_string_lossy().to_string(),
639 hash: hash_bytes(b"content"),
640 baseline_hash: hash_bytes(b"content"),
641 };
642 assert_eq!(drift_status(tmp.path(), &emitted), "clean");
643 }
644
645 #[test]
646 fn drift_status_reports_modified() {
647 let tmp = TempDir::new().unwrap();
648 let file = tmp.path().join("test.md");
649 fs::write(&file, "different").unwrap();
650
651 let emitted = crate::adapter::EmittedFile {
652 path: file.file_name().unwrap().to_string_lossy().to_string(),
653 hash: hash_bytes(b"original"),
654 baseline_hash: hash_bytes(b"original"),
655 };
656 assert_eq!(drift_status(tmp.path(), &emitted), "modified");
657 }
658
659 #[test]
660 fn drift_status_reports_missing() {
661 let tmp = TempDir::new().unwrap();
662 let emitted = crate::adapter::EmittedFile {
663 path: "nonexistent.md".into(),
664 hash: "abc".into(),
665 baseline_hash: "abc".into(),
666 };
667 assert_eq!(drift_status(tmp.path(), &emitted), "missing");
668 }
669
670 #[test]
671 fn managed_mcp_entry_status_tracks_the_entry_not_the_file() {
672 let tmp = TempDir::new().unwrap();
673 let config_path = tmp.path().join("mcp.json");
674 let entry_value = serde_json::json!({"command": "npx", "args": ["-y", "srv"]});
675 let both = |neighbour: &str| {
676 serde_json::to_string_pretty(&serde_json::json!({
677 "mcpServers": {"github": entry_value, "neighbour": {"command": neighbour}}
678 }))
679 .unwrap()
680 };
681 fs::write(&config_path, both("hand")).unwrap();
682 let managed = ManagedMcpEntry {
683 config_path: "mcp.json".into(),
684 baseline_hash: managed_mcp_entry_baseline(&entry_value).unwrap(),
685 };
686
687 assert_eq!(
690 managed_mcp_entry_status(tmp.path(), "github", &managed),
691 "clean"
692 );
693 fs::write(&config_path, both("edited")).unwrap();
694 assert_eq!(
695 managed_mcp_entry_status(tmp.path(), "github", &managed),
696 "clean"
697 );
698
699 fs::write(
701 &config_path,
702 r#"{"mcpServers": {"github": {"command": "tampered"}}}"#,
703 )
704 .unwrap();
705 assert_eq!(
706 managed_mcp_entry_status(tmp.path(), "github", &managed),
707 "modified"
708 );
709 fs::write(&config_path, r#"{"mcpServers": {}}"#).unwrap();
710 assert_eq!(
711 managed_mcp_entry_status(tmp.path(), "github", &managed),
712 "missing"
713 );
714 fs::remove_file(&config_path).unwrap();
715 assert_eq!(
716 managed_mcp_entry_status(tmp.path(), "github", &managed),
717 "missing"
718 );
719 }
720}