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 std::collections::HashMap;
115    use tempfile::TempDir;
116
117    fn make_graph() -> Graph {
118        let mut g = Graph::new();
119        g.add_node(Node {
120            path: "index.md".into(),
121            node_type: NodeType::File,
122            hash: Some("b3:aaa".into()),
123            graph: None,
124            metadata: HashMap::new(),
125        });
126        g.add_node(Node {
127            path: "setup.md".into(),
128            node_type: NodeType::File,
129            hash: Some("b3:bbb".into()),
130            graph: None,
131            metadata: HashMap::new(),
132        });
133        g
134    }
135
136    #[test]
137    fn from_graph_produces_sorted_nodes() {
138        let lf = Lockfile::from_graph(&make_graph());
139        let keys: Vec<&String> = lf.nodes.keys().collect();
140        assert_eq!(keys, vec!["index.md", "setup.md"]);
141    }
142
143    #[test]
144    fn roundtrip_toml() {
145        let lf = Lockfile::from_graph(&make_graph());
146        let toml_str = lf.to_toml().unwrap();
147        let parsed = Lockfile::from_toml(&toml_str).unwrap();
148        assert_eq!(lf, parsed);
149    }
150
151    #[test]
152    fn deterministic_output() {
153        let lf = Lockfile::from_graph(&make_graph());
154        let a = lf.to_toml().unwrap();
155        let b = lf.to_toml().unwrap();
156        assert_eq!(a, b);
157    }
158
159    #[test]
160    fn write_and_read() {
161        let dir = TempDir::new().unwrap();
162        let lf = Lockfile::from_graph(&make_graph());
163        write_lockfile(dir.path(), &lf).unwrap();
164        let read_back = read_lockfile(dir.path()).unwrap().unwrap();
165        assert_eq!(lf, read_back);
166    }
167
168    #[test]
169    fn read_missing_returns_none() {
170        let dir = TempDir::new().unwrap();
171        assert!(read_lockfile(dir.path()).unwrap().is_none());
172    }
173
174    #[test]
175    fn no_edges_in_lockfile() {
176        let lf = Lockfile::from_graph(&make_graph());
177        let toml_str = lf.to_toml().unwrap();
178        assert!(
179            !toml_str.contains("[[edges]]"),
180            "lockfile v2 should not contain edges"
181        );
182    }
183
184    #[test]
185    fn stores_interface_when_present() {
186        let mut g = make_graph();
187        g.interface = vec!["index.md".to_string()];
188        let lf = Lockfile::from_graph(&g);
189        assert!(lf.interface.is_some());
190        assert_eq!(lf.interface.unwrap().nodes, vec!["index.md"]);
191    }
192
193    #[test]
194    fn no_interface_when_empty() {
195        let g = make_graph();
196        let lf = Lockfile::from_graph(&g);
197        assert!(lf.interface.is_none());
198    }
199
200    #[test]
201    fn parses_current_lockfile_version() {
202        let toml = "lockfile_version = 2\n";
203        let result = Lockfile::from_toml(toml);
204        assert!(result.is_ok());
205    }
206}