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 { nodes }
64    }
65
66    /// Serialize to deterministic TOML with a generated header comment.
67    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    /// Deserialize from TOML (comments and the header are ignored).
91    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// `deny_unknown_fields` rejects foreign shapes (e.g. an older lockfile format)
113// so they surface as a parse failure that points at `drft lock`, rather than
114// silently deserializing into an empty lock.
115#[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
140/// Read `drft.lock` from `root`. Returns `Ok(None)` when the file is absent or
141/// cannot be parsed (a parse failure warns and points at `drft lock`).
142pub 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
158/// Write `drft.lock` atomically (temp file + rename).
159pub 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}