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