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 Lock { nodes }
64 }
65
66 pub fn to_toml(&self) -> Result<String> {
68 let doc = LockToml {
69 node: self
70 .nodes
71 .iter()
72 .map(|(path, node)| NodeToml {
73 path: path.clone(),
74 hash: node.hash.clone(),
75 edge: node
76 .edges
77 .iter()
78 .map(|(target, target_hash)| EdgeToml {
79 target: target.clone(),
80 target_hash: target_hash.clone(),
81 })
82 .collect(),
83 })
84 .collect(),
85 };
86 let body = toml::to_string_pretty(&doc).context("failed to serialize lockfile")?;
87 Ok(format!("{HEADER}{body}"))
88 }
89
90 pub fn from_toml(content: &str) -> Result<Self> {
92 let doc: LockToml = toml::from_str(content).context("failed to parse lockfile")?;
93 let mut nodes = BTreeMap::new();
94 for node in doc.node {
95 let edges = node
96 .edge
97 .into_iter()
98 .map(|e| (e.target, e.target_hash))
99 .collect();
100 nodes.insert(
101 node.path,
102 LockedNode {
103 hash: node.hash,
104 edges,
105 },
106 );
107 }
108 Ok(Lock { nodes })
109 }
110}
111
112#[derive(Debug, Serialize, Deserialize, Default)]
116#[serde(deny_unknown_fields)]
117struct LockToml {
118 #[serde(rename = "node", default)]
119 node: Vec<NodeToml>,
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124struct NodeToml {
125 path: String,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 hash: Option<String>,
128 #[serde(rename = "edge", default, skip_serializing_if = "Vec::is_empty")]
129 edge: Vec<EdgeToml>,
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134struct EdgeToml {
135 target: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 target_hash: Option<String>,
138}
139
140pub fn read(root: &Path) -> Result<Option<Lock>> {
143 let path = root.join(LOCK_FILE);
144 if !path.exists() {
145 return Ok(None);
146 }
147 let content = std::fs::read_to_string(&path)
148 .with_context(|| format!("failed to read {}", path.display()))?;
149 match Lock::from_toml(&content) {
150 Ok(lock) => Ok(Some(lock)),
151 Err(e) => {
152 eprintln!("warn: could not parse {LOCK_FILE} ({e}) — run `drft lock` to regenerate");
153 Ok(None)
154 }
155 }
156}
157
158pub fn write(root: &Path, lock: &Lock) -> Result<()> {
160 let content = lock.to_toml()?;
161 let lock_path = root.join(LOCK_FILE);
162 let tmp_path = root.join("drft.lock.tmp");
163
164 std::fs::write(&tmp_path, &content)
165 .with_context(|| format!("failed to write {}", tmp_path.display()))?;
166 std::fs::rename(&tmp_path, &lock_path).with_context(|| {
167 let _ = std::fs::remove_file(&tmp_path);
168 format!(
169 "failed to rename {} to {}",
170 tmp_path.display(),
171 lock_path.display()
172 )
173 })?;
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::compose::compose;
181 use crate::model::{Edge, GraphSet, Node};
182 use serde_json::json;
183 use tempfile::TempDir;
184
185 fn fs_fragment(entries: &[(&str, &str)]) -> Graph {
186 let mut g = Graph::labeled("fs");
187 for (path, hash) in entries {
188 g.set_node(
189 *path,
190 Node::new(
191 json!({ "type": "file", "hash": hash })
192 .as_object()
193 .unwrap()
194 .clone(),
195 ),
196 );
197 }
198 g
199 }
200
201 #[test]
202 fn from_composed_captures_hashes_and_edges() {
203 let mut fs = fs_fragment(&[("index.md", "b3:idx"), ("setup.md", "b3:setup")]);
204 fs.add_edge(Edge::new("index.md", "setup.md"));
205 let composed = compose(&GraphSet::new(vec![fs]));
206
207 let lock = Lock::from_composed(&composed);
208 assert_eq!(lock.nodes["index.md"].hash.as_deref(), Some("b3:idx"));
209 assert_eq!(
210 lock.nodes["index.md"].edges["setup.md"].as_deref(),
211 Some("b3:setup")
212 );
213 assert!(lock.nodes["setup.md"].edges.is_empty());
214 }
215
216 #[test]
217 fn toml_round_trips() {
218 let mut fs = fs_fragment(&[("a.md", "b3:a"), ("b.md", "b3:b")]);
219 fs.add_edge(Edge::new("a.md", "b.md"));
220 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
221
222 let toml = lock.to_toml().unwrap();
223 assert!(toml.contains("[[node]]"));
224 assert!(toml.contains("[[node.edge]]"));
225 assert!(!toml.contains("version"));
226 let parsed = Lock::from_toml(&toml).unwrap();
227 assert_eq!(lock, parsed);
228 }
229
230 #[test]
231 fn deterministic_output() {
232 let fs = fs_fragment(&[("z.md", "b3:z"), ("a.md", "b3:a")]);
233 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
234 assert_eq!(lock.to_toml().unwrap(), lock.to_toml().unwrap());
235 }
236
237 #[test]
238 fn write_then_read() {
239 let dir = TempDir::new().unwrap();
240 let fs = fs_fragment(&[("a.md", "b3:a")]);
241 let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
242 write(dir.path(), &lock).unwrap();
243 assert_eq!(read(dir.path()).unwrap().unwrap(), lock);
244 }
245
246 #[test]
247 fn read_missing_is_none() {
248 let dir = TempDir::new().unwrap();
249 assert!(read(dir.path()).unwrap().is_none());
250 }
251
252 #[test]
253 fn unparseable_lock_warns_and_returns_none() {
254 let dir = TempDir::new().unwrap();
255 std::fs::write(dir.path().join(LOCK_FILE), "this is not valid toml {{{").unwrap();
256 assert!(read(dir.path()).unwrap().is_none());
257 }
258}