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 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}