1use std::fs;
18use std::io::{BufRead, Write};
19use std::path::{Path, PathBuf};
20
21use crate::entry::GraphOp;
22
23pub struct OperationBuffer {
25 path: PathBuf,
26}
27
28impl OperationBuffer {
29 pub fn new(path: impl Into<PathBuf>) -> Self {
31 Self { path: path.into() }
32 }
33
34 pub fn append(&self, op: &GraphOp) -> Result<(), String> {
36 let json = serde_json::to_string(op).map_err(|e| format!("serialize: {e}"))?;
37 let mut file = fs::OpenOptions::new()
38 .create(true)
39 .append(true)
40 .open(&self.path)
41 .map_err(|e| format!("open {}: {e}", self.path.display()))?;
42 writeln!(file, "{json}").map_err(|e| format!("write: {e}"))?;
43 Ok(())
44 }
45
46 pub fn read_all(&self) -> Result<Vec<GraphOp>, String> {
48 if !self.path.exists() {
49 return Ok(vec![]);
50 }
51 let file =
52 fs::File::open(&self.path).map_err(|e| format!("open {}: {e}", self.path.display()))?;
53 let reader = std::io::BufReader::new(file);
54 let mut ops = Vec::new();
55 for (i, line) in reader.lines().enumerate() {
56 let line = line.map_err(|e| format!("read line {}: {e}", i + 1))?;
57 let trimmed = line.trim();
58 if trimmed.is_empty() {
59 continue;
60 }
61 let op: GraphOp =
62 serde_json::from_str(trimmed).map_err(|e| format!("parse line {}: {e}", i + 1))?;
63 ops.push(op);
64 }
65 Ok(ops)
66 }
67
68 pub fn len(&self) -> usize {
70 self.read_all().map(|ops| ops.len()).unwrap_or(0)
71 }
72
73 pub fn is_empty(&self) -> bool {
75 self.len() == 0
76 }
77
78 pub fn clear(&self) -> Result<(), String> {
80 if self.path.exists() {
81 fs::write(&self.path, b"").map_err(|e| format!("truncate: {e}"))?;
82 }
83 Ok(())
84 }
85
86 pub fn path(&self) -> &Path {
88 &self.path
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::collections::BTreeMap;
96
97 use crate::entry::Value;
98
99 fn tmp_path() -> PathBuf {
100 let dir = tempfile::tempdir().unwrap();
101 let path = dir.path().join("buffer.jsonl");
104 std::mem::forget(dir);
105 path
106 }
107
108 #[test]
109 fn empty_buffer_reads_empty() {
110 let buf = OperationBuffer::new(tmp_path());
111 assert!(buf.is_empty());
112 assert_eq!(buf.read_all().unwrap(), vec![]);
113 }
114
115 #[test]
116 fn append_and_read_roundtrip() {
117 let path = tmp_path();
118 let buf = OperationBuffer::new(&path);
119
120 let op1 = GraphOp::AddNode {
121 node_id: "n1".into(),
122 node_type: "server".into(),
123 subtype: None,
124 label: "Server 1".into(),
125 properties: BTreeMap::from([("ip".into(), Value::String("10.0.0.1".into()))]),
126 };
127 let op2 = GraphOp::UpdateProperty {
128 entity_id: "n1".into(),
129 key: "status".into(),
130 value: Value::String("active".into()),
131 };
132
133 buf.append(&op1).unwrap();
134 buf.append(&op2).unwrap();
135
136 let ops = buf.read_all().unwrap();
137 assert_eq!(ops.len(), 2);
138 assert_eq!(buf.len(), 2);
139
140 match &ops[0] {
142 GraphOp::AddNode { node_id, .. } => assert_eq!(node_id, "n1"),
143 _ => panic!("expected AddNode"),
144 }
145 match &ops[1] {
147 GraphOp::UpdateProperty { entity_id, key, .. } => {
148 assert_eq!(entity_id, "n1");
149 assert_eq!(key, "status");
150 }
151 _ => panic!("expected UpdateProperty"),
152 }
153 }
154
155 #[test]
156 fn clear_empties_buffer() {
157 let path = tmp_path();
158 let buf = OperationBuffer::new(&path);
159
160 buf.append(&GraphOp::RemoveNode {
161 node_id: "n1".into(),
162 })
163 .unwrap();
164 assert_eq!(buf.len(), 1);
165
166 buf.clear().unwrap();
167 assert!(buf.is_empty());
168 }
169
170 #[test]
171 fn nonexistent_file_is_empty() {
172 let buf = OperationBuffer::new("/tmp/silk_test_nonexistent_buffer.jsonl");
173 assert!(buf.is_empty());
174 assert_eq!(buf.read_all().unwrap(), vec![]);
175 }
176
177 #[test]
178 fn all_op_types_roundtrip() {
179 let path = tmp_path();
180 let buf = OperationBuffer::new(&path);
181
182 let ops = vec![
183 GraphOp::AddNode {
184 node_id: "n1".into(),
185 node_type: "entity".into(),
186 subtype: Some("server".into()),
187 label: "S".into(),
188 properties: BTreeMap::new(),
189 },
190 GraphOp::AddEdge {
191 edge_id: "e1".into(),
192 edge_type: "RUNS_ON".into(),
193 source_id: "n1".into(),
194 target_id: "n2".into(),
195 properties: BTreeMap::new(),
196 },
197 GraphOp::UpdateProperty {
198 entity_id: "n1".into(),
199 key: "status".into(),
200 value: Value::String("active".into()),
201 },
202 GraphOp::RemoveNode {
203 node_id: "n1".into(),
204 },
205 GraphOp::RemoveEdge {
206 edge_id: "e1".into(),
207 },
208 ];
209
210 for op in &ops {
211 buf.append(op).unwrap();
212 }
213
214 let read = buf.read_all().unwrap();
215 assert_eq!(read.len(), 5);
216 }
217
218 #[test]
219 fn multiple_appends_are_additive() {
220 let path = tmp_path();
221 let buf = OperationBuffer::new(&path);
222
223 buf.append(&GraphOp::RemoveNode {
224 node_id: "a".into(),
225 })
226 .unwrap();
227
228 let buf2 = OperationBuffer::new(&path);
230 buf2.append(&GraphOp::RemoveNode {
231 node_id: "b".into(),
232 })
233 .unwrap();
234
235 assert_eq!(buf2.len(), 2);
236 }
237}