collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use std::fs;
use tempfile::tempdir;

use collet::tools::registry;

// ---------------------------------------------------------------------------
// Bash tool
// ---------------------------------------------------------------------------

#[tokio::test]
async fn bash_echo_hello() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let result = registry::dispatch("bash", r#"{"command": "echo hello"}"#, wd)
        .await
        .unwrap();

    let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert_eq!(parsed["exit_code"], 0);
    assert_eq!(parsed["stdout"].as_str().unwrap().trim(), "hello");
}

#[tokio::test]
async fn bash_nonzero_exit_code() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let result = registry::dispatch("bash", r#"{"command": "exit 42"}"#, wd)
        .await
        .unwrap();

    let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert_eq!(parsed["exit_code"], 42);
}

#[tokio::test]
async fn bash_captures_stderr() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let result = registry::dispatch("bash", r#"{"command": "echo oops >&2"}"#, wd)
        .await
        .unwrap();

    let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert!(parsed["stderr"].as_str().unwrap().contains("oops"));
}

#[tokio::test]
async fn bash_runs_in_working_dir() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let result = registry::dispatch("bash", r#"{"command": "pwd"}"#, wd)
        .await
        .unwrap();

    let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
    let stdout = parsed["stdout"].as_str().unwrap().trim();
    // Resolve symlinks (macOS /tmp -> /private/tmp)
    let expected = fs::canonicalize(dir.path()).unwrap();
    let actual = fs::canonicalize(stdout).unwrap();
    assert_eq!(actual, expected);
}

// ---------------------------------------------------------------------------
// File write -> file read round-trip
// ---------------------------------------------------------------------------

#[tokio::test]
async fn file_write_then_read_round_trip() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    // Write a file
    let write_args = serde_json::json!({
        "path": "test_file.txt",
        "content": "line one\nline two\nline three\n"
    });
    let write_result = registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();
    assert!(write_result.contains("Successfully wrote"));

    // Read it back
    let read_args = serde_json::json!({ "path": "test_file.txt" });
    let read_result = registry::dispatch("file_read", &read_args.to_string(), wd)
        .await
        .unwrap();

    assert!(read_result.contains("line one"));
    assert!(read_result.contains("line two"));
    assert!(read_result.contains("line three"));
}

#[tokio::test]
async fn file_write_creates_subdirectories() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let write_args = serde_json::json!({
        "path": "deep/nested/dir/file.txt",
        "content": "nested content"
    });
    let result = registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();
    assert!(result.contains("Successfully wrote"));

    // Verify the file was actually created
    let read_args = serde_json::json!({ "path": "deep/nested/dir/file.txt" });
    let content = registry::dispatch("file_read", &read_args.to_string(), wd)
        .await
        .unwrap();
    assert!(content.contains("nested content"));
}

#[tokio::test]
async fn file_read_with_offset_and_limit() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let write_args = serde_json::json!({
        "path": "lines.txt",
        "content": "alpha\nbeta\ngamma\ndelta\nepsilon\n"
    });
    registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();

    // Read lines 2-3 (offset=2, limit=2)
    let read_args = serde_json::json!({
        "path": "lines.txt",
        "offset": 2,
        "limit": 2
    });
    let result = registry::dispatch("file_read", &read_args.to_string(), wd)
        .await
        .unwrap();

    assert!(result.contains("beta"));
    assert!(result.contains("gamma"));
    assert!(!result.contains("alpha"), "offset should skip first line");
}

#[tokio::test]
async fn file_read_nonexistent_returns_error() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let read_args = serde_json::json!({ "path": "nonexistent.txt" });
    let result = registry::dispatch("file_read", &read_args.to_string(), wd).await;
    assert!(result.is_err());
}

// ---------------------------------------------------------------------------
// File edit
// ---------------------------------------------------------------------------

#[tokio::test]
async fn file_edit_replaces_exact_string() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    // Create a file
    let write_args = serde_json::json!({
        "path": "editable.txt",
        "content": "Hello, World!\nThis is a test.\nGoodbye.\n"
    });
    registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();

    // Edit it
    let edit_args = serde_json::json!({
        "path": "editable.txt",
        "old_string": "This is a test.",
        "new_string": "This has been edited."
    });
    let result = registry::dispatch("file_edit", &edit_args.to_string(), wd)
        .await
        .unwrap();
    assert!(result.contains("Successfully edited"));

    // Verify the edit
    let read_args = serde_json::json!({ "path": "editable.txt" });
    let content = registry::dispatch("file_read", &read_args.to_string(), wd)
        .await
        .unwrap();
    assert!(content.contains("This has been edited."));
    assert!(!content.contains("This is a test."));
    // Surrounding lines should be untouched
    assert!(content.contains("Hello, World!"));
    assert!(content.contains("Goodbye."));
}

