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 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 pub fn to_toml(&self) -> Result<String> {
66 toml::to_string_pretty(self).context("failed to serialize lockfile")
67 }
68
69 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
76pub 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
89pub 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}