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
20pub 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
32pub fn find_project_root_canonical(path: &Path) -> Option<PathBuf> {
35 let canonical = path.canonicalize().ok()?;
36 find_project_root(&canonical)
37}
38
39pub 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
47pub fn document_state_hash_from_str(absolute_path: &str) -> String {
52 agent_doc_hash::path_string_hash(absolute_path)
53}
54
55pub 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
71pub fn state_lock_path_for(doc: &Path) -> Result<PathBuf> {
73 hashed_state_path(doc, LOCK_DIR, "lock")
74}
75
76pub fn pending_response_path_for(doc: &Path) -> Result<PathBuf> {
78 hashed_state_path(doc, PENDING_DIR, "md")
79}
80
81pub fn turn_scope_path_for(doc: &Path) -> Result<PathBuf> {
83 hashed_state_path(doc, TURN_SCOPE_DIR, "json")
84}
85
86pub fn disk_change_request_path_for(doc: &Path) -> Result<PathBuf> {
92 hashed_state_path(doc, DISK_CHANGE_REQUEST_DIR, "json")
93}
94
95pub fn crdt_replica_event_path_for(doc: &Path) -> Result<PathBuf> {
100 hashed_state_path(doc, CRDT_REPLICA_EVENT_DIR, "json")
101}
102
103pub 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
121pub 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
132pub 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
138pub 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
150pub 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
155pub fn baseline_path_for(doc: &Path) -> Result<PathBuf> {
157 hashed_state_path(doc, BASELINE_DIR, "md")
158}
159
160pub 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
168pub fn pre_response_path_for(doc: &Path) -> Result<PathBuf> {
170 hashed_state_path(doc, PRE_RESPONSE_DIR, "md")
171}
172
173pub fn crdt_path_for(doc: &Path) -> Result<PathBuf> {
175 hashed_state_path(doc, CRDT_DIR, "yrs")
176}
177
178pub fn overlay_crdt_path_for(doc: &Path) -> Result<PathBuf> {
180 hashed_state_path_with_suffix(doc, CRDT_DIR, "overlay.yrs")
181}
182
183pub fn multinode_crdt_path_for(doc: &Path) -> Result<PathBuf> {
185 hashed_state_path_with_suffix(doc, CRDT_DIR, "nodes.yrs")
186}
187
188pub fn snapshot_flock_path_for(doc: &Path) -> Result<PathBuf> {
190 Ok(snapshot_path_for(doc)?.with_extension("md.lock"))
191}
192
193pub fn crdt_flock_path_for(doc: &Path) -> Result<PathBuf> {
195 Ok(crdt_path_for(doc)?.with_extension("yrs.lock"))
196}
197
198pub 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
217pub 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
238pub 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
361static ATOMIC_WRITE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
364
365pub 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 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
423pub 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
450pub 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
481pub 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(¤t));
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 #[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(); 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 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 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(¤t, "# source\n").unwrap();
1070 std::fs::write(&target, "# bugs\n").unwrap();
1071
1072 let resolved = referenced_markdown_path(
1073 ¤t,
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(¤t, "# 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 ¤t,
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(¤t, "# 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 ¤t,
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(¤t, "# root\n").unwrap();
1153
1154 let err = referenced_markdown_path_checked(
1155 ¤t,
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}