agentdir 0.1.5

Virtual filesystem for agent-optimized file exploration using CoW reflinks
Documentation
use agentdir::types::{MappingDirection, SourcePath, VirtualPath};
use agentdir::workspace::Workspace;
use tempfile::TempDir;

fn create_source_files(dir: &std::path::Path) {
    std::fs::write(dir.join("file1.txt"), b"hello").unwrap();
    std::fs::write(dir.join("file2.txt"), b"world").unwrap();
    std::fs::create_dir(dir.join("subdir")).unwrap();
    std::fs::write(dir.join("subdir/nested.txt"), b"nested").unwrap();
}

#[tokio::test]
async fn test_export_source_to_virtual() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    create_source_files(src.path());

    let canonical_src = src.path().canonicalize().unwrap();
    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(canonical_src.clone()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::SourceToVirtual, None)
        .unwrap();
    assert_eq!(mapping.len(), 3);

    let src_key = canonical_src
        .join("file1.txt")
        .to_string_lossy()
        .to_string();
    assert_eq!(mapping.get(&src_key).unwrap(), "/docs/file1.txt");
}

#[tokio::test]
async fn test_export_virtual_to_source() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    create_source_files(src.path());

    let canonical_src = src.path().canonicalize().unwrap();
    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(canonical_src.clone()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::VirtualToSource, None)
        .unwrap();
    assert_eq!(mapping.len(), 3);

    let expected_source = canonical_src
        .join("file1.txt")
        .to_string_lossy()
        .to_string();
    assert_eq!(mapping.get("/docs/file1.txt").unwrap(), &expected_source);
}

#[tokio::test]
async fn test_export_relative_to() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    create_source_files(src.path());

    let canonical_src = src.path().canonicalize().unwrap();
    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(canonical_src.clone()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::SourceToVirtual, Some(&canonical_src))
        .unwrap();
    assert_eq!(mapping.len(), 3);
    assert_eq!(mapping.get("file1.txt").unwrap(), "/docs/file1.txt");
    assert_eq!(
        mapping.get("subdir/nested.txt").unwrap(),
        "/docs/subdir/nested.txt"
    );
}

#[tokio::test]
async fn test_export_empty_workspace() {
    let ws_dir = TempDir::new().unwrap();
    let ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::SourceToVirtual, None)
        .unwrap();
    assert!(mapping.is_empty());
}

#[tokio::test]
async fn test_export_after_mv() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    std::fs::write(src.path().join("old.txt"), b"content").unwrap();

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src.path().canonicalize().unwrap()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    ws.mv(
        &VirtualPath::new("/docs/old.txt").unwrap(),
        &VirtualPath::new("/docs/new.txt").unwrap(),
    )
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::VirtualToSource, None)
        .unwrap();
    assert!(mapping.contains_key("/docs/new.txt"));
    assert!(!mapping.contains_key("/docs/old.txt"));
}

#[tokio::test]
async fn test_export_directories_excluded() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    std::fs::write(src.path().join("file.txt"), b"content").unwrap();

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src.path().canonicalize().unwrap()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    ws.mkdir(&VirtualPath::new("/extra_dir").unwrap()).unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::SourceToVirtual, None)
        .unwrap();
    for value in mapping.values() {
        assert_ne!(value, "/extra_dir");
    }
    assert_eq!(mapping.len(), 1);
}

#[tokio::test]
async fn test_export_multi_source() {
    let src1 = TempDir::new().unwrap();
    let src2 = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    std::fs::write(src1.path().join("a.txt"), b"aaa").unwrap();
    std::fs::write(src2.path().join("b.txt"), b"bbb").unwrap();

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src1.path().canonicalize().unwrap()),
        VirtualPath::new("/src1").unwrap(),
    )
    .await
    .unwrap();
    ws.map(
        SourcePath::new(src2.path().canonicalize().unwrap()),
        VirtualPath::new("/src2").unwrap(),
    )
    .await
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::VirtualToSource, None)
        .unwrap();
    assert_eq!(mapping.len(), 2);
    assert!(mapping.contains_key("/src1/a.txt"));
    assert!(mapping.contains_key("/src2/b.txt"));
}

#[tokio::test]
async fn test_export_deterministic_order() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    for name in ["z.txt", "a.txt", "m.txt"] {
        std::fs::write(src.path().join(name), name.as_bytes()).unwrap();
    }

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src.path().canonicalize().unwrap()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::VirtualToSource, None)
        .unwrap();
    let keys: Vec<&String> = mapping.keys().collect();
    let mut sorted_keys = keys.clone();
    sorted_keys.sort();
    assert_eq!(keys, sorted_keys);
}

#[tokio::test]
async fn test_export_source_to_virtual_rejects_cp_duplicates() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    std::fs::write(src.path().join("file.txt"), b"content").unwrap();

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src.path().canonicalize().unwrap()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    ws.cp(
        &VirtualPath::new("/docs/file.txt").unwrap(),
        &VirtualPath::new("/backup/file.txt").unwrap(),
    )
    .unwrap();

    let result = ws.export_mapping(MappingDirection::SourceToVirtual, None);
    assert!(result.is_err());
}

#[tokio::test]
async fn test_export_virtual_to_source_works_after_cp() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    std::fs::write(src.path().join("file.txt"), b"content").unwrap();

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src.path().canonicalize().unwrap()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    ws.cp(
        &VirtualPath::new("/docs/file.txt").unwrap(),
        &VirtualPath::new("/backup/file.txt").unwrap(),
    )
    .unwrap();

    let mapping = ws
        .export_mapping(MappingDirection::VirtualToSource, None)
        .unwrap();
    assert_eq!(mapping.len(), 2);
    assert!(mapping.contains_key("/docs/file.txt"));
    assert!(mapping.contains_key("/backup/file.txt"));
}

#[tokio::test]
async fn test_export_invalid_relative_to_errors() {
    let src = TempDir::new().unwrap();
    let ws_dir = TempDir::new().unwrap();
    std::fs::write(src.path().join("file.txt"), b"content").unwrap();

    let mut ws = Workspace::init(ws_dir.path().to_path_buf()).unwrap();
    ws.map(
        SourcePath::new(src.path().canonicalize().unwrap()),
        VirtualPath::new("/docs").unwrap(),
    )
    .await
    .unwrap();

    let result = ws.export_mapping(
        MappingDirection::SourceToVirtual,
        Some(std::path::Path::new(
            "/nonexistent/path/that/does/not/exist",
        )),
    );
    assert!(result.is_err());
}