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