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
22fn 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 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 pub fn to_toml(&self) -> Result<String> {
71 toml::to_string_pretty(self).context("failed to serialize lockfile")
72 }
73
74 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
81pub 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
94pub 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}