Skip to main content

agx_core/
annotations.rs

1//! Per-step annotations — the first persistent write-back feature.
2//!
3//! Notes live outside the session file (agx is read-only with respect to
4//! session data, always), in a sidecar JSON under `~/.agx/notes/`. Keyed
5//! by a FNV-1a hash of the canonical session path so moves-within-the-
6//! same-canonical-path keep their notes while renames start fresh (a
7//! deliberate trade-off — session UUID extraction varies per format and
8//! isn't available for all of them).
9//!
10//! File format (version 1):
11//! ```json
12//! {
13//!   "version": 1,
14//!   "path": "/absolute/path/to/session.jsonl",
15//!   "notes": {
16//!     "0": {"text": "...", "created_at_ms": 1704000000000, "updated_at_ms": 1704000000000},
17//!     "5": {...}
18//!   }
19//! }
20//! ```
21//! Key is the 0-based step index as a JSON-string (since JSON objects
22//! require string keys). `created_at_ms` never changes; `updated_at_ms`
23//! refreshes on every edit.
24//!
25//! Writes go through a temp-file + rename so a partial write never
26//! corrupts an existing notes file. Reads are fault-tolerant: a missing
27//! file or a malformed parse yields an empty `Annotations` so the TUI
28//! always has something to render against.
29
30use 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    /// Canonical absolute path of the session file these annotations
52    /// belong to. Recorded for reference / portability — the disk-side
53    /// filename is derived via `annotations_file_for`.
54    #[serde(default)]
55    pub path: String,
56    /// Step index (0-based, as a JSON-string per the format) → note.
57    /// `BTreeMap` keeps iteration order stable for the list overlay
58    /// in follow-up work.
59    #[serde(default)]
60    pub notes: BTreeMap<String, Note>,
61}
62
63fn default_version() -> u32 {
64    CURRENT_VERSION
65}
66
67impl Annotations {
68    /// Fresh, empty annotations bound to the given session path.
69    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    /// True when no notes are stored. Used by the export integrations
82    /// to skip emitting a "notes" section and by tests.
83    pub fn is_empty(&self) -> bool {
84        self.notes.is_empty()
85    }
86
87    /// Get the note for a step index, if any.
88    pub fn get(&self, step_idx: usize) -> Option<&Note> {
89        self.notes.get(&step_idx.to_string())
90    }
91
92    /// True when the given step index has an annotation.
93    pub fn has(&self, step_idx: usize) -> bool {
94        self.notes.contains_key(&step_idx.to_string())
95    }
96
97    /// Upsert a note. Empty / whitespace-only text deletes the entry
98    /// (the intuitive "clear the annotation" behavior). Returns `true`
99    /// when the set changed anything.
100    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    /// Iterate notes in numeric step-index order. `BTreeMap` iterates
129    /// string keys lexicographically, which would put "12" before "2";
130    /// we collect and re-sort by the parsed usize instead. Consumed by
131    /// the TUI `A` list overlay and by the export writers (md / html /
132    /// json) for their per-step note sections.
133    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    /// Load annotations for a session from disk. Returns an empty set
144    /// (not an error) when the file doesn't exist — the common case
145    /// for sessions the user hasn't annotated yet.
146    ///
147    /// A corrupted / malformed notes file also returns empty rather
148    /// than erroring, so one bad file doesn't prevent the TUI from
149    /// launching. A stderr warning is emitted so users know to look.
150    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                // Canonicalize the recorded path if it was a no-op
161                // before (e.g. first save happened before canonicalize
162                // succeeded).
163                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    /// Write to disk atomically: serialize to a sibling `*.tmp`, then
180    /// rename. `rename(2)` is atomic on the same filesystem, so partial
181    /// writes never corrupt an existing notes file.
182    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
202/// Resolve the annotations file path for a given session.
203///
204/// Scheme: `<agx_dir>/notes/<session_stem>-<hash8>.json` where
205/// `<hash8>` is the first 8 hex chars of FNV-1a-64 over the canonical
206/// session path. Human-readable stem + short unique tag → collisions
207/// are vanishingly unlikely in practice while filenames stay
208/// recognizable when users inspect the directory.
209pub 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
223/// Root directory for agx's persistent state. `AGX_HOME` overrides for
224/// tests; otherwise `~/.agx`. Returns an error when the HOME
225/// environment variable is unset (which is very unusual on the
226/// platforms we target, but we surface it explicitly rather than
227/// silently dropping writes).
228pub 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
246/// FNV-1a 64-bit. Deterministic (unlike `std::collections::hash_map::DefaultHasher`
247/// whose seed is process-random) so notes files don't change name
248/// across agx invocations. 5-line implementation, no new crate dep.
249fn 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    // Cargo runs tests in parallel, so mutating the process-wide
265    // `AGX_HOME` env var races across threads. Serialize access via
266    // a module-level mutex — each test holds the guard for its full
267    // lifetime (returned alongside the `TempDir` so both drop at
268    // end-of-scope together).
269    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        // created_at_ms stays the same across edits.
332        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        // Keep `home` alive so the TempDir isn't dropped mid-test.
379        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        // Format: <stem>-<8-hex>.json → stem + 1 dash + 8 chars + 5 chars
390        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        // The whole point of rolling our own FNV is determinism across
404        // process launches — std's hashmap hasher has a random seed.
405        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}