use anyhow::{Context, Result};
use fs2::FileExt;
use sha2::{Digest, Sha256};
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
const SNAP_DIR: &str = ".agent-doc/snapshots";
const LOCK_DIR: &str = ".agent-doc/locks";
const PENDING_DIR: &str = ".agent-doc/pending";
const CRDT_DIR: &str = ".agent-doc/crdt";
pub fn doc_hash(doc: &Path) -> Result<String> {
let canonical = doc.canonicalize()?;
Ok(hash_path_str(&canonical.to_string_lossy()))
}
pub fn doc_hash_from_str(absolute_path: &str) -> String {
hash_path_str(absolute_path)
}
fn hash_path_str(path: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(path.as_bytes());
hex::encode(hasher.finalize())
}
pub fn lock_path_for(doc: &Path) -> Result<PathBuf> {
let hash = doc_hash(doc)?;
let canonical = doc.canonicalize()?;
let project_root = find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
Ok(project_root.join(LOCK_DIR).join(format!("{}.lock", hash)))
}
pub fn pending_path_for(doc: &Path) -> Result<PathBuf> {
let hash = doc_hash(doc)?;
let canonical = doc.canonicalize()?;
let project_root = find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
Ok(project_root.join(PENDING_DIR).join(format!("{}.md", hash)))
}
pub fn find_project_root(path: &Path) -> Option<PathBuf> {
let mut current = if path.is_file() {
path.parent()?
} else {
path
};
loop {
if current.join(".agent-doc").is_dir() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
}
pub struct SnapshotLock {
_file: File,
lock_path: PathBuf,
}
impl SnapshotLock {
pub fn acquire(doc: &Path) -> Result<Self> {
let snap = path_for(doc)?;
let lock_path = snap.with_extension("md.lock");
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("failed to open snapshot lock {}", lock_path.display()))?;
file.lock_exclusive()
.with_context(|| format!("failed to acquire snapshot lock on {}", lock_path.display()))?;
Ok(Self { _file: file, lock_path })
}
}
impl Drop for SnapshotLock {
fn drop(&mut self) {
let _ = self._file.unlock();
let _ = std::fs::remove_file(&self.lock_path);
}
}
pub fn path_for(doc: &Path) -> Result<PathBuf> {
let hash = doc_hash(doc)?;
let filename = format!("{}.md", hash);
if let Ok(canonical) = doc.canonicalize()
&& let Some(root) = find_project_root(&canonical)
{
return Ok(root.join(SNAP_DIR).join(filename));
}
Ok(PathBuf::from(SNAP_DIR).join(filename))
}
pub fn load(doc: &Path) -> Result<Option<String>> {
let snap = path_for(doc)?;
if !snap.exists() {
return Ok(None);
}
let _lock = SnapshotLock::acquire(doc)?;
load_unlocked(doc)
}
pub fn save(doc: &Path, content: &str) -> Result<()> {
let _lock = SnapshotLock::acquire(doc)?;
save_unlocked(doc, content)?;
crate::ops_log::log_op(doc, &format!(
"snapshot_save file={} len={}",
doc.display(),
content.len()
));
Ok(())
}
pub fn delete(doc: &Path) -> Result<()> {
let snap = path_for(doc)?;
if !snap.exists() {
return Ok(());
}
let _lock = SnapshotLock::acquire(doc)?;
if snap.exists() {
std::fs::remove_file(&snap)?;
}
Ok(())
}
pub fn resolve(doc: &Path) -> Result<Option<String>> {
let snap_path = path_for(doc)?;
if snap_path.exists() {
return load(doc);
}
let git_mtime = crate::git::last_commit_mtime(doc).unwrap_or(None);
if git_mtime.is_some() {
match crate::git::show_head(doc)? {
Some(git_content) => {
let current = std::fs::read_to_string(doc).unwrap_or_default();
if git_content == current {
eprintln!("[snapshot] No snapshot file, git matches current — treating as first submit");
Ok(None)
} else {
eprintln!("[snapshot] No snapshot file, recovering from git");
Ok(Some(git_content))
}
}
None => Ok(None),
}
} else {
Ok(None)
}
}
pub fn ensure_initialized(doc: &Path) -> Result<bool> {
let uuid_assigned = ensure_session_uuid(doc)?;
let snapshot_created = ensure_snapshot(doc)?;
if snapshot_created {
ensure_git_tracked(doc)?;
}
Ok(uuid_assigned || snapshot_created)
}
pub fn ensure_session_uuid(doc: &Path) -> Result<bool> {
let content = match std::fs::read_to_string(doc) {
Ok(c) => c,
Err(_) => return Ok(false),
};
let (fm, _) = match crate::frontmatter::parse(&content) {
Ok(parsed) => parsed,
Err(_) => return Ok(false),
};
if fm.format.is_none() || fm.session.is_some() {
return Ok(false);
}
eprintln!(
"[init] assigning session UUID to {} (has format but no session)",
doc.display()
);
let (updated, session_id) = crate::frontmatter::ensure_session(&content)?;
if updated != content {
std::fs::write(doc, &updated)?;
eprintln!("[init] assigned session UUID: {}", session_id);
Ok(true)
} else {
Ok(false)
}
}
pub fn ensure_snapshot(doc: &Path) -> Result<bool> {
let snap = path_for(doc)?;
if snap.exists() {
return Ok(false);
}
eprintln!(
"[init] creating snapshot for {} (none found)",
doc.display()
);
if let Ok(content) = std::fs::read_to_string(doc) {
let snapshot_content = crate::claim::strip_exchange_content(&content);
save(doc, &snapshot_content)?;
}
Ok(true)
}
pub fn ensure_git_tracked(doc: &Path) -> Result<()> {
let canonical = std::fs::canonicalize(doc).unwrap_or_else(|_| doc.to_path_buf());
let project_root = find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
let is_tracked = std::process::Command::new("git")
.args(["ls-files", "--error-unmatch"])
.arg(&canonical)
.current_dir(&project_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !is_tracked {
eprintln!("[init] file is untracked — staging with git add");
let _ = std::process::Command::new("git")
.args(["add", "--"])
.arg(&canonical)
.current_dir(&project_root)
.status();
}
if let Err(e) = crate::git::commit(doc) {
eprintln!("[init] warning: failed to commit after init: {}", e);
}
Ok(())
}
fn load_unlocked(doc: &Path) -> Result<Option<String>> {
let snap = path_for(doc)?;
if snap.exists() {
Ok(Some(std::fs::read_to_string(&snap)?))
} else {
Ok(None)
}
}
fn save_unlocked(doc: &Path, content: &str) -> Result<()> {
let snap = path_for(doc)?;
if let Some(parent) = snap.parent() {
std::fs::create_dir_all(parent)?;
}
let parent = snap.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temp file in {}", parent.display()))?;
std::io::Write::write_all(&mut tmp, content.as_bytes())
.with_context(|| "failed to write snapshot temp file")?;
tmp.persist(&snap)
.with_context(|| format!("failed to rename temp file to {}", snap.display()))?;
Ok(())
}
const PRE_RESPONSE_DIR: &str = ".agent-doc/pre-response";
pub fn pre_response_path_for(doc: &Path) -> Result<PathBuf> {
let hash = doc_hash(doc)?;
let canonical = doc.canonicalize()?;
let project_root = find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
Ok(project_root.join(PRE_RESPONSE_DIR).join(format!("{}.md", hash)))
}
pub fn save_pre_response(doc: &Path, content: &str) -> Result<()> {
let path = pre_response_path_for(doc)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let parent = path.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temp file in {}", parent.display()))?;
std::io::Write::write_all(&mut tmp, content.as_bytes())
.with_context(|| "failed to write pre-response temp file")?;
tmp.persist(&path)
.with_context(|| format!("failed to rename temp file to {}", path.display()))?;
eprintln!("[snapshot] saved pre-response snapshot for {}", doc.display());
Ok(())
}
pub fn load_pre_response(doc: &Path) -> Result<Option<String>> {
let path = pre_response_path_for(doc)?;
if !path.exists() {
return Ok(None);
}
Ok(Some(std::fs::read_to_string(&path)?))
}
pub fn delete_pre_response(doc: &Path) -> Result<()> {
let path = pre_response_path_for(doc)?;
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn crdt_path_for(doc: &Path) -> Result<PathBuf> {
let hash = doc_hash(doc)?;
let filename = format!("{}.yrs", hash);
let canonical = doc.canonicalize()?;
let project_root = find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
Ok(project_root.join(CRDT_DIR).join(filename))
}
pub fn load_crdt(doc: &Path) -> Result<Option<Vec<u8>>> {
let path = crdt_path_for(doc)?;
if !path.exists() {
return Ok(None);
}
let _lock = acquire_crdt_lock(doc)?;
let bytes = std::fs::read(&path)
.with_context(|| format!("failed to read CRDT state {}", path.display()))?;
Ok(Some(bytes))
}
pub fn save_crdt(doc: &Path, state: &[u8]) -> Result<()> {
let path = crdt_path_for(doc)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let _lock = acquire_crdt_lock(doc)?;
let parent = path.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temp file in {}", parent.display()))?;
std::io::Write::write_all(&mut tmp, state)
.with_context(|| "failed to write CRDT state temp file")?;
tmp.persist(&path)
.with_context(|| format!("failed to rename temp file to {}", path.display()))?;
Ok(())
}
pub fn delete_crdt(doc: &Path) -> Result<()> {
let path = crdt_path_for(doc)?;
if path.exists() {
let _lock = acquire_crdt_lock(doc)?;
if path.exists() {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
fn acquire_crdt_lock(doc: &Path) -> Result<File> {
let crdt_path = crdt_path_for(doc)?;
let lock_path = crdt_path.with_extension("yrs.lock");
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Ok(meta) = std::fs::metadata(&lock_path)
&& let Some(age) = meta.modified().ok().and_then(|t| t.elapsed().ok())
&& age > std::time::Duration::from_secs(3600)
{
let _ = std::fs::remove_file(&lock_path);
}
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("failed to open CRDT lock {}", lock_path.display()))?;
file.lock_exclusive()
.with_context(|| format!("failed to acquire CRDT lock on {}", lock_path.display()))?;
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let doc = dir.path().join("test.md");
fs::write(&doc, "# Test\n").unwrap();
(dir, doc)
}
fn write_snapshot_directly(dir: &Path, doc: &Path, content: &str) {
let snap = snapshot_path_in(dir, doc);
fs::create_dir_all(snap.parent().unwrap()).unwrap();
fs::write(&snap, content).unwrap();
}
fn read_snapshot_directly(dir: &Path, doc: &Path) -> Option<String> {
let snap = snapshot_path_in(dir, doc);
if snap.exists() {
Some(fs::read_to_string(&snap).unwrap())
} else {
None
}
}
fn snapshot_path_in(dir: &Path, doc: &Path) -> PathBuf {
let p = path_for(doc).unwrap();
if p.is_absolute() {
p
} else {
dir.join(&p)
}
}
#[test]
fn path_for_consistent_hash() {
let (_dir, doc) = setup();
let p1 = path_for(&doc).unwrap();
let p2 = path_for(&doc).unwrap();
assert_eq!(p1, p2);
}
#[test]
fn path_for_different_files_different_hashes() {
let dir = TempDir::new().unwrap();
let doc_a = dir.path().join("a.md");
let doc_b = dir.path().join("b.md");
fs::write(&doc_a, "a").unwrap();
fs::write(&doc_b, "b").unwrap();
let pa = path_for(&doc_a).unwrap();
let pb = path_for(&doc_b).unwrap();
assert_ne!(pa, pb);
}
#[test]
fn path_for_has_correct_structure() {
let (_dir, doc) = setup();
let p = path_for(&doc).unwrap();
assert!(p.to_string_lossy().contains(".agent-doc/snapshots/"));
assert!(p.to_string_lossy().ends_with(".md"));
let filename = p.file_stem().unwrap().to_string_lossy();
assert_eq!(filename.len(), 64);
assert!(filename.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn load_returns_none_when_no_snapshot() {
let (_dir, doc) = setup();
let result = load(&doc).unwrap();
assert!(result.is_none());
}
#[test]
fn snapshot_write_and_read_directly() {
let (dir, doc) = setup();
let content = "# Snapshot content\n\nWith body.\n";
write_snapshot_directly(dir.path(), &doc, content);
let loaded = read_snapshot_directly(dir.path(), &doc);
assert_eq!(loaded.as_deref(), Some(content));
}
#[test]
fn snapshot_overwrite() {
let (dir, doc) = setup();
write_snapshot_directly(dir.path(), &doc, "first");
write_snapshot_directly(dir.path(), &doc, "second");
let loaded = read_snapshot_directly(dir.path(), &doc);
assert_eq!(loaded.as_deref(), Some("second"));
}
#[test]
fn snapshot_delete_by_removing_file() {
let (dir, doc) = setup();
write_snapshot_directly(dir.path(), &doc, "content");
assert!(read_snapshot_directly(dir.path(), &doc).is_some());
let snap = snapshot_path_in(dir.path(), &doc);
fs::remove_file(&snap).unwrap();
assert!(read_snapshot_directly(dir.path(), &doc).is_none());
}
#[test]
fn delete_no_error_when_missing() {
let (_dir, doc) = setup();
delete(&doc).unwrap();
}
#[test]
fn flock_acquire_and_release_on_drop() {
use fs2::FileExt;
use std::fs::OpenOptions;
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("test.lock");
{
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.unwrap();
file.lock_exclusive().unwrap();
file.unlock().unwrap();
}
let file2 = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.unwrap();
file2.lock_exclusive().unwrap();
file2.unlock().unwrap();
}
#[test]
fn flock_serializes_concurrent_access() {
use fs2::FileExt;
use std::sync::{Arc, Barrier};
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("test.lock");
let data_path = dir.path().join("data.txt");
fs::write(&data_path, "0").unwrap();
let n = 10usize;
let barrier = Arc::new(Barrier::new(n));
let mut handles = Vec::new();
for _ in 0..n {
let lp = lock_path.clone();
let dp = data_path.clone();
let bar = Arc::clone(&barrier);
handles.push(std::thread::spawn(move || {
bar.wait();
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lp)
.unwrap();
file.lock_exclusive().unwrap();
let val: usize = fs::read_to_string(&dp).unwrap().trim().parse().unwrap();
fs::write(&dp, (val + 1).to_string()).unwrap();
file.unlock().unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
let final_val: usize = fs::read_to_string(&data_path)
.unwrap()
.trim()
.parse()
.unwrap();
assert_eq!(final_val, n, "all {} increments should be serialized", n);
}
#[test]
fn atomic_write_via_tempfile_produces_correct_content() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("output.md");
let parent = dir.path();
let mut tmp = tempfile::NamedTempFile::new_in(parent).unwrap();
std::io::Write::write_all(&mut tmp, b"atomic content").unwrap();
tmp.persist(&target).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "atomic content");
}
#[test]
fn atomic_write_overwrites_existing() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("output.md");
fs::write(&target, "old").unwrap();
let mut tmp = tempfile::NamedTempFile::new_in(dir.path()).unwrap();
std::io::Write::write_all(&mut tmp, b"new").unwrap();
tmp.persist(&target).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new");
}
#[test]
fn crdt_path_has_correct_extension() {
let (_dir, doc) = setup();
let p = crdt_path_for(&doc).unwrap();
assert!(p.to_string_lossy().contains(".agent-doc/crdt/"));
assert!(p.to_string_lossy().ends_with(".yrs"));
}
#[test]
fn crdt_save_and_load_roundtrip() {
let (_dir, doc) = setup();
let state = vec![1u8, 2, 3, 4, 5];
save_crdt(&doc, &state).unwrap();
let loaded = load_crdt(&doc).unwrap();
assert_eq!(loaded, Some(state));
}
#[test]
fn crdt_load_returns_none_when_missing() {
let (_dir, doc) = setup();
let loaded = load_crdt(&doc).unwrap();
assert!(loaded.is_none());
}
#[test]
fn crdt_delete_removes_file() {
let (_dir, doc) = setup();
save_crdt(&doc, &[1, 2, 3]).unwrap();
assert!(load_crdt(&doc).unwrap().is_some());
delete_crdt(&doc).unwrap();
assert!(load_crdt(&doc).unwrap().is_none());
}
#[test]
fn crdt_delete_no_error_when_missing() {
let (_dir, doc) = setup();
delete_crdt(&doc).unwrap();
}
#[test]
fn concurrent_atomic_writes_no_partial_content() {
use std::sync::{Arc, Barrier};
let dir = TempDir::new().unwrap();
let target = dir.path().join("concurrent.md");
fs::write(&target, "initial").unwrap();
let n = 20;
let barrier = Arc::new(Barrier::new(n));
let mut handles = Vec::new();
for i in 0..n {
let path = target.clone();
let parent = dir.path().to_path_buf();
let bar = Arc::clone(&barrier);
let content = format!("writer-{}-content", i);
handles.push(std::thread::spawn(move || {
bar.wait();
let mut tmp = tempfile::NamedTempFile::new_in(&parent).unwrap();
std::io::Write::write_all(&mut tmp, content.as_bytes()).unwrap();
tmp.persist(&path).unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
let final_content = fs::read_to_string(&target).unwrap();
assert!(
final_content.starts_with("writer-") && final_content.ends_with("-content"),
"unexpected content: {}",
final_content
);
}
#[test]
fn resolve_prefers_snapshot_over_git() {
let (dir, doc) = setup();
let snapshot_content = "snapshot baseline content";
write_snapshot_directly(dir.path(), &doc, snapshot_content);
let resolved = resolve(&doc).unwrap();
assert_eq!(resolved.as_deref(), Some(snapshot_content),
"resolve() should always prefer snapshot file when it exists");
}
fn setup_with_frontmatter(content: &str) -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let doc = dir.path().join("test.md");
fs::write(&doc, content).unwrap();
(dir, doc)
}
#[test]
fn ensure_session_uuid_assigns_to_template_without_session() {
let (_dir, doc) = setup_with_frontmatter(
"---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n"
);
let assigned = ensure_session_uuid(&doc).unwrap();
assert!(assigned, "should assign UUID to template file without session");
let content = fs::read_to_string(&doc).unwrap();
assert!(content.contains("agent_doc_session:"), "file should have session UUID");
}
#[test]
fn ensure_session_uuid_noop_when_session_exists() {
let (_dir, doc) = setup_with_frontmatter(
"---\nagent_doc_session: existing-uuid\nagent_doc_format: template\n---\n\nBody\n"
);
let assigned = ensure_session_uuid(&doc).unwrap();
assert!(!assigned, "should not reassign when session already exists");
let content = fs::read_to_string(&doc).unwrap();
assert!(content.contains("existing-uuid"), "original UUID preserved");
}
#[test]
fn ensure_session_uuid_noop_when_no_format() {
let (_dir, doc) = setup_with_frontmatter(
"---\ntitle: plain doc\n---\n\nNo agent_doc_format\n"
);
let assigned = ensure_session_uuid(&doc).unwrap();
assert!(!assigned, "should not assign UUID to non-agent-doc files");
}
#[test]
fn ensure_snapshot_creates_when_missing() {
let (dir, doc) = setup_with_frontmatter(
"---\nagent_doc_session: test-uuid\nagent_doc_format: template\n---\n\n<!-- agent:exchange patch=append -->\nuser text\n<!-- /agent:exchange -->\n"
);
fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
let created = ensure_snapshot(&doc).unwrap();
assert!(created, "should create snapshot when none exists");
let snap = read_snapshot_directly(dir.path(), &doc);
assert!(snap.is_some(), "snapshot file should exist");
let snap_content = snap.unwrap();
assert!(!snap_content.contains("user text"),
"snapshot should have stripped exchange content");
}
#[test]
fn ensure_snapshot_noop_when_exists() {
let (dir, doc) = setup();
write_snapshot_directly(dir.path(), &doc, "existing snapshot");
let created = ensure_snapshot(&doc).unwrap();
assert!(!created, "should not recreate when snapshot exists");
let snap = read_snapshot_directly(dir.path(), &doc).unwrap();
assert_eq!(snap, "existing snapshot", "existing snapshot preserved");
}
#[test]
fn ensure_initialized_assigns_uuid_even_when_snapshot_exists() {
let (dir, doc) = setup_with_frontmatter(
"---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\nBody\n"
);
fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
write_snapshot_directly(dir.path(), &doc, "pre-existing snapshot");
let initialized = ensure_initialized(&doc).unwrap();
assert!(initialized, "should return true when UUID was assigned");
let content = fs::read_to_string(&doc).unwrap();
assert!(content.contains("agent_doc_session:"), "UUID should be assigned");
}
}