1use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::Path;
14
15use crate::model::{Graph, Node};
16
17const LOCK_FILE: &str = "drft.lock";
18const HEADER: &str = "# drft.lock — generated by `drft lock`. Do not edit by hand.\n\n";
19
20#[derive(Debug, Clone, PartialEq, Default)]
22pub struct Lock {
23 pub nodes: BTreeMap<String, LockedNode>,
24}
25
26#[derive(Debug, Clone, PartialEq, Default)]
29pub struct LockedNode {
30 pub hash: Option<String>,
31 pub edges: BTreeMap<String, Option<String>>,
33}
34
35impl Lock {
36 pub fn from_composed(graph: &Graph) -> Self {
39 let mut nodes: BTreeMap<String, LockedNode> = BTreeMap::new();
40
41 for (path, node) in &graph.nodes {
42 nodes.insert(
43 path.clone(),
44 LockedNode {
45 hash: node.fs_hash().map(str::to_string),
46 edges: BTreeMap::new(),
47 },
48 );
49 }
50
51 for edge in &graph.edges {
52 let entry = nodes.entry(edge.source.clone()).or_default();
53 entry.edges.insert(
54 edge.target.clone(),
55 graph
56 .nodes
57 .get(&edge.target)
58 .and_then(Node::fs_hash)
59 .map(str::to_string),
60 );
61 }
62
63 nodes.retain(|_, node| node.hash.is_some() || !node.edges.is_empty());
68
69 Lock { nodes }
70 }
71
72 pub fn to_toml(&self) -> Result<String> {
74 let doc = LockToml {
75 node: self
76 .nodes
77 .iter()
78 .map(|(path, node)| NodeToml {
79 path: path.clone(),
80 hash: node.hash.clone(),
81 edge: node
82 .edges
83 .iter()
84 .map(|(target, target_hash)| EdgeToml {
85 target: target.clone(),
86 target_hash: target_hash.clone(),
87 })
88 .collect(),
89 })
90 .collect(),
91 };
92 let body = toml::to_string_pretty(&doc).context("failed to serialize lockfile")?;
93 Ok(format!("{HEADER}{body}"))
94 }
95
96 pub fn from_toml(content: &str) -> Result<Self> {
98 let doc: LockToml = toml::from_str(content).context("failed to parse lockfile")?;
99 let mut nodes = BTreeMap::new();
100 for node in doc.node {
101 let edges = node
102 .edge
103 .into_iter()
104 .map(|e| (e.target, e.target_hash))
105 .collect();
106 nodes.insert(
107 node.path,
108 LockedNode {
109 hash: node.hash,
110 edges,
111 },
112 );
113 }
114 Ok(Lock { nodes })
115 }
116}
117
118#[derive(Debug, Serialize, Deserialize, Default)]
122#[serde(deny_unknown_fields)]
123struct LockToml {
124 #[serde(rename = "node", default)]
125 node: Vec<NodeToml>,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130struct NodeToml {
131 path: String,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 hash: Option<String>,
134 #[serde(rename = "edge", default, skip_serializing_if = "Vec::is_empty")]
135 edge: Vec<EdgeToml>,
136}
137
138#[derive(Debug, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140struct EdgeToml {
141 target: String,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 target_hash: Option<String>,
144}
145
146pub fn read(root: &Path) -> Result<Option<Lock>> {
149 let path = root.join(LOCK_FILE);
150 if !path.exists() {
151 return Ok(None);
152 }
153 let content = std::fs::read_to_string(&path)
154 .with_context(|| format!("failed to read {}", path.display()))?;
155 match Lock::from_toml(&content) {
156 Ok(lock) => Ok(Some(lock)),
157 Err(e) => {
158 eprintln!("warn: could not parse {LOCK_FILE} ({e}) — run `drft lock` to regenerate");
159 Ok(None)
160 }
161 }
162}
163
164pub fn write(root: &Path, lock: &Lock) -> Result<()> {
166 let content = lock.to_toml()?;
167 let lock_path = root.join(LOCK_FILE);
168 let tmp_path = root.join("drft.lock.tmp");
169
170 std::fs::write(&tmp_path, &content)
171 .with_context(|| format!("failed to write {}", tmp_path.display()))?;
172 std::fs::rename(&tmp_path, &lock_path).with_context(|| {
173 let _ = std::fs::remove_file(&tmp_path);
174 format!(
175 "failed to rename {} to {}",
176 tmp_path.display(),
177 lock_path.display()
178 )
179 })?;
180 Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::compose::compose;
187 use crate::model::{Edge, GraphSet, Node};
188 use serde_json::json;
189 use tempfile::TempDir;
190
191 fn fs_fragment(entries: &[(&str, &str)]) -> Graph {
192 let mut g = Graph::labeled("fs");
193 for (path, hash) in entries {
194 g.set_node(
195 *path,
196 Node::new(
197 json!({ "type": "file", "hash": hash })
198 .as_object()
199 .unwrap()
200 .clone(),
201 ),
202 );
203 }
204 g
205 }
206
207 #[test]
208 fn from_composed_captures_hashes_and_edges() {
209 let mut fs = fs_fragment(&[("index.md", "b3:idx"), ("setup.md", "b3:setup")]);
210 fs.add_edge(Edge::new("index.md", "setup.md"));
211 let composed = compose(&GraphSet::new(vec![fs]));
212
213 let lock = Lock::from_composed(&composed);
214 assert_eq!(lock.nodes["index.md"].hash.as_deref(), Some("b3:idx"));
215 assert_eq!(
216 lock.nodes["index.md"].edges["setup.md"].as_deref(),
217 Some("b3:setup")
218 );
219 assert!(lock.nodes["setup.md"].edges.is_empty());
220 }
221
222 #[test]
223 fn directory_nodes_are_omitted_but_resolve_edges() {
224 let mut fs = fs_fragment(&[("index.md", "b3:idx")]);
228 fs.set_node(
229 "guides",
230 Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
231 );
232 fs.add_edge(Edge::new("index.md", "guides"));
233 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
234
235 assert!(
236 !lock.nodes.contains_key("guides"),
237 "hash-less, edge-less directory node must not be locked"
238 );
239 assert!(
240 lock.nodes["index.md"].edges.contains_key("guides"),
241 "the link to the directory must still be recorded"
242 );
243 assert_eq!(lock.nodes["index.md"].edges["guides"], None);
244 }
245
246 #[test]
247 fn toml_round_trips() {
248 let mut fs = fs_fragment(&[("a.md", "b3:a"), ("b.md", "b3:b")]);
249 fs.add_edge(Edge::new("a.md", "b.md"));
250 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
251
252 let toml = lock.to_toml().unwrap();
253 assert!(toml.contains("[[node]]"));
254 assert!(toml.contains("[[node.edge]]"));
255 assert!(!toml.contains("version"));
256 let parsed = Lock::from_toml(&toml).unwrap();
257 assert_eq!(lock, parsed);
258 }
259
260 #[test]
261 fn deterministic_output() {
262 let fs = fs_fragment(&[("z.md", "b3:z"), ("a.md", "b3:a")]);
263 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
264 assert_eq!(lock.to_toml().unwrap(), lock.to_toml().unwrap());
265 }
266
267 #[test]
268 fn write_then_read() {
269 let dir = TempDir::new().unwrap();
270 let fs = fs_fragment(&[("a.md", "b3:a")]);
271 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
272 write(dir.path(), &lock).unwrap();
273 assert_eq!(read(dir.path()).unwrap().unwrap(), lock);
274 }
275
276 #[test]
277 fn read_missing_is_none() {
278 let dir = TempDir::new().unwrap();
279 assert!(read(dir.path()).unwrap().is_none());
280 }
281
282 #[test]
283 fn unparseable_lock_warns_and_returns_none() {
284 let dir = TempDir::new().unwrap();
285 std::fs::write(dir.path().join(LOCK_FILE), "this is not valid toml {{{").unwrap();
286 assert!(read(dir.path()).unwrap().is_none());
287 }
288}