#[tokio::test]
async fn file_edit_fails_when_old_string_not_found() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let write_args = serde_json::json!({
        "path": "stable.txt",
        "content": "original content\n"
    });
    registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();

    let edit_args = serde_json::json!({
        "path": "stable.txt",
        "old_string": "this does not exist",
        "new_string": "replacement"
    });
    let result = registry::dispatch("file_edit", &edit_args.to_string(), wd).await;
    assert!(
        result.is_err(),
        "edit should fail when old_string is not found"
    );
}

#[tokio::test]
async fn file_edit_fails_when_old_string_is_ambiguous() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let write_args = serde_json::json!({
        "path": "ambiguous.txt",
        "content": "foo bar foo\n"
    });
    registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();

    let edit_args = serde_json::json!({
        "path": "ambiguous.txt",
        "old_string": "foo",
        "new_string": "baz"
    });
    let result = registry::dispatch("file_edit", &edit_args.to_string(), wd).await;
    assert!(
        result.is_err(),
        "edit should fail when old_string matches multiple times"
    );
}

// ---------------------------------------------------------------------------
// Search tool
// ---------------------------------------------------------------------------

#[tokio::test]
async fn search_finds_pattern_in_files() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    fs::write(
        dir.path().join("a.txt"),
        "the quick brown fox\njumps over\n",
    )
    .unwrap();
    fs::write(dir.path().join("b.txt"), "the lazy dog\nfox trot\n").unwrap();

    let args = serde_json::json!({
        "pattern": "fox",
        "path": wd
    });
    let result = registry::dispatch("search", &args.to_string(), wd)
        .await
        .unwrap();

    assert!(result.contains("fox"), "search should find 'fox'");
    // Should have matches from both files
    assert!(result.contains("a.txt"));
    assert!(result.contains("b.txt"));
}

#[tokio::test]
async fn search_with_glob_filter() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    fs::write(dir.path().join("code.rs"), "fn main() { todo!() }\n").unwrap();
    fs::write(dir.path().join("notes.txt"), "todo: review code\n").unwrap();

    let args = serde_json::json!({
        "pattern": "todo",
        "path": wd,
        "glob": "*.rs"
    });
    let result = registry::dispatch("search", &args.to_string(), wd)
        .await
        .unwrap();

    assert!(result.contains("code.rs"), "should find match in .rs file");
    assert!(
        !result.contains("notes.txt"),
        "glob filter should exclude .txt files"
    );
}

#[tokio::test]
async fn search_no_matches_returns_no_matches_message() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    fs::write(dir.path().join("file.txt"), "hello world\n").unwrap();

    let args = serde_json::json!({
        "pattern": "zzz_nonexistent_pattern_zzz",
        "path": wd
    });
    let result = registry::dispatch("search", &args.to_string(), wd)
        .await
        .unwrap();

    assert!(
        result.contains("No matches found"),
        "should indicate no matches"
    );
}

// ---------------------------------------------------------------------------
// Tool registry: dispatch routing
// ---------------------------------------------------------------------------

#[tokio::test]
async fn dispatch_unknown_tool_returns_error() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();

    let result = registry::dispatch("nonexistent_tool", "{}", wd).await;
    assert!(result.is_err());
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("Unknown tool"),
        "error should mention unknown tool"
    );
}

#[test]
fn all_tool_definitions_is_nonempty() {
    let defs = registry::all_tool_definitions(false, false);
    assert!(defs.len() >= 5, "should have at least 5 tool definitions");

    // Each definition should have a function name
    for def in &defs {
        let name = def["function"]["name"].as_str();
        assert!(
            name.is_some(),
            "each tool definition should have a function name"
        );
    }
}

#[test]
fn tool_definitions_contain_expected_tools() {
    let defs = registry::all_tool_definitions(false, false);
    let names: Vec<String> = defs
        .iter()
        .filter_map(|d| d["function"]["name"].as_str().map(String::from))
        .collect();

    assert!(names.contains(&"bash".to_string()));
    assert!(names.contains(&"file_read".to_string()));
    assert!(names.contains(&"file_write".to_string()));
    assert!(names.contains(&"file_edit".to_string()));
    assert!(names.contains(&"search".to_string()));
}

// ---------------------------------------------------------------------------
// File write with absolute path
// ---------------------------------------------------------------------------

#[tokio::test]
async fn file_write_and_read_with_absolute_path() {
    let dir = tempdir().unwrap();
    let wd = dir.path().to_str().unwrap();
    let abs_path = dir.path().join("abs_test.txt");

    let write_args = serde_json::json!({
        "path": abs_path.to_str().unwrap(),
        "content": "absolute path content"
    });
    registry::dispatch("file_write", &write_args.to_string(), wd)
        .await
        .unwrap();

    let read_args = serde_json::json!({
        "path": abs_path.to_str().unwrap()
    });
    let content = registry::dispatch("file_read", &read_args.to_string(), wd)
        .await
        .unwrap();
    assert!(content.contains("absolute path content"));
}