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;
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}
39
40fn default_scope() -> String {
41 "project".to_string()
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SourceMetadata {
46 #[serde(rename = "type")]
47 pub source_type: String,
48 pub url: String,
49 #[serde(rename = "ref")]
50 pub source_ref: String,
51 pub skill: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TargetLockEntry {
56 #[serde(rename = "emittedFiles")]
57 pub emitted_files: Vec<EmittedFile>,
58 #[serde(
59 default,
60 rename = "managedHooks",
61 skip_serializing_if = "Vec::is_empty"
62 )]
63 pub managed_hooks: Vec<ManagedHook>,
64 #[serde(default)]
65 pub ownership: TargetOwnership,
66 #[serde(default)]
67 pub sha256: String,
68 #[serde(default)]
69 pub installed_path: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ManagedHook {
74 #[serde(rename = "settingsPath")]
75 pub settings_path: String,
76 pub event: String,
77 #[serde(
78 default,
79 rename = "canonicalEvent",
80 skip_serializing_if = "Option::is_none"
81 )]
82 pub canonical_event: Option<String>,
83 pub command: String,
84 #[serde(rename = "baselineHash")]
85 pub baseline_hash: String,
86}
87
88pub fn managed_hooks_from_fragment(
89 repo_root: &Path,
90 settings_path: &str,
91 fragment: &serde_json::Value,
92) -> Result<Vec<ManagedHook>> {
93 managed_hooks_from_fragment_with_canonical(repo_root, settings_path, fragment, None)
94}
95
96pub fn managed_hooks_from_fragment_with_canonical(
97 repo_root: &Path,
98 settings_path: &str,
99 fragment: &serde_json::Value,
100 canonical_event: Option<&str>,
101) -> Result<Vec<ManagedHook>> {
102 let mut managed = Vec::new();
103 let Some(events) = fragment.get("hooks").and_then(serde_json::Value::as_object) else {
104 return Ok(managed);
105 };
106
107 for (event, groups) in events {
108 let Some(groups) = groups.as_array() else {
109 continue;
110 };
111 for group in groups {
112 let hooks = group
113 .get("hooks")
114 .and_then(serde_json::Value::as_array)
115 .map_or_else(|| vec![group], |hooks| hooks.iter().collect());
116 for hook in hooks {
117 let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else {
118 continue;
119 };
120 let baseline = serde_json::to_vec(hook)?;
121 managed.push(ManagedHook {
122 settings_path: settings_path.to_string(),
123 event: event.clone(),
124 canonical_event: canonical_event.map(str::to_owned),
125 command: command.to_string(),
126 baseline_hash: write_baseline_object(repo_root, &baseline)?,
127 });
128 }
129 }
130 }
131 Ok(managed)
132}
133
134pub fn managed_hook_status(repo_root: &Path, hook: &ManagedHook) -> &'static str {
135 let path = repo_root.join(&hook.settings_path);
136 let Ok(settings) = std::fs::read_to_string(path) else {
137 return "missing";
138 };
139 let Ok(settings): std::result::Result<serde_json::Value, _> = serde_json::from_str(&settings)
140 else {
141 return "modified";
142 };
143 let Some(groups) = settings
144 .get("hooks")
145 .and_then(|hooks| hooks.get(&hook.event))
146 .and_then(serde_json::Value::as_array)
147 else {
148 return "missing";
149 };
150
151 for group in groups {
152 let entries = group
153 .get("hooks")
154 .and_then(serde_json::Value::as_array)
155 .map_or_else(|| vec![group], |entries| entries.iter().collect());
156 for entry in entries {
157 if entry.get("command").and_then(serde_json::Value::as_str)
158 == Some(hook.command.as_str())
159 {
160 let Ok(content) = serde_json::to_vec(entry) else {
161 return "modified";
162 };
163 return if hash_bytes(&content) == hook.baseline_hash {
164 "clean"
165 } else {
166 "modified"
167 };
168 }
169 }
170 }
171 "missing"
172}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "lowercase")]
176pub enum TargetOwnership {
177 #[default]
178 Generated,
179 Imported,
180}
181
182pub fn lockfile_path(repo_root: &Path) -> PathBuf {
183 let global = crate::paths::global_lockfile(repo_root);
184 if global.exists() {
185 global
186 } else {
187 repo_root.join("tuff.lock")
188 }
189}
190
191pub fn init_lockfile(repo_root: &Path) -> Result<PathBuf> {
192 let lock_path = repo_root.join("tuff.lock");
193 init_lockfile_at(&lock_path)?;
194 Ok(lock_path)
195}
196
197pub fn init_lockfile_at(lock_path: &Path) -> Result<()> {
198 if !lock_path.exists() {
199 write_lockfile_at(
200 lock_path,
201 &Lockfile {
202 version: LOCKFILE_VERSION,
203 capabilities: BTreeMap::new(),
204 },
205 )?;
206 }
207 Ok(())
208}
209
210pub fn require_lockfile(repo_root: &Path) -> Result<Lockfile> {
211 let lock_path = lockfile_path(repo_root);
212 read_lockfile_at(&lock_path)
213}
214
215pub fn read_lockfile_at(path: &Path) -> Result<Lockfile> {
216 if !path.exists() {
217 let parent = path.parent().unwrap_or(Path::new("."));
218 return Err(TuffError::new(format!(
219 "{} is missing; run 'tuff init' first",
220 parent
221 .join(path.file_name().unwrap_or(OsStr::new("tuff.lock")))
222 .display()
223 )));
224 }
225
226 let wire: WireLockfile = toml::from_str(&std::fs::read_to_string(path)?)?;
227 let mut capabilities = BTreeMap::new();
228 for item in wire.capabilities {
229 let target = item.target.clone();
230 let mut targets = BTreeMap::new();
231 targets.insert(
232 target,
233 TargetLockEntry {
234 emitted_files: Vec::new(),
235 managed_hooks: item.managed_hooks,
236 ownership: item.ownership,
237 sha256: item.sha256,
238 installed_path: item.installed_path,
239 },
240 );
241 capabilities
242 .entry(item.name.clone())
243 .and_modify(|entry: &mut CapabilityLockEntry| {
244 entry.targets.extend(targets.clone());
245 })
246 .or_insert_with(|| CapabilityLockEntry {
247 capability_type: item.capability_type,
248 installed_version: item.version,
249 description: item.description,
250 source_path: item.source_path.clone(),
251 targets,
252 source: (item.source == "git").then_some(SourceMetadata {
253 source_type: "git".to_string(),
254 url: item.repository,
255 source_ref: item.resolved_ref,
256 skill: item.source_path,
257 }),
258 scope: "project".to_string(),
259 });
260 }
261 let lockfile = Lockfile {
262 version: wire.version,
263 capabilities,
264 };
265 if lockfile.version != LOCKFILE_VERSION {
266 return Err(TuffError::new(format!(
267 "unsupported lockfile version: {}",
268 lockfile.version
269 )));
270 }
271 Ok(lockfile)
272}
273
274pub fn write_lockfile(repo_root: &Path, lockfile: &Lockfile) -> Result<()> {
275 let lock_path = lockfile_path(repo_root);
276 write_lockfile_at(&lock_path, lockfile)
277}
278
279pub fn write_lockfile_at(path: &Path, lockfile: &Lockfile) -> Result<()> {
280 if let Some(parent) = path.parent() {
281 std::fs::create_dir_all(parent)?;
282 }
283 let mut capabilities = Vec::new();
284 for (name, entry) in &lockfile.capabilities {
285 for (target, target_entry) in &entry.targets {
286 let (source, repository, source_path, resolved_ref) = match &entry.source {
287 Some(source) => (
288 "git".to_string(),
289 source.url.clone(),
290 source.skill.clone(),
291 source.source_ref.clone(),
292 ),
293 None => (
294 "local".to_string(),
295 String::new(),
296 entry.source_path.clone(),
297 String::new(),
298 ),
299 };
300 capabilities.push(WireCapability {
301 name: name.clone(),
302 capability_type: entry.capability_type,
303 source,
304 repository,
305 source_path,
306 resolved_ref,
307 sha256: target_entry.sha256.clone(),
308 target: target.clone(),
309 installed_path: target_entry.installed_path.clone(),
310 version: entry.installed_version.clone(),
311 description: entry.description.clone(),
312 ownership: target_entry.ownership,
313 managed_hooks: target_entry.managed_hooks.clone(),
314 });
315 }
316 }
317 capabilities.sort_by(|a, b| {
318 a.name
319 .cmp(&b.name)
320 .then_with(|| a.capability_type.as_str().cmp(b.capability_type.as_str()))
321 .then_with(|| a.target.cmp(&b.target))
322 .then_with(|| a.installed_path.cmp(&b.installed_path))
323 });
324 let wire = WireLockfile {
325 version: LOCKFILE_VERSION,
326 capabilities,
327 };
328 let content = format!(
329 "# Tuff lockfile. Each entry records one capability installation target.\n{}\n",
330 toml::to_string_pretty(&wire)?
331 );
332 std::fs::write(path, content)?;
333 Ok(())
334}
335
336#[derive(Debug, Serialize, Deserialize)]
337struct WireLockfile {
338 version: u8,
339 #[serde(rename = "capabilities")]
340 capabilities: Vec<WireCapability>,
341}
342
343#[derive(Debug, Serialize, Deserialize)]
344struct WireCapability {
345 name: String,
346 #[serde(rename = "type")]
347 capability_type: CapabilityType,
348 source: String,
349 #[serde(default, skip_serializing_if = "String::is_empty")]
350 repository: String,
351 source_path: String,
352 #[serde(default)]
353 resolved_ref: String,
354 sha256: String,
355 target: String,
356 installed_path: String,
357 #[serde(default)]
358 version: String,
359 #[serde(default)]
360 description: String,
361 #[serde(default)]
362 ownership: TargetOwnership,
363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
364 managed_hooks: Vec<ManagedHook>,
365}
366
367pub fn hash_bytes(content: &[u8]) -> String {
368 let mut hasher = Sha256::new();
369 hasher.update(content);
370 format!("{:x}", hasher.finalize())
371}
372
373pub fn write_baseline_object(_repo_root: &Path, content: &[u8]) -> Result<String> {
374 Ok(hash_bytes(content))
377}
378
379pub fn prune_unreferenced_baseline_objects(
380 _repo_root: &Path,
381 _lockfile: &Lockfile,
382) -> Result<usize> {
383 Ok(0)
384}
385
386pub fn drift_status(repo_root: &Path, emitted_file: &EmittedFile) -> &'static str {
387 let target_path = repo_root.join(&emitted_file.path);
388 if !target_path.exists() {
389 return "missing";
390 }
391
392 let Ok(content) = std::fs::read(&target_path) else {
393 return "missing";
394 };
395
396 if hash_bytes(&content) == emitted_file.hash {
397 "clean"
398 } else {
399 "modified"
400 }
401}
402
403pub fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
404 path.strip_prefix(repo_root)
405 .map(|relative| relative.to_string_lossy().replace('\\', "/"))
406 .unwrap_or_else(|_| path.to_string_lossy().to_string())
407}
408
409pub fn absolutize(repo_root: &Path, path: &Path) -> PathBuf {
410 if path.is_absolute() {
411 path.to_path_buf()
412 } else {
413 repo_root.join(path)
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use std::fs;
421 use tempfile::TempDir;
422
423 #[test]
424 fn init_lockfile_at_creates_new_file() {
425 let tmp = TempDir::new().unwrap();
426 let path = tmp.path().join("tuff.lock");
427 init_lockfile_at(&path).unwrap();
428 assert!(path.exists());
429
430 let lf = read_lockfile_at(&path).unwrap();
431 assert_eq!(lf.version, 1);
432 assert!(lf.capabilities.is_empty());
433 }
434
435 #[test]
436 fn read_lockfile_at_rejects_missing() {
437 let tmp = TempDir::new().unwrap();
438 let path = tmp.path().join("tuff.lock");
439 assert!(read_lockfile_at(&path).is_err());
440 }
441
442 #[test]
443 fn read_lockfile_at_rejects_v4_schema() {
444 let tmp = TempDir::new().unwrap();
445 let path = tmp.path().join("tuff.lock");
446 fs::write(&path, "version = 4\ncapabilities = []\n").unwrap();
447
448 let error = read_lockfile_at(&path).unwrap_err();
449 assert!(
450 error
451 .to_string()
452 .contains("unsupported lockfile version: 4")
453 );
454 }
455
456 #[test]
457 fn write_and_read_roundtrip() {
458 let tmp = TempDir::new().unwrap();
459 let path = tmp.path().join("tuff.lock");
460 let mut lf = Lockfile {
461 version: LOCKFILE_VERSION,
462 capabilities: BTreeMap::new(),
463 };
464 lf.capabilities.insert(
465 "test".into(),
466 CapabilityLockEntry {
467 capability_type: CapabilityType::Skill,
468 installed_version: "1.0".into(),
469 description: "test skill".into(),
470 source_path: "".into(),
471 targets: BTreeMap::from([(
472 "open-agents".into(),
473 TargetLockEntry {
474 emitted_files: Vec::new(),
475 managed_hooks: Vec::new(),
476 ownership: TargetOwnership::Generated,
477 sha256: hash_bytes(b"content"),
478 installed_path: ".agents/skills/test".into(),
479 },
480 )]),
481 source: None,
482 scope: "project".into(),
483 },
484 );
485 write_lockfile_at(&path, &lf).unwrap();
486 let read = read_lockfile_at(&path).unwrap();
487 assert_eq!(read.capabilities.len(), 1);
488 }
489
490 #[test]
491 fn missing_target_ownership_defaults_to_generated() {
492 let tmp = TempDir::new().unwrap();
493 let path = tmp.path().join("tuff.lock");
494 fs::write(&path, "version = 1\ncapabilities = []\n").unwrap();
495 let read = read_lockfile_at(&path).unwrap();
496 assert!(read.capabilities.is_empty());
497 }
498
499 #[test]
500 fn hash_bytes_produces_consistent_output() {
501 let h1 = hash_bytes(b"hello");
502 let h2 = hash_bytes(b"hello");
503 assert_eq!(h1, h2);
504 assert_eq!(h1.len(), 64);
505 assert_ne!(h1, hash_bytes(b"world"));
506 }
507
508 #[test]
509 fn drift_status_reports_clean() {
510 let tmp = TempDir::new().unwrap();
511 let file = tmp.path().join("test.md");
512 fs::write(&file, "content").unwrap();
513
514 let emitted = crate::adapter::EmittedFile {
515 path: file.file_name().unwrap().to_string_lossy().to_string(),
516 hash: hash_bytes(b"content"),
517 baseline_hash: hash_bytes(b"content"),
518 };
519 assert_eq!(drift_status(tmp.path(), &emitted), "clean");
520 }
521
522 #[test]
523 fn drift_status_reports_modified() {
524 let tmp = TempDir::new().unwrap();
525 let file = tmp.path().join("test.md");
526 fs::write(&file, "different").unwrap();
527
528 let emitted = crate::adapter::EmittedFile {
529 path: file.file_name().unwrap().to_string_lossy().to_string(),
530 hash: hash_bytes(b"original"),
531 baseline_hash: hash_bytes(b"original"),
532 };
533 assert_eq!(drift_status(tmp.path(), &emitted), "modified");
534 }
535
536 #[test]
537 fn drift_status_reports_missing() {
538 let tmp = TempDir::new().unwrap();
539 let emitted = crate::adapter::EmittedFile {
540 path: "nonexistent.md".into(),
541 hash: "abc".into(),
542 baseline_hash: "abc".into(),
543 };
544 assert_eq!(drift_status(tmp.path(), &emitted), "missing");
545 }
546}