Skip to main content

drft/
lock.rs

1//! The lockfile: a snapshot of node content hashes and per-node outbound edge
2//! target hashes, keyed by path. Lock is infrastructure, not a graph — the graph
3//! carries current observations only; `drft check` joins this snapshot to derive
4//! staleness.
5//!
6//! The on-disk form (`drft.lock`) is deterministic TOML with no timestamps and
7//! no version field. A parse failure warns and points at `drft lock` rather than
8//! failing the command.
9
10use 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/// The locked snapshot, keyed by path.
21#[derive(Debug, Clone, PartialEq, Default)]
22pub struct Lock {
23    pub nodes: BTreeMap<String, LockedNode>,
24}
25
26/// A locked node: its content hash and the target hash of each outbound edge at
27/// lock time.
28#[derive(Debug, Clone, PartialEq, Default)]
29pub struct LockedNode {
30    pub hash: Option<String>,
31    /// Outbound edge target path → that target's content hash at lock time.
32    pub edges: BTreeMap<String, Option<String>>,
33}
34
35impl Lock {
36    /// Snapshot the composed graph: each node's `fs` hash, and for each outbound
37    /// edge, the current hash of its target.
38    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 only what can drift. A node with no hash and no outbound edges —
64        // a directory, or an unreferenced escaping symlink — carries nothing to
65        // check, so it stays out of the lockfile. A directory that *is* an edge
66        // target still appears as a (null) target hash under the linking node.
67        nodes.retain(|_, node| node.hash.is_some() || !node.edges.is_empty());
68
69        Lock { nodes }
70    }
71
72    /// Serialize to deterministic TOML with a generated header comment.
73    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    /// Deserialize from TOML (comments and the header are ignored).
97    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// `deny_unknown_fields` rejects foreign shapes (e.g. an older lockfile format)
119// so they surface as a parse failure that points at `drft lock`, rather than
120// silently deserializing into an empty lock.
121#[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
146/// Read `drft.lock` from `root`. Returns `Ok(None)` when the file is absent or
147/// cannot be parsed (a parse failure warns and points at `drft lock`).
148pub 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
164/// Write `drft.lock` atomically (temp file + rename).
165pub 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        // A directory node carries no hash and no outbound edges. A file links to
225        // it. The directory must not appear as its own lock entry, but the link
226        // must still resolve — recorded as a null target hash under the file.
227        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}