use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const CURRENT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
pub text: String,
pub created_at_ms: u64,
pub updated_at_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Annotations {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub path: String,
#[serde(default)]
pub notes: BTreeMap<String, Note>,
}
fn default_version() -> u32 {
CURRENT_VERSION
}
impl Annotations {
pub fn new(session_path: &Path) -> Self {
Annotations {
version: CURRENT_VERSION,
path: session_path
.canonicalize()
.unwrap_or_else(|_| session_path.to_path_buf())
.display()
.to_string(),
notes: BTreeMap::new(),
}
}
pub fn is_empty(&self) -> bool {
self.notes.is_empty()
}
pub fn get(&self, step_idx: usize) -> Option<&Note> {
self.notes.get(&step_idx.to_string())
}
pub fn has(&self, step_idx: usize) -> bool {
self.notes.contains_key(&step_idx.to_string())
}
pub fn set(&mut self, step_idx: usize, text: &str) -> bool {
let key = step_idx.to_string();
let trimmed = text.trim();
if trimmed.is_empty() {
return self.notes.remove(&key).is_some();
}
let now = now_ms();
match self.notes.get_mut(&key) {
Some(existing) if existing.text == trimmed => false,
Some(existing) => {
existing.text = trimmed.to_string();
existing.updated_at_ms = now;
true
}
None => {
self.notes.insert(
key,
Note {
text: trimmed.to_string(),
created_at_ms: now,
updated_at_ms: now,
},
);
true
}
}
}
pub fn iter(&self) -> impl Iterator<Item = (usize, &Note)> {
let mut items: Vec<(usize, &Note)> = self
.notes
.iter()
.filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
.collect();
items.sort_by_key(|(idx, _)| *idx);
items.into_iter()
}
pub fn load_for(session_path: &Path) -> Self {
let file = match annotations_file_for(session_path) {
Ok(p) => p,
Err(_) => return Self::new(session_path),
};
let Ok(contents) = fs::read_to_string(&file) else {
return Self::new(session_path);
};
match serde_json::from_str::<Annotations>(&contents) {
Ok(mut a) => {
if a.path.is_empty() {
a.path = session_path.display().to_string();
}
a
}
Err(e) => {
eprintln!(
"agx: ignoring malformed annotations file {}: {}",
file.display(),
e
);
Self::new(session_path)
}
}
}
pub fn save_for(&self, session_path: &Path) -> Result<PathBuf> {
let dest = annotations_file_for(session_path)?;
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(self)?;
let tmp = dest.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("creating temp file {}", tmp.display()))?;
f.write_all(json.as_bytes())
.with_context(|| format!("writing {}", tmp.display()))?;
f.sync_all().ok();
}
fs::rename(&tmp, &dest)
.with_context(|| format!("renaming {} → {}", tmp.display(), dest.display()))?;
Ok(dest)
}
}
pub fn annotations_file_for(session_path: &Path) -> Result<PathBuf> {
let canonical = session_path
.canonicalize()
.unwrap_or_else(|_| session_path.to_path_buf());
let key = canonical.display().to_string();
let hash = fnv1a_64(key.as_bytes());
let stem = canonical
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("session");
let filename = format!("{stem}-{:08x}.json", hash as u32);
Ok(agx_home_dir()?.join("notes").join(filename))
}
pub fn agx_home_dir() -> Result<PathBuf> {
if let Some(override_dir) = std::env::var_os("AGX_HOME") {
return Ok(PathBuf::from(override_dir));
}
let home = std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("$HOME is not set"))?;
Ok(PathBuf::from(home).join(".agx"))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| {
let millis = d.as_millis();
u64::try_from(millis).unwrap_or(u64::MAX)
})
.unwrap_or(0)
}
fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn test_home() -> (TempDir, MutexGuard<'static, ()>) {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var("AGX_HOME", tmp.path());
}
(tmp, guard)
}
#[test]
fn new_is_empty_and_bound_to_path() {
let _home = test_home();
let a = Annotations::new(Path::new("/tmp/foo.jsonl"));
assert!(a.is_empty());
assert_eq!(a.version, CURRENT_VERSION);
assert!(!a.path.is_empty());
}
#[test]
fn set_inserts_and_trims_whitespace() {
let _home = test_home();
let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
let changed = a.set(0, " hello ");
assert!(changed);
let note = a.get(0).unwrap();
assert_eq!(note.text, "hello");
assert!(note.created_at_ms > 0);
assert_eq!(note.created_at_ms, note.updated_at_ms);
}
#[test]
fn set_with_empty_text_deletes() {
let _home = test_home();
let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
a.set(3, "real note");
assert!(a.has(3));
let changed = a.set(3, " ");
assert!(changed);
assert!(!a.has(3));
}
#[test]
fn set_to_identical_text_is_a_noop() {
let _home = test_home();
let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
a.set(1, "same");
let changed = a.set(1, "same");
assert!(!changed);
}
#[test]
fn set_updates_updated_at() {
let _home = test_home();
let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
a.set(0, "first");
let before = a.get(0).unwrap().updated_at_ms;
std::thread::sleep(std::time::Duration::from_millis(2));
a.set(0, "second");
let after = a.get(0).unwrap().updated_at_ms;
assert!(after > before);
assert_eq!(a.get(0).unwrap().created_at_ms, before);
}
#[test]
fn iter_yields_notes_in_step_index_order() {
let _home = test_home();
let mut a = Annotations::new(Path::new("/tmp/foo.jsonl"));
a.set(5, "five");
a.set(1, "one");
a.set(12, "twelve");
let got: Vec<usize> = a.iter().map(|(idx, _)| idx).collect();
assert_eq!(got, vec![1, 5, 12]);
}
#[test]
fn save_then_load_round_trip() {
let _home = test_home();
let session = Path::new("/tmp/session-foo.jsonl");
let mut a = Annotations::new(session);
a.set(2, "this went wrong");
a.set(7, "revisit this edit");
let written = a.save_for(session).unwrap();
assert!(written.exists(), "expected saved notes file to exist");
let loaded = Annotations::load_for(session);
assert_eq!(loaded.notes.len(), 2);
assert_eq!(loaded.get(2).unwrap().text, "this went wrong");
assert_eq!(loaded.get(7).unwrap().text, "revisit this edit");
}
#[test]
fn load_for_nonexistent_file_returns_empty_without_error() {
let _home = test_home();
let a = Annotations::load_for(Path::new("/tmp/nonexistent.jsonl"));
assert!(a.is_empty());
}
#[test]
fn load_for_malformed_file_returns_empty_without_panic() {
let home = test_home();
let session = Path::new("/tmp/session-mal.jsonl");
let target = annotations_file_for(session).unwrap();
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "{not valid json").unwrap();
let a = Annotations::load_for(session);
assert!(a.is_empty());
let _ = home;
}
#[test]
fn annotations_file_for_produces_readable_stem_plus_hash() {
let _home = test_home();
let path = annotations_file_for(Path::new("/tmp/abcd.jsonl")).unwrap();
let name = path.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("abcd-"), "unexpected filename: {name}");
assert!(name.ends_with(".json"), "unexpected filename: {name}");
assert_eq!(name.len(), "abcd".len() + 1 + 8 + ".json".len());
}
#[test]
fn annotations_file_for_different_paths_differ_in_hash_suffix() {
let _home = test_home();
let a = annotations_file_for(Path::new("/tmp/a/session.jsonl")).unwrap();
let b = annotations_file_for(Path::new("/tmp/b/session.jsonl")).unwrap();
assert_ne!(a.file_name(), b.file_name());
}
#[test]
fn fnv1a_64_is_deterministic() {
let h1 = fnv1a_64(b"/tmp/foo.jsonl");
let h2 = fnv1a_64(b"/tmp/foo.jsonl");
assert_eq!(h1, h2);
assert_ne!(h1, fnv1a_64(b"/tmp/bar.jsonl"));
}
}