1use std::fs;
24use std::io::Write;
25use std::path::Path;
26
27use crate::timeline::now_epoch_ms;
28
29const MAGIC: &[u8; 4] = b"OPL1";
30const ENTRY_HEADER: usize = 8 + 8 + 1 + 1 + 4; pub const MAX_TEXT_LEN: usize = 1024;
32
33pub const STATUS_OPEN: u8 = 0;
34pub const STATUS_RESOLVED: u8 = 1;
35pub const STATUS_ARCHIVED: u8 = 2;
36
37#[derive(Clone, Debug)]
38pub struct OpenLoopEntry {
39 pub id: u64,
40 pub ts_ms: u64,
41 pub importance: u8,
42 pub status: u8,
43 pub text: String,
44}
45
46pub fn next_id(dir: &Path) -> u64 {
49 let p = dir.join("open_loops.idx");
50 let raw = fs::read_to_string(&p).unwrap_or_default();
51 raw.trim().parse::<u64>().map(|n| n + 1).unwrap_or(1)
52}
53
54fn persist_next_id(dir: &Path, id: u64) -> Result<(), String> {
55 let p = dir.join("open_loops.idx");
56 let mut f = fs::OpenOptions::new()
57 .create(true)
58 .write(true)
59 .truncate(true)
60 .open(&p)
61 .map_err(|e| format!("open open_loops.idx: {}", e))?;
62 f.write_all(format!("{}\n", id).as_bytes())
63 .map_err(|e| format!("write open_loops.idx: {}", e))?;
64 Ok(())
65}
66
67pub fn append_open(dir: &Path, text: &str, importance: u8) -> Result<u64, String> {
70 let id = next_id(dir);
71 let entry = OpenLoopEntry {
72 id,
73 ts_ms: now_epoch_ms(),
74 importance,
75 status: STATUS_OPEN,
76 text: text.to_string(),
77 };
78 write_entry(&dir.join("open_loops.bin"), &entry)?;
79 persist_next_id(dir, id)?;
80 Ok(id)
81}
82
83pub fn resolve(dir: &Path, id: u64) -> Result<bool, String> {
86 let latest = read_latest(&dir.join("open_loops.bin"));
87 if let Some(entry) = latest.iter().find(|e| e.id == id) {
88 if entry.status == STATUS_RESOLVED {
89 return Ok(false);
90 }
91 let resolved = OpenLoopEntry {
92 id,
93 ts_ms: now_epoch_ms(),
94 importance: entry.importance,
95 status: STATUS_RESOLVED,
96 text: entry.text.clone(),
97 };
98 write_entry(&dir.join("open_loops.bin"), &resolved)?;
99 let timeline_entry = crate::timeline::TimelineEntry {
102 ts_ms: resolved.ts_ms,
103 layer_id: crate::layer_to_id("long_term"),
104 importance: resolved.importance,
105 depth: 4,
106 status: crate::timeline::STATUS_RESOLVED,
107 text: format!("[loop#{} resolved] {}", id, entry.text),
108 };
109 let _ = crate::timeline::append_entry(&dir.join("timeline.bin"), &timeline_entry);
110 Ok(true)
111 } else {
112 Ok(false)
113 }
114}
115
116pub fn archive(dir: &Path, id: u64) -> Result<bool, String> {
118 let latest = read_latest(&dir.join("open_loops.bin"));
119 if let Some(entry) = latest.iter().find(|e| e.id == id) {
120 if entry.status == STATUS_ARCHIVED {
121 return Ok(false);
122 }
123 let archived = OpenLoopEntry {
124 id,
125 ts_ms: now_epoch_ms(),
126 importance: entry.importance,
127 status: STATUS_ARCHIVED,
128 text: entry.text.clone(),
129 };
130 write_entry(&dir.join("open_loops.bin"), &archived)?;
131 Ok(true)
132 } else {
133 Ok(false)
134 }
135}
136
137pub fn read_all(path: &Path) -> Vec<OpenLoopEntry> {
140 if !path.exists() {
141 return Vec::new();
142 }
143 let data = match fs::read(path) {
144 Ok(d) => d,
145 Err(_) => return Vec::new(),
146 };
147 if data.len() < 4 || &data[0..4] != MAGIC {
148 return Vec::new();
149 }
150
151 let mut entries = Vec::new();
152 let mut pos = 4;
153 while pos + ENTRY_HEADER <= data.len() {
154 let id = u64::from_le_bytes([
155 data[pos],
156 data[pos + 1],
157 data[pos + 2],
158 data[pos + 3],
159 data[pos + 4],
160 data[pos + 5],
161 data[pos + 6],
162 data[pos + 7],
163 ]);
164 let ts_ms = u64::from_le_bytes([
165 data[pos + 8],
166 data[pos + 9],
167 data[pos + 10],
168 data[pos + 11],
169 data[pos + 12],
170 data[pos + 13],
171 data[pos + 14],
172 data[pos + 15],
173 ]);
174 let importance = data[pos + 16];
175 let status = data[pos + 17];
176 let len = u32::from_le_bytes([
177 data[pos + 18],
178 data[pos + 19],
179 data[pos + 20],
180 data[pos + 21],
181 ]) as usize;
182 pos += ENTRY_HEADER;
183 if pos + len > data.len() {
184 break;
185 }
186 let text = String::from_utf8_lossy(&data[pos..pos + len]).to_string();
187 pos += len;
188
189 entries.push(OpenLoopEntry {
190 id,
191 ts_ms,
192 importance,
193 status,
194 text,
195 });
196 }
197 entries
198}
199
200pub fn read_latest(path: &Path) -> Vec<OpenLoopEntry> {
202 let mut by_id: std::collections::HashMap<u64, OpenLoopEntry> = std::collections::HashMap::new();
203 for e in read_all(path) {
204 by_id.insert(e.id, e);
206 }
207 let mut out: Vec<OpenLoopEntry> = by_id.into_values().collect();
208 out.sort_by_key(|e| e.id);
209 out
210}
211
212pub fn read_open(path: &Path) -> Vec<OpenLoopEntry> {
215 let mut v: Vec<OpenLoopEntry> = read_latest(path)
216 .into_iter()
217 .filter(|e| e.status == STATUS_OPEN)
218 .collect();
219 v.sort_by(|a, b| b.importance.cmp(&a.importance).then(b.ts_ms.cmp(&a.ts_ms)));
220 v
221}
222
223fn write_entry(path: &Path, entry: &OpenLoopEntry) -> Result<(), String> {
224 let needs_magic = !path.exists() || fs::metadata(path).map(|m| m.len() == 0).unwrap_or(true);
225
226 let mut file = fs::OpenOptions::new()
227 .create(true)
228 .append(true)
229 .open(path)
230 .map_err(|e| format!("open open_loops: {}", e))?;
231
232 if needs_magic {
233 file.write_all(MAGIC)
234 .map_err(|e| format!("write magic: {}", e))?;
235 }
236
237 let text_bytes = entry.text.as_bytes();
238 let len = text_bytes.len().min(MAX_TEXT_LEN);
239
240 file.write_all(&entry.id.to_le_bytes())
241 .map_err(|e| format!("write id: {}", e))?;
242 file.write_all(&entry.ts_ms.to_le_bytes())
243 .map_err(|e| format!("write ts: {}", e))?;
244 file.write_all(&[entry.importance])
245 .map_err(|e| format!("write imp: {}", e))?;
246 file.write_all(&[entry.status])
247 .map_err(|e| format!("write status: {}", e))?;
248 file.write_all(&(len as u32).to_le_bytes())
249 .map_err(|e| format!("write len: {}", e))?;
250 file.write_all(&text_bytes[..len])
251 .map_err(|e| format!("write text: {}", e))?;
252 Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_append_and_read_open() {
261 let dir = tempfile::tempdir().unwrap();
262 let id1 = append_open(dir.path(), "task A", 7).unwrap();
263 let id2 = append_open(dir.path(), "task B", 9).unwrap();
264 assert_eq!(id1, 1);
265 assert_eq!(id2, 2);
266
267 let open = read_open(&dir.path().join("open_loops.bin"));
268 assert_eq!(open.len(), 2);
269 assert_eq!(open[0].text, "task B");
271 assert_eq!(open[1].text, "task A");
272 }
273
274 #[test]
275 fn test_resolve_hides_from_open() {
276 let dir = tempfile::tempdir().unwrap();
277 let id = append_open(dir.path(), "doomed task", 5).unwrap();
278 let changed = resolve(dir.path(), id).unwrap();
279 assert!(changed);
280
281 let open = read_open(&dir.path().join("open_loops.bin"));
282 assert!(open.is_empty());
283
284 let changed2 = resolve(dir.path(), id).unwrap();
286 assert!(!changed2);
287 }
288
289 #[test]
290 fn test_latest_status_per_id() {
291 let dir = tempfile::tempdir().unwrap();
292 let id = append_open(dir.path(), "test", 5).unwrap();
293 resolve(dir.path(), id).unwrap();
294
295 let latest = read_latest(&dir.path().join("open_loops.bin"));
296 assert_eq!(latest.len(), 1);
297 assert_eq!(latest[0].id, id);
298 assert_eq!(latest[0].status, STATUS_RESOLVED);
299
300 let raw = read_all(&dir.path().join("open_loops.bin"));
302 assert_eq!(raw.len(), 2);
303 }
304
305 #[test]
306 fn test_resolve_unknown_id_returns_false() {
307 let dir = tempfile::tempdir().unwrap();
308 let changed = resolve(dir.path(), 9999).unwrap();
309 assert!(!changed);
310 }
311}