1use anyhow::{Context, Result};
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeMap;
33use std::fs;
34use std::io::Write;
35use std::path::{Path, PathBuf};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38const CURRENT_VERSION: u32 = 1;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Note {
42 pub text: String,
43 pub created_at_ms: u64,
44 pub updated_at_ms: u64,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct Annotations {
49 #[serde(default = "default_version")]
50 pub version: u32,
51 #[serde(default)]
55 pub path: String,
56 #[serde(default)]
60 pub notes: BTreeMap<String, Note>,
61}
62
63fn default_version() -> u32 {
64 CURRENT_VERSION
65}
66
67impl Annotations {
68 pub fn new(session_path: &Path) -> Self {
70 Annotations {
71 version: CURRENT_VERSION,
72 path: session_path
73 .canonicalize()
74 .unwrap_or_else(|_| session_path.to_path_buf())
75 .display()
76 .to_string(),
77 notes: BTreeMap::new(),
78 }
79 }
80
81 pub fn is_empty(&self) -> bool {
84 self.notes.is_empty()
85 }
86
87 pub fn get(&self, step_idx: usize) -> Option<&Note> {
89 self.notes.get(&step_idx.to_string())
90 }
91
92 pub fn has(&self, step_idx: usize) -> bool {
94 self.notes.contains_key(&step_idx.to_string())
95 }
96
97 pub fn set(&mut self, step_idx: usize, text: &str) -> bool {
101 let key = step_idx.to_string();
102 let trimmed = text.trim();
103 if trimmed.is_empty() {
104 return self.notes.remove(&key).is_some();
105 }
106 let now = now_ms();
107 match self.notes.get_mut(&key) {
108 Some(existing) if existing.text == trimmed => false,
109 Some(existing) => {
110 existing.text = trimmed.to_string();
111 existing.updated_at_ms = now;
112 true
113 }
114 None => {
115 self.notes.insert(
116 key,
117 Note {
118 text: trimmed.to_string(),
119 created_at_ms: now,
120 updated_at_ms: now,
121 },
122 );
123 true
124 }
125 }
126 }
127
128 pub fn iter(&self) -> impl Iterator<Item = (usize, &Note)> {
134 let mut items: Vec<(usize, &Note)> = self
135 .notes
136 .iter()
137 .filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
138 .collect();
139 items.sort_by_key(|(idx, _)| *idx);
140 items.into_iter()
141 }
142
143 pub fn load_for(session_path: &Path) -> Self {
151 let file = match annotations_file_for(session_path) {
152 Ok(p) => p,
153 Err(_) => return Self::new(session_path),
154 };
155 let Ok(contents) = fs::read_to_string(&file) else {
156 return Self::new(session_path);
157 };
158 match serde_json::from_str::<Annotations>(&contents) {
159 Ok(mut a) => {
160 if a.path.is_empty() {
164 a.path = session_path.display().to_string();
165 }
166 a
167 }
168 Err(e) => {
169 eprintln!(
170 "agx: ignoring malformed annotations file {}: {}",
171 file.display(),
172 e
173 );
174 Self::new(session_path)
175 }
176 }
177 }
178
179 pub fn save_for(&self, session_path: &Path) -> Result<PathBuf> {
183 let dest = annotations_file_for(session_path)?;
184 if let Some(parent) = dest.parent() {
185 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
186 }
187 let json = serde_json::to_string_pretty(self)?;
188 let tmp = dest.with_extension("json.tmp");
189 {
190 let mut f = fs::File::create(&tmp)
191 .with_context(|| format!("creating temp file {}", tmp.display()))?;
192 f.write_all(json.as_bytes())
193 .with_context(|| format!("writing {}", tmp.display()))?;
194 f.sync_all().ok();
195 }
196 fs::rename(&tmp, &dest)
197 .with_context(|| format!("renaming {} → {}", tmp.display(), dest.display()))?;
198 Ok(dest)
199 }
200}
201
202pub fn annotations_file_for(session_path: &Path) -> Result<PathBuf> {
210 let canonical = session_path
211 .canonicalize()
212 .unwrap_or_else(|_| session_path.to_path_buf());
213 let key = canonical.display().to_string();
214 let hash = fnv1a_64(key.as_bytes());
215 let stem = canonical
216 .file_stem()
217 .and_then(|s| s.to_str())
218 .unwrap_or("session");
219 let filename = format!("{stem}-{:08x}.json", hash as u32);
220 Ok(agx_home_dir()?.join("notes").join(filename))
221}
222
223pub fn agx_home_dir() -> Result<PathBuf> {
229 if let Some(override_dir) = std::env::var_os("AGX_HOME") {
230 return Ok(PathBuf::from(override_dir));
231 }
232 let home = std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("$HOME is not set"))?;
233 Ok(PathBuf::from(home).join(".agx"))
234}
235
236fn now_ms() -> u64 {
237 SystemTime::now()
238 .duration_since(UNIX_EPOCH)
239 .map(|d| {
240 let millis = d.as_millis();
241 u64::try_from(millis).unwrap_or(u64::MAX)
242 })
243 .unwrap_or(0)
244}
245
246fn fnv1a_64(bytes: &[u8]) -> u64 {
250 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
251 for b in bytes {
252 hash ^= u64::from(*b);
253 hash = hash.wrapping_mul(0x100_0000_01b3);
254 }
255 hash
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use std::sync::{Mutex, MutexGuard};
262 use tempfile::TempDir;
263
264 static ENV_LOCK: Mutex<()> = Mutex::new(());
270
271 fn test_home() -> (TempDir, MutexGuard<'static, ()>) {
272 let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
273 let tmp = TempDir::new().unwrap();
274 unsafe {
275 std::env::set_var("AGX_HOME", tmp.path());
276 }
277 (tmp, guard)
278 }
279
280 #[test]
281 fn new_is_empty_and_bound_to_path() {
282 let _home = test_home();
283 let a = Annotations::new(Path::new("/tmp/foo.jsonl"));
284 assert!(a.is_empty());
285 assert_eq!(a.version, CURRENT_VERSION);
286 assert!(!a.path.is_empty());
287 }
288
289 #[test]
290 fn set_inserts_and_trims_whitespace() {
291 let _home = test_home();
292 let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
293 let changed = a.set(0, " hello ");
294 assert!(changed);
295 let note = a.get(0).unwrap();
296 assert_eq!(note.text, "hello");
297 assert!(note.created_at_ms > 0);
298 assert_eq!(note.created_at_ms, note.updated_at_ms);
299 }
300
301 #[test]
302 fn set_with_empty_text_deletes() {
303 let _home = test_home();
304 let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
305 a.set(3, "real note");
306 assert!(a.has(3));
307 let changed = a.set(3, " ");
308 assert!(changed);
309 assert!(!a.has(3));
310 }
311
312 #[test]
313 fn set_to_identical_text_is_a_noop() {
314 let _home = test_home();
315 let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
316 a.set(1, "same");
317 let changed = a.set(1, "same");
318 assert!(!changed);
319 }
320
321 #[test]
322 fn set_updates_updated_at() {
323 let _home = test_home();
324 let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
325 a.set(0, "first");
326 let before = a.get(0).unwrap().updated_at_ms;
327 std::thread::sleep(std::time::Duration::from_millis(2));
328 a.set(0, "second");
329 let after = a.get(0).unwrap().updated_at_ms;
330 assert!(after > before);
331 assert_eq!(a.get(0).unwrap().created_at_ms, before);
333 }
334
335 #[test]
336 fn iter_yields_notes_in_step_index_order() {
337 let _home = test_home();
338 let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
339 a.set(5, "five");
340 a.set(1, "one");
341 a.set(12, "twelve");
342 let got: Vec<usize> = a.iter().map(|(idx, _)| idx).collect();
343 assert_eq!(got, vec![1, 5, 12]);
344 }
345
346 #[test]
347 fn save_then_load_round_trip() {
348 let _home = test_home();
349 let session = Path::new("/tmp/session-foo.jsonl");
350 let mut a = Annotations::new(session);
351 a.set(2, "this went wrong");
352 a.set(7, "revisit this edit");
353 let written = a.save_for(session).unwrap();
354 assert!(written.exists(), "expected saved notes file to exist");
355
356 let loaded = Annotations::load_for(session);
357 assert_eq!(loaded.notes.len(), 2);
358 assert_eq!(loaded.get(2).unwrap().text, "this went wrong");
359 assert_eq!(loaded.get(7).unwrap().text, "revisit this edit");
360 }
361
362 #[test]
363 fn load_for_nonexistent_file_returns_empty_without_error() {
364 let _home = test_home();
365 let a = Annotations::load_for(Path::new("/tmp/nonexistent.jsonl"));
366 assert!(a.is_empty());
367 }
368
369 #[test]
370 fn load_for_malformed_file_returns_empty_without_panic() {
371 let home = test_home();
372 let session = Path::new("/tmp/session-mal.jsonl");
373 let target = annotations_file_for(session).unwrap();
374 fs::create_dir_all(target.parent().unwrap()).unwrap();
375 fs::write(&target, "{not valid json").unwrap();
376 let a = Annotations::load_for(session);
377 assert!(a.is_empty());
378 let _ = home;
380 }
381
382 #[test]
383 fn annotations_file_for_produces_readable_stem_plus_hash() {
384 let _home = test_home();
385 let path = annotations_file_for(Path::new("/tmp/abcd.jsonl")).unwrap();
386 let name = path.file_name().unwrap().to_str().unwrap();
387 assert!(name.starts_with("abcd-"), "unexpected filename: {name}");
388 assert!(name.ends_with(".json"), "unexpected filename: {name}");
389 assert_eq!(name.len(), "abcd".len() + 1 + 8 + ".json".len());
391 }
392
393 #[test]
394 fn annotations_file_for_different_paths_differ_in_hash_suffix() {
395 let _home = test_home();
396 let a = annotations_file_for(Path::new("/tmp/a/session.jsonl")).unwrap();
397 let b = annotations_file_for(Path::new("/tmp/b/session.jsonl")).unwrap();
398 assert_ne!(a.file_name(), b.file_name());
399 }
400
401 #[test]
402 fn fnv1a_64_is_deterministic() {
403 let h1 = fnv1a_64(b"/tmp/foo.jsonl");
406 let h2 = fnv1a_64(b"/tmp/foo.jsonl");
407 assert_eq!(h1, h2);
408 assert_ne!(h1, fnv1a_64(b"/tmp/bar.jsonl"));
409 }
410}