Skip to main content

agent_doc_fs/
lib.rs

1use anyhow::{Context, Result};
2use std::path::{Component, Path, PathBuf};
3
4pub mod install_freshness;
5
6const SNAPSHOT_DIR: &str = ".agent-doc/snapshots";
7const BASELINE_DIR: &str = ".agent-doc/baselines";
8const LOCK_DIR: &str = ".agent-doc/locks";
9const PENDING_DIR: &str = ".agent-doc/pending";
10const TURN_SCOPE_DIR: &str = ".agent-doc/turn-scope";
11const CRDT_DIR: &str = ".agent-doc/crdt";
12const CRDT_REPLICA_EVENT_DIR: &str = ".agent-doc/crdt-replica-events";
13const PRE_RESPONSE_DIR: &str = ".agent-doc/pre-response";
14const CYCLE_STATE_DIR: &str = ".agent-doc/state/cycles";
15const DISK_CHANGE_REQUEST_DIR: &str = ".agent-doc/disk-change-requests";
16const RECOVERY_DIR: &str = ".agent-doc/recovery";
17const STARTING_DIR: &str = ".agent-doc/starting";
18const BASELINE_OVERLAY_EXT: &str = "overlay.yrs";
19
20/// Walk up the directory tree from `path` to find the directory containing
21/// `.agent-doc` (the project root). Returns `None` if no such ancestor exists.
22pub fn find_project_root(path: &Path) -> Option<PathBuf> {
23    let mut current = if path.is_file() { path.parent()? } else { path };
24    loop {
25        if current.join(".agent-doc").is_dir() {
26            return Some(current.to_path_buf());
27        }
28        current = current.parent()?;
29    }
30}
31
32/// Canonicalize `path` first, then delegate to [`find_project_root`].
33/// Returns `None` if canonicalization fails or no `.agent-doc` ancestor exists.
34pub fn find_project_root_canonical(path: &Path) -> Option<PathBuf> {
35    let canonical = path.canonicalize().ok()?;
36    find_project_root(&canonical)
37}
38
39/// Compute the SHA-256 hex hash used to key per-document state sidecars.
40pub fn document_state_hash(doc: &Path) -> Result<String> {
41    let canonical = canonical_document_path(doc)?;
42    Ok(agent_doc_hash::path_string_hash(
43        &canonical.to_string_lossy(),
44    ))
45}
46
47/// Compute the per-document state hash from an already-resolved path string.
48///
49/// This avoids filesystem access for paths that no longer exist, such as the
50/// old document path during rename recovery.
51pub fn document_state_hash_from_str(absolute_path: &str) -> String {
52    agent_doc_hash::path_string_hash(absolute_path)
53}
54
55/// Compute `<project_root>/.agent-doc/snapshots/<hash>.md` for a document.
56///
57/// If no `.agent-doc` project root exists, this preserves the historical
58/// relative fallback `.agent-doc/snapshots/<hash>.md`.
59pub fn snapshot_path_for(doc: &Path) -> Result<PathBuf> {
60    let canonical = canonical_document_path(doc)?;
61    let filename = format!(
62        "{}.md",
63        agent_doc_hash::path_string_hash(&canonical.to_string_lossy())
64    );
65    if let Some(root) = find_project_root(&canonical) {
66        return Ok(root.join(SNAPSHOT_DIR).join(filename));
67    }
68    Ok(PathBuf::from(SNAPSHOT_DIR).join(filename))
69}
70
71/// Compute `<project_root>/.agent-doc/locks/<hash>.lock` for a document.
72pub fn state_lock_path_for(doc: &Path) -> Result<PathBuf> {
73    hashed_state_path(doc, LOCK_DIR, "lock")
74}
75
76/// Compute `<project_root>/.agent-doc/pending/<hash>.md` for a document.
77pub fn pending_response_path_for(doc: &Path) -> Result<PathBuf> {
78    hashed_state_path(doc, PENDING_DIR, "md")
79}
80
81/// Compute `<project_root>/.agent-doc/turn-scope/<hash>.json` for a document.
82pub fn turn_scope_path_for(doc: &Path) -> Result<PathBuf> {
83    hashed_state_path(doc, TURN_SCOPE_DIR, "json")
84}
85
86/// Compute `<project_root>/.agent-doc/disk-change-requests/<hash>.json` for a
87/// document. The controller watch daemon drops this marker when the file changed
88/// on disk out of band; the CPC/controller consumer reads it, reconciles the
89/// change into the canonical replica, and clears it (the marker + idle-poll
90/// cross-process signal, mirroring recycle-request).
91pub fn disk_change_request_path_for(doc: &Path) -> Result<PathBuf> {
92    hashed_state_path(doc, DISK_CHANGE_REQUEST_DIR, "json")
93}
94
95/// Compute `<project_root>/.agent-doc/crdt-replica-events/<hash>.json` for a
96/// document. The controller writes this as an editor-facing event signal after
97/// CRDT fan-out or replace rebootstrap work is queued, and editor plugins watch
98/// the directory to drain pending replica deliveries without polling.
99pub fn crdt_replica_event_path_for(doc: &Path) -> Result<PathBuf> {
100    hashed_state_path(doc, CRDT_REPLICA_EVENT_DIR, "json")
101}
102
103/// Compute `<project_root>/.agent-doc/state/cycles/<hash>.json` for a document.
104///
105/// Returns `Ok(None)` when `doc` cannot be canonicalized or no `.agent-doc`
106/// project root exists.
107pub fn cycle_state_path_for(doc: &Path) -> Result<Option<PathBuf>> {
108    let canonical = match doc.canonicalize() {
109        Ok(path) => path,
110        Err(_) => return Ok(None),
111    };
112    let Some(root) = find_project_root(&canonical) else {
113        return Ok(None);
114    };
115    let hash = document_state_hash(&canonical)?;
116    Ok(Some(
117        root.join(CYCLE_STATE_DIR).join(format!("{hash}.json")),
118    ))
119}
120
121/// Compute `<project_root>/.agent-doc/starting` for a document.
122///
123/// Returns `None` when `doc` cannot be canonicalized or a root/fallback parent
124/// cannot be resolved.
125pub fn startup_starting_dir_for(doc: &Path) -> Option<PathBuf> {
126    let canonical = doc.canonicalize().ok()?;
127    let base =
128        find_project_root(&canonical).or_else(|| canonical.parent().map(Path::to_path_buf))?;
129    Some(base.join(STARTING_DIR))
130}
131
132/// Compute the startup lock filename for a tmux session.
133pub fn startup_session_lock_name(session_name: &str) -> String {
134    let hash = document_state_hash_from_str(&format!("session:{session_name}"));
135    format!("session-{hash}.lock")
136}
137
138/// Compute `<project_root>/.agent-doc/starting/<hash>.lock` for a document.
139///
140/// Returns `None` when the startup directory cannot be resolved. Falls back to
141/// hashing the input path text when the document hash cannot be derived from the
142/// filesystem.
143pub fn startup_document_lock_path_for(doc: &Path) -> Option<PathBuf> {
144    let starting_dir = startup_starting_dir_for(doc)?;
145    let hash = document_state_hash(doc)
146        .unwrap_or_else(|_| document_state_hash_from_str(&doc.to_string_lossy()));
147    Some(starting_dir.join(format!("{hash}.lock")))
148}
149
150/// Compute `<project_root>/.agent-doc/starting/session-<hash>.lock`.
151pub fn startup_session_lock_path_for(doc: &Path, session_name: &str) -> Option<PathBuf> {
152    Some(startup_starting_dir_for(doc)?.join(startup_session_lock_name(session_name)))
153}
154
155/// Compute `<project_root>/.agent-doc/baselines/<hash>.md` for a document.
156pub fn baseline_path_for(doc: &Path) -> Result<PathBuf> {
157    hashed_state_path(doc, BASELINE_DIR, "md")
158}
159
160/// Compute `<project_root>/.agent-doc/baselines/<hash>.overlay.yrs`.
161pub fn baseline_overlay_path_for(doc: &Path) -> Result<PathBuf> {
162    let (root, hash) = state_root_and_hash(doc)?;
163    Ok(root
164        .join(BASELINE_DIR)
165        .join(format!("{}.{}", hash, BASELINE_OVERLAY_EXT)))
166}
167
168/// Compute `<project_root>/.agent-doc/pre-response/<hash>.md`.
169pub fn pre_response_path_for(doc: &Path) -> Result<PathBuf> {
170    hashed_state_path(doc, PRE_RESPONSE_DIR, "md")
171}
172
173/// Compute `<project_root>/.agent-doc/crdt/<hash>.yrs`.
174pub fn crdt_path_for(doc: &Path) -> Result<PathBuf> {
175    hashed_state_path(doc, CRDT_DIR, "yrs")
176}
177
178/// Compute `<project_root>/.agent-doc/crdt/<hash>.overlay.yrs`.
179pub fn overlay_crdt_path_for(doc: &Path) -> Result<PathBuf> {
180    hashed_state_path_with_suffix(doc, CRDT_DIR, "overlay.yrs")
181}
182
183/// Compute `<project_root>/.agent-doc/crdt/<hash>.nodes.yrs`.
184pub fn multinode_crdt_path_for(doc: &Path) -> Result<PathBuf> {
185    hashed_state_path_with_suffix(doc, CRDT_DIR, "nodes.yrs")
186}
187
188/// Compute the snapshot flock path adjacent to the snapshot sidecar.
189pub fn snapshot_flock_path_for(doc: &Path) -> Result<PathBuf> {
190    Ok(snapshot_path_for(doc)?.with_extension("md.lock"))
191}
192
193/// Compute the CRDT flock path adjacent to the legacy CRDT sidecar.
194pub fn crdt_flock_path_for(doc: &Path) -> Result<PathBuf> {
195    Ok(crdt_path_for(doc)?.with_extension("yrs.lock"))
196}
197
198/// Rewrite `file_path` to be relative to `cwd` so a spawned command resolves
199/// correctly when its working directory is narrowed to a submodule root.
200///
201/// When pane cwd resolution narrows to a submodule, a caller's super-root
202/// relative path does not resolve inside that cwd. On any filesystem miss or
203/// non-descendant path, the original string is returned unchanged.
204pub fn rewrite_start_path(file: &Path, cwd: &Path, original: &str) -> String {
205    let Ok(abs_file) = std::fs::canonicalize(file) else {
206        return original.to_string();
207    };
208    let Ok(abs_cwd) = std::fs::canonicalize(cwd) else {
209        return original.to_string();
210    };
211    match abs_file.strip_prefix(&abs_cwd) {
212        Ok(rel) => rel.to_string_lossy().into_owned(),
213        Err(_) => original.to_string(),
214    }
215}
216
217/// The inode a process currently maps via `/proc/<pid>/exe`.
218///
219/// On Linux this magic symlink resolves to the real on-disk inode of the running
220/// executable even after the install path has been replaced. Returns `None`
221/// when `/proc` is unavailable, the process is gone, or the stat fails.
222pub fn running_exe_inode_for_pid(pid: u32) -> Option<u64> {
223    #[cfg(target_os = "linux")]
224    {
225        use std::os::unix::fs::MetadataExt;
226
227        std::fs::metadata(format!("/proc/{pid}/exe"))
228            .ok()
229            .map(|meta| meta.ino())
230    }
231    #[cfg(not(target_os = "linux"))]
232    {
233        let _ = pid;
234        None
235    }
236}
237
238/// Inode of the on-disk file at `path`. Returns `None` on non-Unix platforms or
239/// any stat error.
240pub fn inode_of_path(path: &Path) -> Option<u64> {
241    #[cfg(unix)]
242    {
243        use std::os::unix::fs::MetadataExt;
244
245        std::fs::metadata(path).ok().map(|meta| meta.ino())
246    }
247    #[cfg(not(unix))]
248    {
249        let _ = path;
250        None
251    }
252}
253
254pub fn referenced_markdown_path(current_file: &Path, text: &str) -> Option<PathBuf> {
255    referenced_markdown_path_checked(current_file, text)
256        .ok()
257        .flatten()
258}
259
260pub fn referenced_markdown_path_checked(
261    current_file: &Path,
262    text: &str,
263) -> Result<Option<PathBuf>> {
264    let current = normalize_path(current_file);
265    let project_roots = project_roots_for(current_file);
266    for raw in text.split_whitespace() {
267        let candidate = raw.trim_matches(|c: char| {
268            matches!(
269                c,
270                '`' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | ';' | ':'
271            )
272        });
273        if !candidate.ends_with(".md") {
274            continue;
275        }
276
277        let path = Path::new(candidate);
278        let mut possibilities = Vec::<PathBuf>::new();
279        let has_project_prefix = first_component(path).is_some_and(|first| {
280            project_roots.iter().any(|root| {
281                root.file_name()
282                    .is_some_and(|name| Component::Normal(name) == first)
283            })
284        });
285        if path.is_absolute() {
286            possibilities.push(path.to_path_buf());
287        } else {
288            for root in &project_roots {
289                if let Some(stripped) = strip_redundant_project_prefix(root, path) {
290                    possibilities.push(root.join(stripped));
291                }
292            }
293            for root in &project_roots {
294                possibilities.push(root.join(path));
295                if let Some(stripped) = strip_redundant_project_prefix(root, path) {
296                    possibilities.push(root.join(stripped));
297                }
298            }
299            possibilities.push(
300                current_file
301                    .parent()
302                    .unwrap_or_else(|| Path::new("."))
303                    .join(path),
304            );
305        }
306
307        let mut fallback = None;
308        let mut matched_current = false;
309        let mut existing = Vec::new();
310        for resolved in possibilities {
311            let resolved = normalize_path(&resolved);
312            if resolved == current {
313                matched_current = true;
314                continue;
315            }
316            if resolved.exists() {
317                if !existing.iter().any(|seen| seen == &resolved) {
318                    existing.push(resolved);
319                }
320                continue;
321            }
322            fallback.get_or_insert(resolved);
323        }
324        if existing.len() > 1 {
325            anyhow::bail!(
326                "ambiguous markdown reference `{}` from {} matched multiple project roots: {}",
327                candidate,
328                current_file.display(),
329                existing
330                    .iter()
331                    .map(|path| path.display().to_string())
332                    .collect::<Vec<_>>()
333                    .join(", ")
334            );
335        }
336        if let Some(resolved) = existing.into_iter().next() {
337            return Ok(Some(resolved));
338        }
339        if has_project_prefix {
340            let attempted = fallback
341                .as_ref()
342                .map(|path| path.display().to_string())
343                .unwrap_or_else(|| candidate.to_string());
344            anyhow::bail!(
345                "project-prefixed markdown reference `{}` from {} did not resolve to an existing file (first candidate: {})",
346                candidate,
347                current_file.display(),
348                attempted
349            );
350        }
351        if matched_current {
352            continue;
353        }
354        if let Some(resolved) = fallback {
355            return Ok(Some(resolved));
356        }
357    }
358    Ok(None)
359}
360
361/// Process-local sequence so concurrent [`write_atomic`] calls never collide on
362/// the same sibling temp-file name.
363static ATOMIC_WRITE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
364
365/// Atomically write `contents` to `path` by writing a sibling temp file and
366/// renaming it into place. `rename(2)` on the same filesystem is atomic, so a
367/// crash or `execve` mid-write leaves either the previous file or the
368/// fully-written new one — never a truncated/0-byte file. Creates the parent
369/// directory if missing.
370///
371/// This is the write counterpart to [`read_optional_text`] and the fix for an
372/// interrupted write (e.g. an `auto_install_reexec` recycle killed mid-write)
373/// leaving a 0-byte controller-state file that then wedges every future read.
374pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
375    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
376    if let Some(parent) = parent {
377        std::fs::create_dir_all(parent)
378            .with_context(|| format!("failed to create parent dir for {}", path.display()))?;
379    }
380    let dir = parent.unwrap_or_else(|| Path::new("."));
381    let stem = path
382        .file_name()
383        .and_then(|n| n.to_str())
384        .unwrap_or("agent-doc-state");
385    let seq = ATOMIC_WRITE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
386    let tmp = dir.join(format!(".{stem}.tmp-{}-{seq}", std::process::id()));
387    std::fs::write(&tmp, contents)
388        .with_context(|| format!("failed to write temp file {}", tmp.display()))?;
389    if let Err(err) = std::fs::rename(&tmp, path) {
390        // Best-effort cleanup so a failed rename does not litter temp files;
391        // surface (never swallow) the original rename error.
392        if let Err(cleanup) = std::fs::remove_file(&tmp) {
393            eprintln!(
394                "[agent-doc] warning: failed to clean up temp file {} after rename error: {cleanup}",
395                tmp.display()
396            );
397        }
398        return Err(err)
399            .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()));
400    }
401    Ok(())
402}
403
404pub fn read_optional_text(path: &Path) -> Result<Option<String>> {
405    read_optional(path, |path| std::fs::read_to_string(path))
406}
407
408pub fn read_optional_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
409    read_optional(path, |path| std::fs::read(path))
410}
411
412fn read_optional<T, F>(path: &Path, read: F) -> Result<Option<T>>
413where
414    F: FnOnce(&Path) -> std::io::Result<T>,
415{
416    match read(path) {
417        Ok(value) => Ok(Some(value)),
418        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
419        Err(err) => Err(err.into()),
420    }
421}
422
423/// Read `path` and parse it with `parse`. Missing → `Ok(None)`. If the file
424/// exists but is empty / whitespace-only / fails to parse (a *corrupt* state
425/// file — e.g. a 0-byte `controller-state.json` left by a pre-[`write_atomic`]
426/// interrupted write or an external truncation), quarantine the bad file by
427/// renaming it aside and return `Ok(None)` so the caller reboots from a clean
428/// slate instead of wedging on every future read.
429///
430/// This is the read counterpart to [`write_atomic`] (`#corrupt-state-quarantine`):
431/// `write_atomic` stops *new* 0-byte files; this recovers automatically from an
432/// *already-corrupt* one, so a partial write no longer manifests as the manual
433/// `start "timed out waiting for project controller"` move-aside dance.
434pub fn read_valid_or_quarantine<T, F>(path: &Path, parse: F) -> Result<Option<T>>
435where
436    F: FnOnce(&str) -> Option<T>,
437{
438    let Some(text) = read_optional_text(path)? else {
439        return Ok(None);
440    };
441    if !text.trim().is_empty()
442        && let Some(parsed) = parse(&text)
443    {
444        return Ok(Some(parsed));
445    }
446    quarantine_corrupt_file(path)?;
447    Ok(None)
448}
449
450/// Rename a corrupt state file aside to a sibling `<name>.corrupt-<pid>-<seq>`
451/// so it stops wedging reads while remaining available for forensics. A file
452/// that raced away (already removed) is treated as success.
453pub fn quarantine_corrupt_file(path: &Path) -> Result<()> {
454    let file_name = path
455        .file_name()
456        .and_then(|n| n.to_str())
457        .unwrap_or("agent-doc-state");
458    let seq = ATOMIC_WRITE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
459    let quarantine =
460        path.with_file_name(format!("{file_name}.corrupt-{}-{seq}", std::process::id()));
461    match std::fs::rename(path, &quarantine) {
462        Ok(()) => {
463            eprintln!(
464                "[agent-doc] quarantined corrupt state file {} -> {}",
465                path.display(),
466                quarantine.display()
467            );
468            Ok(())
469        }
470        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
471        Err(err) => Err(err).with_context(|| {
472            format!(
473                "failed to quarantine corrupt state file {} -> {}",
474                path.display(),
475                quarantine.display()
476            )
477        }),
478    }
479}
480
481/// Preserve a buffer the merge is about to drop to a durable recovery sidecar at
482/// `<project_root>/.agent-doc/recovery/<hash>.<pid>-<seq>.md`, so concurrent
483/// operator text is recoverable instead of silently lost (`#qftlossdelta`).
484/// Written atomically; returns the sidecar path. Best-effort by the caller —
485/// this is a safety net alongside, not a replacement for, the merge decision.
486pub fn preserve_dropped_operator_buffer(doc: &Path, content: &str) -> Result<PathBuf> {
487    let (root, hash) = state_root_and_hash(doc)?;
488    let seq = ATOMIC_WRITE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
489    let path = root
490        .join(RECOVERY_DIR)
491        .join(format!("{hash}.{}-{seq}.md", std::process::id()));
492    write_atomic(&path, content.as_bytes())?;
493    Ok(path)
494}
495
496fn first_component(path: &Path) -> Option<Component<'_>> {
497    path.components().next()
498}
499
500fn strip_redundant_project_prefix(root: &Path, path: &Path) -> Option<PathBuf> {
501    let root_name = root.file_name()?;
502    let mut components = path.components();
503    let Component::Normal(first) = components.next()? else {
504        return None;
505    };
506    if first != root_name {
507        return None;
508    }
509    let stripped = components.as_path();
510    (!stripped.as_os_str().is_empty()).then(|| stripped.to_path_buf())
511}
512
513fn normalize_path(path: &Path) -> PathBuf {
514    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
515}
516
517pub fn same_document_path(left: &Path, right: &Path) -> bool {
518    normalize_path(left) == normalize_path(right)
519}
520
521fn canonical_document_path(doc: &Path) -> Result<PathBuf> {
522    doc.canonicalize()
523        .with_context(|| format!("canonicalize document path for hash: {}", doc.display()))
524}
525
526fn state_root_and_hash(doc: &Path) -> Result<(PathBuf, String)> {
527    let canonical = canonical_document_path(doc)?;
528    let hash = agent_doc_hash::path_string_hash(&canonical.to_string_lossy());
529    let root = find_project_root(&canonical)
530        .unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
531    Ok((root, hash))
532}
533
534fn hashed_state_path(doc: &Path, dir: &str, extension: &str) -> Result<PathBuf> {
535    hashed_state_path_with_suffix(doc, dir, extension)
536}
537
538fn hashed_state_path_with_suffix(doc: &Path, dir: &str, suffix: &str) -> Result<PathBuf> {
539    let (root, hash) = state_root_and_hash(doc)?;
540    Ok(root.join(dir).join(format!("{}.{}", hash, suffix)))
541}
542
543fn project_roots_for(path: &Path) -> Vec<PathBuf> {
544    let mut current = if path.is_dir() {
545        path.to_path_buf()
546    } else {
547        match path.parent() {
548            Some(parent) => parent.to_path_buf(),
549            None => return Vec::new(),
550        }
551    };
552    let mut roots = Vec::new();
553    loop {
554        if current.join(".agent-doc").is_dir() {
555            roots.push(normalize_path(&current));
556        }
557        if !current.pop() {
558            return roots;
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::{
566        baseline_overlay_path_for, baseline_path_for, crdt_flock_path_for, crdt_path_for,
567        cycle_state_path_for, document_state_hash, document_state_hash_from_str, inode_of_path,
568        multinode_crdt_path_for, overlay_crdt_path_for, pending_response_path_for,
569        pre_response_path_for, preserve_dropped_operator_buffer, quarantine_corrupt_file,
570        read_optional, read_valid_or_quarantine, referenced_markdown_path,
571        referenced_markdown_path_checked, rewrite_start_path, running_exe_inode_for_pid,
572        same_document_path, snapshot_flock_path_for, snapshot_path_for,
573        startup_document_lock_path_for, startup_session_lock_name, startup_session_lock_path_for,
574        startup_starting_dir_for, state_lock_path_for, turn_scope_path_for, write_atomic,
575    };
576    use std::path::Path;
577
578    #[test]
579    fn write_atomic_creates_parent_and_writes_contents() {
580        let tmp = tempfile::TempDir::new().unwrap();
581        let path = tmp.path().join("nested/dir/state.json");
582        write_atomic(&path, b"{\"k\":1}").unwrap();
583        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"k\":1}");
584    }
585
586    // #corrupt-state-quarantine
587
588    #[test]
589    fn read_valid_or_quarantine_missing_is_none_no_side_effect() {
590        let tmp = tempfile::TempDir::new().unwrap();
591        let path = tmp.path().join("controller-state.json");
592        let got: Option<String> = read_valid_or_quarantine(&path, |s| Some(s.to_string())).unwrap();
593        assert!(got.is_none());
594        assert!(std::fs::read_dir(tmp.path()).unwrap().next().is_none());
595    }
596
597    #[test]
598    fn read_valid_or_quarantine_valid_returns_parsed_and_keeps_file() {
599        let tmp = tempfile::TempDir::new().unwrap();
600        let path = tmp.path().join("controller-state.json");
601        std::fs::write(&path, "42").unwrap();
602        let got: Option<u32> = read_valid_or_quarantine(&path, |s| s.trim().parse().ok()).unwrap();
603        assert_eq!(got, Some(42));
604        assert!(path.exists(), "valid file must be left intact");
605    }
606
607    #[test]
608    fn read_valid_or_quarantine_zero_byte_quarantines_and_returns_none() {
609        let tmp = tempfile::TempDir::new().unwrap();
610        let path = tmp.path().join("controller-state.json");
611        std::fs::write(&path, "").unwrap(); // 0-byte, the classic wedge
612        let got: Option<u32> = read_valid_or_quarantine(&path, |s| s.trim().parse().ok()).unwrap();
613        assert!(got.is_none(), "empty state must not parse");
614        assert!(!path.exists(), "0-byte file must be moved aside");
615        let quarantined: Vec<_> = std::fs::read_dir(tmp.path())
616            .unwrap()
617            .filter_map(|e| e.ok())
618            .filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
619            .collect();
620        assert_eq!(quarantined.len(), 1, "exactly one quarantine sibling");
621    }
622
623    #[test]
624    fn read_valid_or_quarantine_unparseable_quarantines() {
625        let tmp = tempfile::TempDir::new().unwrap();
626        let path = tmp.path().join("controller-state.json");
627        std::fs::write(&path, "{not json").unwrap();
628        let got: Option<u32> = read_valid_or_quarantine(&path, |s| s.trim().parse().ok()).unwrap();
629        assert!(got.is_none());
630        assert!(!path.exists(), "corrupt file must be moved aside");
631    }
632
633    #[test]
634    fn quarantine_corrupt_file_missing_is_ok() {
635        let tmp = tempfile::TempDir::new().unwrap();
636        // A file that raced away is treated as success (idempotent recovery).
637        quarantine_corrupt_file(&tmp.path().join("gone.json")).unwrap();
638    }
639
640    #[test]
641    fn preserve_dropped_operator_buffer_writes_recovery_sidecar() {
642        let tmp = tempfile::TempDir::new().unwrap();
643        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
644        let doc = tmp.path().join("plan.md");
645        std::fs::write(&doc, "# plan\n").unwrap();
646        let path =
647            preserve_dropped_operator_buffer(&doc, "operator text that would be lost").unwrap();
648        assert!(path.exists(), "recovery sidecar must be written");
649        assert!(
650            path.to_string_lossy().contains("/.agent-doc/recovery/"),
651            "sidecar under .agent-doc/recovery: {}",
652            path.display()
653        );
654        assert_eq!(
655            std::fs::read_to_string(&path).unwrap(),
656            "operator text that would be lost"
657        );
658    }
659
660    #[test]
661    fn write_atomic_overwrites_existing_and_leaves_no_temp_files() {
662        let tmp = tempfile::TempDir::new().unwrap();
663        let path = tmp.path().join("state.json");
664        write_atomic(&path, b"old").unwrap();
665        write_atomic(&path, b"new-longer-content").unwrap();
666        assert_eq!(
667            std::fs::read_to_string(&path).unwrap(),
668            "new-longer-content"
669        );
670        // No sibling ".state.json.tmp-*" temp files should survive a successful write.
671        let leftover = std::fs::read_dir(tmp.path())
672            .unwrap()
673            .filter_map(|e| e.ok())
674            .any(|e| e.file_name().to_string_lossy().contains(".tmp-"));
675        assert!(!leftover, "temp file leaked after atomic write");
676    }
677
678    #[cfg(unix)]
679    #[test]
680    fn inode_of_path_reads_existing_file_inode() {
681        use std::os::unix::fs::MetadataExt;
682
683        let tmp = tempfile::TempDir::new().unwrap();
684        let path = tmp.path().join("binary");
685        std::fs::write(&path, b"agent-doc").unwrap();
686
687        assert_eq!(
688            inode_of_path(&path),
689            Some(std::fs::metadata(&path).unwrap().ino())
690        );
691        assert_eq!(inode_of_path(&tmp.path().join("missing")), None);
692    }
693
694    #[cfg(target_os = "linux")]
695    #[test]
696    fn running_exe_inode_for_pid_reads_current_process_inode() {
697        use std::os::unix::fs::MetadataExt;
698
699        let expected = std::fs::metadata(format!("/proc/{}/exe", std::process::id()))
700            .unwrap()
701            .ino();
702
703        assert_eq!(
704            running_exe_inode_for_pid(std::process::id()),
705            Some(expected)
706        );
707    }
708
709    #[test]
710    fn read_optional_returns_none_on_not_found() {
711        let value: Option<String> = read_optional(Path::new("missing"), |_| {
712            Err(std::io::Error::from(std::io::ErrorKind::NotFound))
713        })
714        .unwrap();
715        assert!(value.is_none());
716    }
717
718    #[test]
719    fn read_optional_preserves_other_errors() {
720        let err = read_optional::<String, _>(Path::new("denied"), |_| {
721            Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
722        })
723        .unwrap_err();
724        let message = err.to_string().to_ascii_lowercase();
725        assert!(
726            message.contains("permission denied"),
727            "unexpected error: {err}"
728        );
729    }
730
731    #[test]
732    fn document_state_hash_uses_canonical_path_string() {
733        let tmp = tempfile::TempDir::new().unwrap();
734        let doc = tmp.path().join("doc.md");
735        std::fs::write(&doc, "# doc\n").unwrap();
736        let canonical = doc.canonicalize().unwrap();
737
738        assert_eq!(
739            document_state_hash(&doc).unwrap(),
740            document_state_hash_from_str(&canonical.to_string_lossy())
741        );
742    }
743
744    #[test]
745    fn same_document_path_matches_equal_unresolved_paths() {
746        assert!(same_document_path(
747            Path::new("/tmp/agent-doc-same.md"),
748            Path::new("/tmp/agent-doc-same.md")
749        ));
750        assert!(!same_document_path(
751            Path::new("/tmp/agent-doc-left.md"),
752            Path::new("/tmp/agent-doc-right.md")
753        ));
754    }
755
756    #[test]
757    fn snapshot_path_uses_project_root_when_available() {
758        let tmp = tempfile::TempDir::new().unwrap();
759        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
760        let doc = tmp.path().join("nested").join("doc.md");
761        std::fs::create_dir_all(doc.parent().unwrap()).unwrap();
762        std::fs::write(&doc, "# doc\n").unwrap();
763        let hash = document_state_hash(&doc).unwrap();
764
765        assert_eq!(
766            snapshot_path_for(&doc).unwrap(),
767            tmp.path()
768                .join(".agent-doc")
769                .join("snapshots")
770                .join(format!("{hash}.md"))
771        );
772    }
773
774    #[test]
775    fn snapshot_path_preserves_relative_fallback_without_project_root() {
776        let Some(tmp) = temp_dir_without_agent_doc_ancestor() else {
777            return;
778        };
779        let doc = tmp.path().join("doc.md");
780        std::fs::write(&doc, "# doc\n").unwrap();
781        let hash = document_state_hash(&doc).unwrap();
782
783        assert_eq!(
784            snapshot_path_for(&doc).unwrap(),
785            Path::new(".agent-doc")
786                .join("snapshots")
787                .join(format!("{hash}.md"))
788        );
789    }
790
791    fn temp_dir_without_agent_doc_ancestor() -> Option<tempfile::TempDir> {
792        for base in [
793            std::path::PathBuf::from("/var/tmp"),
794            std::path::PathBuf::from("/dev/shm"),
795            std::env::temp_dir(),
796        ] {
797            if !base.is_dir() || has_agent_doc_ancestor(&base) {
798                continue;
799            }
800            if let Ok(dir) = tempfile::Builder::new()
801                .prefix("agent-doc-fs-no-root")
802                .tempdir_in(base)
803            {
804                return Some(dir);
805            }
806        }
807        None
808    }
809
810    fn has_agent_doc_ancestor(path: &Path) -> bool {
811        let Ok(mut current) = path.canonicalize() else {
812            return false;
813        };
814        loop {
815            if current.join(".agent-doc").is_dir() {
816                return true;
817            }
818            if !current.pop() {
819                return false;
820            }
821        }
822    }
823
824    #[test]
825    fn document_state_sidecar_paths_share_hash_and_project_root() {
826        let tmp = tempfile::TempDir::new().unwrap();
827        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
828        let doc = tmp.path().join("doc.md");
829        std::fs::write(&doc, "# doc\n").unwrap();
830        let hash = document_state_hash(&doc).unwrap();
831        let agent_doc = tmp.path().join(".agent-doc");
832
833        assert_eq!(
834            state_lock_path_for(&doc).unwrap(),
835            agent_doc.join("locks").join(format!("{hash}.lock"))
836        );
837        assert_eq!(
838            pending_response_path_for(&doc).unwrap(),
839            agent_doc.join("pending").join(format!("{hash}.md"))
840        );
841        assert_eq!(
842            turn_scope_path_for(&doc).unwrap(),
843            agent_doc.join("turn-scope").join(format!("{hash}.json"))
844        );
845        assert_eq!(
846            baseline_path_for(&doc).unwrap(),
847            agent_doc.join("baselines").join(format!("{hash}.md"))
848        );
849        assert_eq!(
850            baseline_overlay_path_for(&doc).unwrap(),
851            agent_doc
852                .join("baselines")
853                .join(format!("{hash}.overlay.yrs"))
854        );
855        assert_eq!(
856            pre_response_path_for(&doc).unwrap(),
857            agent_doc.join("pre-response").join(format!("{hash}.md"))
858        );
859        assert_eq!(
860            crdt_path_for(&doc).unwrap(),
861            agent_doc.join("crdt").join(format!("{hash}.yrs"))
862        );
863        assert_eq!(
864            overlay_crdt_path_for(&doc).unwrap(),
865            agent_doc.join("crdt").join(format!("{hash}.overlay.yrs"))
866        );
867        assert_eq!(
868            multinode_crdt_path_for(&doc).unwrap(),
869            agent_doc.join("crdt").join(format!("{hash}.nodes.yrs"))
870        );
871    }
872
873    #[test]
874    fn turn_scope_path_uses_project_root_and_document_hash() {
875        let tmp = tempfile::TempDir::new().unwrap();
876        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
877        let doc = tmp.path().join("nested").join("doc.md");
878        std::fs::create_dir_all(doc.parent().unwrap()).unwrap();
879        std::fs::write(&doc, "# doc\n").unwrap();
880        let hash = document_state_hash(&doc).unwrap();
881
882        assert_eq!(
883            turn_scope_path_for(&doc).unwrap(),
884            tmp.path()
885                .join(".agent-doc")
886                .join("turn-scope")
887                .join(format!("{hash}.json"))
888        );
889    }
890
891    #[test]
892    fn cycle_state_path_uses_project_root_and_document_hash() {
893        let tmp = tempfile::TempDir::new().unwrap();
894        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
895        let doc = tmp.path().join("nested").join("doc.md");
896        std::fs::create_dir_all(doc.parent().unwrap()).unwrap();
897        std::fs::write(&doc, "# doc\n").unwrap();
898        let hash = document_state_hash(&doc).unwrap();
899
900        assert_eq!(
901            cycle_state_path_for(&doc).unwrap(),
902            Some(
903                tmp.path()
904                    .join(".agent-doc")
905                    .join("state")
906                    .join("cycles")
907                    .join(format!("{hash}.json"))
908            )
909        );
910    }
911
912    #[test]
913    fn cycle_state_path_returns_none_without_project_root() {
914        let Some(tmp) = temp_dir_without_agent_doc_ancestor() else {
915            return;
916        };
917        let doc = tmp.path().join("doc.md");
918        std::fs::write(&doc, "# doc\n").unwrap();
919
920        assert_eq!(cycle_state_path_for(&doc).unwrap(), None);
921    }
922
923    #[test]
924    fn startup_lock_paths_use_project_starting_dir() {
925        let tmp = tempfile::TempDir::new().unwrap();
926        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
927        let doc = tmp.path().join("session.md");
928        std::fs::write(&doc, "content").unwrap();
929
930        let starting_dir = startup_starting_dir_for(&doc).unwrap();
931        assert_eq!(starting_dir, tmp.path().join(".agent-doc/starting"));
932
933        let doc_hash = document_state_hash(&doc).unwrap();
934        assert_eq!(
935            startup_document_lock_path_for(&doc).unwrap(),
936            starting_dir.join(format!("{doc_hash}.lock"))
937        );
938        assert_eq!(
939            startup_session_lock_path_for(&doc, "session-a").unwrap(),
940            starting_dir.join(startup_session_lock_name("session-a"))
941        );
942    }
943
944    #[test]
945    fn cycle_state_path_returns_none_when_canonicalize_fails() {
946        let tmp = tempfile::TempDir::new().unwrap();
947        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
948        let missing = tmp.path().join("missing.md");
949
950        assert_eq!(cycle_state_path_for(&missing).unwrap(), None);
951    }
952
953    #[test]
954    fn turn_scope_path_falls_back_to_document_parent_without_project_root() {
955        let Some(tmp) = temp_dir_without_agent_doc_ancestor() else {
956            return;
957        };
958        let doc = tmp.path().join("doc.md");
959        std::fs::write(&doc, "# doc\n").unwrap();
960        let hash = document_state_hash(&doc).unwrap();
961
962        assert_eq!(
963            turn_scope_path_for(&doc).unwrap(),
964            tmp.path()
965                .join(".agent-doc")
966                .join("turn-scope")
967                .join(format!("{hash}.json"))
968        );
969    }
970
971    #[test]
972    fn flock_paths_are_adjacent_to_snapshot_and_crdt_sidecars() {
973        let tmp = tempfile::TempDir::new().unwrap();
974        std::fs::create_dir_all(tmp.path().join(".agent-doc")).unwrap();
975        let doc = tmp.path().join("doc.md");
976        std::fs::write(&doc, "# doc\n").unwrap();
977
978        assert_eq!(
979            snapshot_flock_path_for(&doc).unwrap(),
980            snapshot_path_for(&doc).unwrap().with_extension("md.lock")
981        );
982        assert_eq!(
983            crdt_flock_path_for(&doc).unwrap(),
984            crdt_path_for(&doc).unwrap().with_extension("yrs.lock")
985        );
986    }
987
988    #[test]
989    fn rewrite_start_path_narrows_to_submodule_relative() {
990        let tmp = tempfile::TempDir::new().unwrap();
991        let super_root = tmp.path();
992        let sub_root = super_root.join("src").join("sub");
993        let tasks_dir = sub_root.join("tasks");
994        std::fs::create_dir_all(&tasks_dir).unwrap();
995        let doc = tasks_dir.join("foo.md");
996        std::fs::write(&doc, "# foo\n").unwrap();
997
998        let rewritten = rewrite_start_path(&doc, &sub_root, "src/sub/tasks/foo.md");
999
1000        assert_eq!(
1001            rewritten,
1002            format!("tasks{}foo.md", std::path::MAIN_SEPARATOR)
1003        );
1004    }
1005
1006    #[test]
1007    fn rewrite_start_path_noops_when_file_path_is_already_cwd_relative() {
1008        let tmp = tempfile::TempDir::new().unwrap();
1009        let root = tmp.path();
1010        let doc = root.join("plan.md");
1011        std::fs::write(&doc, "# plan\n").unwrap();
1012
1013        assert_eq!(rewrite_start_path(&doc, root, "plan.md"), "plan.md");
1014    }
1015
1016    #[test]
1017    fn rewrite_start_path_falls_back_when_canonicalize_fails() {
1018        let tmp = tempfile::TempDir::new().unwrap();
1019        let ghost = tmp.path().join("does-not-exist.md");
1020
1021        assert_eq!(
1022            rewrite_start_path(&ghost, tmp.path(), "does-not-exist.md"),
1023            "does-not-exist.md"
1024        );
1025    }
1026
1027    #[test]
1028    fn rewrite_start_path_falls_back_when_file_not_under_cwd() {
1029        let tmp = tempfile::TempDir::new().unwrap();
1030        let outside = tmp.path().join("outside.md");
1031        std::fs::write(&outside, "# outside\n").unwrap();
1032        let unrelated_cwd = tempfile::TempDir::new().unwrap();
1033
1034        assert_eq!(
1035            rewrite_start_path(&outside, unrelated_cwd.path(), "outside.md"),
1036            "outside.md"
1037        );
1038    }
1039
1040    #[test]
1041    fn referenced_markdown_path_ignores_self_reference() {
1042        let dir = tempfile::TempDir::new().unwrap();
1043        std::fs::create_dir_all(dir.path().join(".agent-doc")).unwrap();
1044        std::fs::create_dir_all(dir.path().join("tasks")).unwrap();
1045        let file = dir.path().join("tasks/plan.md");
1046        std::fs::write(&file, "# plan\n").unwrap();
1047        assert_eq!(
1048            referenced_markdown_path(&file, "Update tasks/plan.md before closing"),
1049            None
1050        );
1051    }
1052
1053    #[test]
1054    fn referenced_markdown_path_finds_other_doc_reference() {
1055        let file = Path::new("/tmp/tasks/plan.md");
1056        let path = referenced_markdown_path(file, "Follow tasks/other-plan.md next").unwrap();
1057        assert!(path.ends_with("tasks/other-plan.md"));
1058    }
1059
1060    #[test]
1061    fn referenced_markdown_path_strips_redundant_project_prefix() {
1062        let dir = tempfile::TempDir::new().unwrap();
1063        let root = dir.path().join("agent-loop");
1064        std::fs::create_dir_all(root.join(".agent-doc")).unwrap();
1065        std::fs::create_dir_all(root.join("tasks/agent-doc")).unwrap();
1066        let current = root.join("tasks/software/tmux-router.md");
1067        let target = root.join("tasks/agent-doc/agent-doc-bugs2.md");
1068        std::fs::create_dir_all(current.parent().unwrap()).unwrap();
1069        std::fs::write(&current, "# source\n").unwrap();
1070        std::fs::write(&target, "# bugs\n").unwrap();
1071
1072        let resolved = referenced_markdown_path(
1073            &current,
1074            "Add to the backlog of agent-loop/tasks/agent-doc/agent-doc-bugs2.md",
1075        )
1076        .unwrap();
1077
1078        assert_eq!(resolved, target.canonicalize().unwrap());
1079    }
1080
1081    #[test]
1082    fn referenced_markdown_path_resolves_parent_project_prefix_from_nested_root() {
1083        let dir = tempfile::TempDir::new().unwrap();
1084        let root = dir.path().join("agent-loop");
1085        let nested = root.join("src/session-share");
1086        std::fs::create_dir_all(root.join(".agent-doc")).unwrap();
1087        std::fs::create_dir_all(nested.join(".agent-doc")).unwrap();
1088        std::fs::create_dir_all(root.join("tasks/agent-doc")).unwrap();
1089        std::fs::create_dir_all(nested.join("tasks/agent-doc")).unwrap();
1090        std::fs::create_dir_all(nested.join("tasks")).unwrap();
1091        let current = nested.join("tasks/root.md");
1092        let parent_target = root.join("tasks/agent-doc/agent-doc-bugs2.md");
1093        let nested_target = nested.join("tasks/agent-doc/agent-doc-bugs2.md");
1094        std::fs::write(&current, "# root\n").unwrap();
1095        std::fs::write(&parent_target, "# parent bugs\n").unwrap();
1096        std::fs::write(&nested_target, "# nested bugs\n").unwrap();
1097
1098        let resolved = referenced_markdown_path_checked(
1099            &current,
1100            "Add to the backlog of agent-loop/tasks/agent-doc/agent-doc-bugs2.md",
1101        )
1102        .unwrap()
1103        .unwrap();
1104
1105        assert_eq!(resolved, parent_target.canonicalize().unwrap());
1106    }
1107
1108    #[test]
1109    fn referenced_markdown_path_fails_on_ambiguous_nested_task_tree() {
1110        let dir = tempfile::TempDir::new().unwrap();
1111        let root = dir.path().join("agent-loop");
1112        let nested = root.join("src/session-share");
1113        std::fs::create_dir_all(root.join(".agent-doc")).unwrap();
1114        std::fs::create_dir_all(nested.join(".agent-doc")).unwrap();
1115        std::fs::create_dir_all(root.join("tasks/agent-doc")).unwrap();
1116        std::fs::create_dir_all(nested.join("tasks/agent-doc")).unwrap();
1117        std::fs::create_dir_all(nested.join("tasks")).unwrap();
1118        let current = nested.join("tasks/root.md");
1119        std::fs::write(&current, "# root\n").unwrap();
1120        std::fs::write(
1121            root.join("tasks/agent-doc/agent-doc-bugs2.md"),
1122            "# parent bugs\n",
1123        )
1124        .unwrap();
1125        std::fs::write(
1126            nested.join("tasks/agent-doc/agent-doc-bugs2.md"),
1127            "# nested bugs\n",
1128        )
1129        .unwrap();
1130
1131        let err = referenced_markdown_path_checked(
1132            &current,
1133            "Add to the backlog of tasks/agent-doc/agent-doc-bugs2.md",
1134        )
1135        .unwrap_err();
1136
1137        assert!(
1138            err.to_string().contains("ambiguous markdown reference"),
1139            "{err:#}"
1140        );
1141    }
1142
1143    #[test]
1144    fn referenced_markdown_path_fails_missing_project_prefixed_target() {
1145        let dir = tempfile::TempDir::new().unwrap();
1146        let root = dir.path().join("agent-loop");
1147        let nested = root.join("src/session-share");
1148        std::fs::create_dir_all(root.join(".agent-doc")).unwrap();
1149        std::fs::create_dir_all(nested.join(".agent-doc")).unwrap();
1150        std::fs::create_dir_all(nested.join("tasks")).unwrap();
1151        let current = nested.join("tasks/root.md");
1152        std::fs::write(&current, "# root\n").unwrap();
1153
1154        let err = referenced_markdown_path_checked(
1155            &current,
1156            "Add to the backlog of agent-loop/tasks/agent-doc/missing.md",
1157        )
1158        .unwrap_err();
1159
1160        assert!(
1161            err.to_string()
1162                .contains("project-prefixed markdown reference"),
1163            "{err:#}"
1164        );
1165    }
1166}