use agentdir::backend::SourceEvent;
use agentdir::reconciler::Reconciler;
use agentdir::types::{SourcePath, VirtualPath};
use agentdir::workspace::Workspace;
use tempfile::TempDir;
fn vp(path: &str) -> VirtualPath {
VirtualPath::new(path).unwrap()
}
async fn mapped_workspace(initial: &[u8]) -> (TempDir, TempDir, Workspace) {
let src = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
std::fs::write(src.path().join("note.txt"), initial).unwrap();
let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
ws.map(SourcePath::new(src.path().to_path_buf()), vp("/docs"))
.await
.unwrap();
(src, ws_dir, ws)
}
#[tokio::test]
async fn refreshes_existing_mapping_when_atomic_save_emits_delete_then_create() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
let source_path = SourcePath::new(src.path().join("note.txt"));
std::fs::write(src.path().join("note.txt"), b"after atomic save").unwrap();
let events = [
SourceEvent::Deleted {
path: source_path.clone(),
},
SourceEvent::Created { path: source_path },
];
let actions = events
.iter()
.flat_map(|event| Reconciler::from_event(&ws.catalog, event).unwrap())
.collect::<Vec<_>>();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(
summary.errors.is_empty(),
"atomic save delete/create should not preflight-fail: {:?}",
summary.errors
);
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 0);
assert_eq!(summary.refreshed, 1);
assert_eq!(
std::fs::read(ws_dir.path().join("docs/note.txt")).unwrap(),
b"after atomic save"
);
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_ok());
}
#[tokio::test]
async fn refreshes_existing_mapping_when_atomic_save_emits_temp_rename_to_tracked_path() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
let temp_path = SourcePath::new(src.path().join(".note.txt.tmp"));
let target_path = SourcePath::new(src.path().join("note.txt"));
std::fs::write(src.path().join("note.txt"), b"after rename save").unwrap();
let event = SourceEvent::Renamed {
from: temp_path,
to: target_path,
};
let actions = Reconciler::from_event(&ws.catalog, &event).unwrap();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(
summary.errors.is_empty(),
"temp rename into tracked path should not duplicate-add: {:?}",
summary.errors
);
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 0);
assert_eq!(summary.refreshed, 1);
assert_eq!(
std::fs::read(ws_dir.path().join("docs/note.txt")).unwrap(),
b"after rename save"
);
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_ok());
}
#[tokio::test]
async fn ignores_transient_temp_create_when_atomic_save_renames_it_to_tracked_path() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
let temp_path = SourcePath::new(src.path().join(".note.txt.tmp"));
let target_path = SourcePath::new(src.path().join("note.txt"));
std::fs::write(src.path().join("note.txt"), b"after temp create rename").unwrap();
let events = [
SourceEvent::Created {
path: temp_path.clone(),
},
SourceEvent::Renamed {
from: temp_path,
to: target_path,
},
];
let actions = events
.iter()
.flat_map(|event| Reconciler::from_event(&ws.catalog, event).unwrap())
.collect::<Vec<_>>();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(
summary.errors.is_empty(),
"transient temp create should not block target refresh: {:?}",
summary.errors
);
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 0);
assert_eq!(summary.refreshed, 1);
assert_eq!(
std::fs::read(ws_dir.path().join("docs/note.txt")).unwrap(),
b"after temp create rename"
);
assert!(ws.catalog.get(&vp("/docs/.note.txt.tmp")).is_err());
assert!(!ws_dir.path().join("docs/.note.txt.tmp").exists());
}
#[tokio::test]
async fn refreshes_all_virtual_aliases_when_atomic_save_replaces_copied_source() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
ws.cp(&vp("/docs/note.txt"), &vp("/copies/note.txt"))
.unwrap();
let source_path = SourcePath::new(src.path().join("note.txt"));
std::fs::write(src.path().join("note.txt"), b"after copied autosave").unwrap();
let events = [
SourceEvent::Deleted {
path: source_path.clone(),
},
SourceEvent::Created { path: source_path },
];
let actions = events
.iter()
.flat_map(|event| Reconciler::from_event(&ws.catalog, event).unwrap())
.collect::<Vec<_>>();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(summary.errors.is_empty());
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 0);
assert_eq!(summary.refreshed, 2);
for path in ["docs/note.txt", "copies/note.txt"] {
assert_eq!(
std::fs::read(ws_dir.path().join(path)).unwrap(),
b"after copied autosave"
);
}
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_ok());
assert!(ws.catalog.get(&vp("/copies/note.txt")).is_ok());
}
#[tokio::test]
async fn preserves_virtual_move_when_atomic_save_replaces_source() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
ws.mv(&vp("/docs/note.txt"), &vp("/renamed/note.txt"))
.unwrap();
let source_path = SourcePath::new(src.path().join("note.txt"));
std::fs::write(src.path().join("note.txt"), b"after moved autosave").unwrap();
let events = [
SourceEvent::Deleted {
path: source_path.clone(),
},
SourceEvent::Created { path: source_path },
];
let actions = events
.iter()
.flat_map(|event| Reconciler::from_event(&ws.catalog, event).unwrap())
.collect::<Vec<_>>();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(summary.errors.is_empty());
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 0);
assert_eq!(summary.refreshed, 1);
assert_eq!(
std::fs::read(ws_dir.path().join("renamed/note.txt")).unwrap(),
b"after moved autosave"
);
assert!(ws.catalog.get(&vp("/renamed/note.txt")).is_ok());
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_err());
assert!(!ws_dir.path().join("docs/note.txt").exists());
}
#[tokio::test]
async fn still_removes_mapping_when_editor_delete_is_not_recreated() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
let source_path = SourcePath::new(src.path().join("note.txt"));
std::fs::remove_file(src.path().join("note.txt")).unwrap();
let event = SourceEvent::Deleted { path: source_path };
let actions = Reconciler::from_event(&ws.catalog, &event).unwrap();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(summary.errors.is_empty());
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 1);
assert_eq!(summary.refreshed, 0);
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_err());
assert!(!ws_dir.path().join("docs/note.txt").exists());
}
#[tokio::test]
async fn still_removes_mapping_when_stale_create_is_followed_by_delete() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
let source_path = SourcePath::new(src.path().join("note.txt"));
std::fs::remove_file(src.path().join("note.txt")).unwrap();
let events = [
SourceEvent::Created {
path: source_path.clone(),
},
SourceEvent::Deleted { path: source_path },
];
let actions = events
.iter()
.flat_map(|event| Reconciler::from_event(&ws.catalog, event).unwrap())
.collect::<Vec<_>>();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(summary.errors.is_empty());
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 1);
assert_eq!(summary.refreshed, 0);
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_err());
assert!(!ws_dir.path().join("docs/note.txt").exists());
}
#[tokio::test]
async fn still_removes_mapping_when_stale_modify_is_followed_by_delete() {
let (src, ws_dir, mut ws) = mapped_workspace(b"before").await;
let source_path = SourcePath::new(src.path().join("note.txt"));
std::fs::remove_file(src.path().join("note.txt")).unwrap();
let events = [
SourceEvent::Modified {
path: source_path.clone(),
},
SourceEvent::Deleted { path: source_path },
];
let actions = events
.iter()
.flat_map(|event| Reconciler::from_event(&ws.catalog, event).unwrap())
.collect::<Vec<_>>();
let summary = Reconciler::apply_actions(&mut ws.catalog, &ws.materializer, &actions).unwrap();
assert!(summary.errors.is_empty());
assert_eq!(summary.added, 0);
assert_eq!(summary.removed, 1);
assert_eq!(summary.refreshed, 0);
assert!(ws.catalog.get(&vp("/docs/note.txt")).is_err());
assert!(!ws_dir.path().join("docs/note.txt").exists());
}