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 included: false,
132 });
133 g.add_node(Node {
134 path: "setup.md".into(),
135 node_type: NodeType::File,
136 hash: Some("b3:bbb".into()),
137 graph: None,
138 is_graph: false,
139 metadata: HashMap::new(),
140 included: false,
141 });
142 g
143 }
144
145 #[test]
146 fn from_graph_produces_sorted_nodes() {
147 let lf = Lockfile::from_graph(&make_graph());
148 let keys: Vec<&String> = lf.nodes.keys().collect();
149 assert_eq!(keys, vec!["index.md", "setup.md"]);
150 }
151
152 #[test]
153 fn roundtrip_toml() {
154 let lf = Lockfile::from_graph(&make_graph());
155 let toml_str = lf.to_toml().unwrap();
156 let parsed = Lockfile::from_toml(&toml_str).unwrap();
157 assert_eq!(lf, parsed);
158 }
159
160 #[test]
161 fn deterministic_output() {
162 let lf = Lockfile::from_graph(&make_graph());
163 let a = lf.to_toml().unwrap();
164 let b = lf.to_toml().unwrap();
165 assert_eq!(a, b);
166 }
167
168 #[test]
169 fn write_and_read() {
170 let dir = TempDir::new().unwrap();
171 let lf = Lockfile::from_graph(&make_graph());
172 write_lockfile(dir.path(), &lf).unwrap();
173 let read_back = read_lockfile(dir.path()).unwrap().unwrap();
174 assert_eq!(lf, read_back);
175 }
176
177 #[test]
178 fn read_missing_returns_none() {
179 let dir = TempDir::new().unwrap();
180 assert!(read_lockfile(dir.path()).unwrap().is_none());
181 }
182
183 #[test]
184 fn no_edges_in_lockfile() {
185 let lf = Lockfile::from_graph(&make_graph());
186 let toml_str = lf.to_toml().unwrap();
187 assert!(
188 !toml_str.contains("[[edges]]"),
189 "lockfile v2 should not contain edges"
190 );
191 }
192
193 #[test]
194 fn stores_interface_when_present() {
195 let mut g = make_graph();
196 g.interface = vec!["index.md".to_string()];
197 let lf = Lockfile::from_graph(&g);
198 assert!(lf.interface.is_some());
199 assert_eq!(lf.interface.unwrap().files, vec!["index.md"]);
200 }
201
202 #[test]
203 fn no_interface_when_empty() {
204 let g = make_graph();
205 let lf = Lockfile::from_graph(&g);
206 assert!(lf.interface.is_none());
207 }
208
209 #[test]
210 fn parses_current_lockfile_version() {
211 let toml = "lockfile_version = 2\n";
212 let result = Lockfile::from_toml(toml);
213 assert!(result.is_ok());
214 }
215}