Skip to main content

drft/
lockfile.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::Path;
5
6use crate::graph::{Graph, NodeType};
7
8#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
9pub struct LockfileInterface {
10    pub nodes: Vec<String>,
11}
12
13#[derive(Debug, Serialize, Deserialize, PartialEq)]
14pub struct Lockfile {
15    pub lockfile_version: u32,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub interface: Option<LockfileInterface>,
18    #[serde(default)]
19    pub nodes: BTreeMap<String, LockfileNode>,
20}
21
22#[derive(Debug, Serialize, Deserialize, PartialEq)]
23pub struct LockfileNode {
24    #[serde(rename = "type")]
25    pub node_type: NodeType,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub hash: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub graph: Option<String>,
30}
31
32impl Lockfile {
33    /// Convert an in-memory Graph to a Lockfile.
34    /// Nodes are stored in a BTreeMap (sorted by path).
35    /// Edges are not stored — edge changes are detected via content hashes.
36    pub fn from_graph(graph: &Graph) -> Self {
37        let mut nodes = BTreeMap::new();
38        for (path, node) in &graph.nodes {
39            nodes.insert(
40                path.clone(),
41                LockfileNode {
42                    node_type: node.node_type,
43                    hash: node.hash.clone(),
44                    graph: node.graph.clone(),
45                },
46            );
47        }
48
49        let interface = if graph.interface.is_empty() {
50            None
51        } else {
52            Some(LockfileInterface {
53                nodes: graph.interface.clone(),
54            })
55        };
56
57        Lockfile {
58            lockfile_version: 2,
59            interface,
60            nodes,
61        }
62    }
63
64    /// Serialize to deterministic TOML string.
65    pub fn to_toml(&self) -> Result<String> {
66        toml::to_string_pretty(self).context("failed to serialize lockfile")
67    }
68
69    /// Deserialize from TOML string.
70    pub fn from_toml(content: &str) -> Result<Self> {
71        let lockfile: Self = toml::from_str(content).context("failed to parse lockfile")?;
72        Ok(lockfile)
73    }
74}
75
76/// Read `drft.lock` from the given root directory.
77/// Returns `Ok(None)` if the file doesn't exist.
78pub fn read_lockfile(root: &Path) -> Result<Option<Lockfile>> {
79    let path = root.join("drft.lock");
80    if !path.exists() {
81        return Ok(None);
82    }
83    let content = std::fs::read_to_string(&path)
84        .with_context(|| format!("failed to read {}", path.display()))?;
85    let lockfile = Lockfile::from_toml(&content)?;
86    Ok(Some(lockfile))
87}
88
89/// Write `drft.lock` atomically using temp file + rename.
90pub fn write_lockfile(root: &Path, lockfile: &Lockfile) -> Result<()> {
91    let content = lockfile.to_toml()?;
92    let lock_path = root.join("drft.lock");
93    let tmp_path = root.join("drft.lock.tmp");
94
95    std::fs::write(&tmp_path, &content)
96        .with_context(|| format!("failed to write {}", tmp_path.display()))?;
97
98    std::fs::rename(&tmp_path, &lock_path).with_context(|| {
99        let _ = std::fs::remove_file(&tmp_path);
100        format!(
101            "failed to rename {} to {}",
102            tmp_path.display(),
103            lock_path.display()
104        )
105    })?;
106
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::graph::{Node, NodeType};
114    use tempfile::TempDir;
115
116    fn make_graph() -> Graph {
117        let mut g = Graph::new();
118        g.add_node(Node {
119            path: "index.md".into(),
120            node_type: NodeType::Source,
121            hash: Some("b3:aaa".into()),
122            graph: None,
123        });
124        g.add_node(Node {
125            path: "setup.md".into(),
126            node_type: NodeType::Source,
127            hash: Some("b3:bbb".into()),
128            graph: None,
129        });
130        g
131    }
132
133    #[test]
134    fn from_graph_produces_sorted_nodes() {
135        let lf = Lockfile::from_graph(&make_graph());
136        let keys: Vec<&String> = lf.nodes.keys().collect();
137        assert_eq!(keys, vec!["index.md", "setup.md"]);
138    }
139
140    #[test]
141    fn roundtrip_toml() {
142        let lf = Lockfile::from_graph(&make_graph());
143        let toml_str = lf.to_toml().unwrap();
144        let parsed = Lockfile::from_toml(&toml_str).unwrap();
145        assert_eq!(lf, parsed);
146    }
147
148    #[test]
149    fn deterministic_output() {
150        let lf = Lockfile::from_graph(&make_graph());
151        let a = lf.to_toml().unwrap();
152        let b = lf.to_toml().unwrap();
153        assert_eq!(a, b);
154    }
155
156    #[test]
157    fn write_and_read() {
158        let dir = TempDir::new().unwrap();
159        let lf = Lockfile::from_graph(&make_graph());
160        write_lockfile(dir.path(), &lf).unwrap();
161        let read_back = read_lockfile(dir.path()).unwrap().unwrap();
162        assert_eq!(lf, read_back);
163    }
164
165    #[test]
166    fn read_missing_returns_none() {
167        let dir = TempDir::new().unwrap();
168        assert!(read_lockfile(dir.path()).unwrap().is_none());
169    }
170
171    #[test]
172    fn no_edges_in_lockfile() {
173        let lf = Lockfile::from_graph(&make_graph());
174        let toml_str = lf.to_toml().unwrap();
175        assert!(
176            !toml_str.contains("[[edges]]"),
177            "lockfile v2 should not contain edges"
178        );
179    }
180
181    #[test]
182    fn stores_interface_when_present() {
183        let mut g = make_graph();
184        g.interface = vec!["index.md".to_string()];
185        let lf = Lockfile::from_graph(&g);
186        assert!(lf.interface.is_some());
187        assert_eq!(lf.interface.unwrap().nodes, vec!["index.md"]);
188    }
189
190    #[test]
191    fn no_interface_when_empty() {
192        let g = make_graph();
193        let lf = Lockfile::from_graph(&g);
194        assert!(lf.interface.is_none());
195    }
196
197    #[test]
198    fn parses_current_lockfile_version() {
199        let toml = "lockfile_version = 2\n";
200        let result = Lockfile::from_toml(toml);
201        assert!(result.is_ok());
202    }
203}