Skip to main content

tuff_core/
lockfile.rs

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    // Kept temporarily as an internal compatibility helper for lifecycle code;
375    // baseline content is now stored only as a verified materialized tree.
376    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}