1use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use crate::Result;
10
11pub const LOCKFILE_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Lockfile {
17 pub version: u32,
18 #[serde(default, rename = "artifact")]
19 pub artifacts: Vec<LockEntry>,
20}
21
22impl Default for Lockfile {
23 fn default() -> Self {
24 Self {
25 version: LOCKFILE_VERSION,
26 artifacts: Vec::new(),
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct LockEntry {
34 pub handle: String,
35 pub version: String,
36 pub target: String,
37 pub content_hash: String,
39 pub files: Vec<String>,
41}
42
43impl Lockfile {
44 pub fn load(path: &Path) -> Result<Self> {
46 match std::fs::read_to_string(path) {
47 Ok(text) => Ok(toml::from_str(&text)?),
48 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
49 Err(e) => Err(e.into()),
50 }
51 }
52
53 pub fn save(&self, path: &Path) -> Result<()> {
55 let mut sorted = self.clone();
56 sorted
57 .artifacts
58 .sort_by(|a, b| (&a.handle, &a.target).cmp(&(&b.handle, &b.target)));
59 let text = toml::to_string_pretty(&sorted)?;
60 std::fs::write(path, text)?;
61 Ok(())
62 }
63
64 pub fn upsert(&mut self, mut entry: LockEntry) {
66 entry.files.sort();
67 match self
68 .artifacts
69 .iter_mut()
70 .find(|e| e.handle == entry.handle && e.target == entry.target)
71 {
72 Some(existing) => *existing = entry,
73 None => self.artifacts.push(entry),
74 }
75 }
76
77 pub fn remove(&mut self, handle: &str, target: &str) -> bool {
79 let before = self.artifacts.len();
80 self.artifacts
81 .retain(|e| !(e.handle == handle && e.target == target));
82 before != self.artifacts.len()
83 }
84
85 pub fn get(&self, handle: &str, target: &str) -> Option<&LockEntry> {
87 self.artifacts
88 .iter()
89 .find(|e| e.handle == handle && e.target == target)
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 fn entry(handle: &str, target: &str, version: &str) -> LockEntry {
98 LockEntry {
99 handle: handle.into(),
100 version: version.into(),
101 target: target.into(),
102 content_hash: "h".into(),
103 files: vec!["b".into(), "a".into()],
104 }
105 }
106
107 #[test]
108 fn upsert_replaces_and_sorts_files() {
109 let mut lf = Lockfile::default();
110 lf.upsert(entry("@a/x", "claude", "v1"));
111 lf.upsert(entry("@a/x", "claude", "v2"));
112 assert_eq!(lf.artifacts.len(), 1);
113 assert_eq!(lf.artifacts[0].version, "v2");
114 assert_eq!(lf.artifacts[0].files, vec!["a", "b"]);
115 }
116
117 #[test]
118 fn remove_only_drops_the_matching_target() {
119 let mut lf = Lockfile::default();
120 lf.upsert(entry("@a/x", "claude", "v1"));
121 lf.upsert(entry("@a/x", "cursor", "v1"));
122 assert!(lf.remove("@a/x", "claude"));
123 assert_eq!(lf.artifacts.len(), 1);
124 assert_eq!(lf.artifacts[0].target, "cursor");
125 assert!(!lf.remove("@a/x", "claude"), "second remove is a no-op");
126 }
127
128 #[test]
129 fn roundtrips_through_toml() {
130 let mut lf = Lockfile::default();
131 lf.upsert(entry("@a/x", "claude", "v1"));
132 let text = toml::to_string_pretty(&lf).unwrap();
133 let back: Lockfile = toml::from_str(&text).unwrap();
134 assert_eq!(back.version, LOCKFILE_VERSION);
135 assert_eq!(back.artifacts, lf.artifacts);
136 }
137}