codewhale-tui 0.9.3

Terminal UI for open-source and open-weight coding models
use super::*;
use serde_json::json;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::tempdir;

#[test]
fn test_tool_result_success() {
    let result = ToolResult::success("hello");
    assert!(result.success);
    assert_eq!(result.content, "hello");
    assert!(result.metadata.is_none());
}

#[test]
fn test_tool_result_error() {
    let result = ToolResult::error("something failed");
    assert!(!result.success);
    assert_eq!(result.content, "something failed");
}

#[test]
fn test_tool_result_json() {
    let data = json!({"key": "value"});
    let result = ToolResult::json(&data).unwrap();
    assert!(result.success);
    assert!(result.content.contains("key"));
}

#[test]
fn test_tool_result_with_metadata() {
    let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
    assert!(result.metadata.is_some());
}

#[test]
fn test_tool_context_resolve_path_relative() {
    let tmp = tempdir().expect("tempdir");
    let ctx = ToolContext::new(tmp.path().to_path_buf());

    // Create a test file
    let test_file = tmp.path().join("test.txt");
    std::fs::write(&test_file, "test").expect("write");

    let resolved = ctx.resolve_path("test.txt").expect("resolve");
    assert!(resolved.ends_with("test.txt"));
}

#[test]
fn test_tool_context_resolve_path_escape() {
    let tmp = tempdir().expect("tempdir");
    let ctx = ToolContext::new(tmp.path().to_path_buf());

    // Try to escape workspace
    let result = ctx.resolve_path("/etc/passwd");
    assert!(result.is_err());
}

#[test]
fn test_tool_context_resolve_path_parent_traversal() {
    let tmp = tempdir().expect("tempdir");
    let ctx = ToolContext::new(tmp.path().to_path_buf());

    let result = ctx.resolve_path("../escape.txt");
    assert!(result.is_err());
}

#[test]
fn test_tool_context_resolve_path_normalizes_parent() {
    let tmp = tempdir().expect("tempdir");
    let ctx = ToolContext::new(tmp.path().to_path_buf());

    let result = ctx.resolve_path("new/../safe.txt");
    assert!(result.is_ok());
}

#[test]
fn test_tool_context_trust_mode() {
    let tmp = tempdir().expect("tempdir");
    let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);

    // In trust mode, absolute paths should work
    let result = ctx.resolve_path("/tmp");
    assert!(result.is_ok());
}

#[test]
fn tool_context_keeps_execution_state_grouped_and_value_cloned() {
    let mut context = ToolContext::new(".");
    context.auto_approve = true;
    context.state_namespace = "session-a".to_string();

    assert!(context.execution.auto_approve);
    assert_eq!(context.execution.state_namespace, "session-a");

    let mut cloned = context.clone();
    cloned.state_namespace = "session-b".to_string();
    assert_eq!(context.state_namespace, "session-a");
    assert_eq!(cloned.execution.state_namespace, "session-b");
}

#[test]
fn tool_context_top_level_stays_slim_as_services_grow() {
    assert!(
        std::mem::size_of::<ToolContext>()
            <= std::mem::size_of::<PathBuf>() + 2 * std::mem::size_of::<usize>(),
        "ToolContext should contain only the workspace and boxed execution group"
    );
}

/// Issue #29: paths under a user-trusted external directory resolve
/// successfully even though they fall outside the workspace, while
/// untrusted external paths still error with `PathEscape`.
#[test]
fn test_tool_context_trusted_external_path_allows_escape() {
    let workspace = tempdir().expect("workspace tempdir");
    let trusted_root = tempdir().expect("trusted tempdir");
    let trusted_file = trusted_root.path().join("notes.md");
    std::fs::write(&trusted_file, "shared notes").unwrap();

    let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
        trusted_root
            .path()
            .canonicalize()
            .unwrap_or_else(|_| trusted_root.path().to_path_buf()),
    ]);

    let resolved = ctx
        .resolve_path(trusted_file.to_str().unwrap())
        .expect("trusted path should resolve");
    assert!(resolved.ends_with("notes.md"));

    // Path outside workspace AND outside the trust list should still fail.
    let other = tempdir().expect("untrusted tempdir");
    let other_file = other.path().join("secret.md");
    std::fs::write(&other_file, "x").unwrap();
    let err = ctx
        .resolve_path(other_file.to_str().unwrap())
        .expect_err("untrusted path must error");
    assert!(matches!(err, ToolError::PathEscape { .. }));
}

#[test]
#[cfg(unix)]
fn test_tool_context_follow_symlinks_allows_nonexistent_path_under_workspace_symlink() {
    let tmp = tempdir().expect("tempdir");
    let workspace = tmp.path().join("workspace");
    let outside = tmp.path().join("outside");
    std::fs::create_dir_all(&workspace).expect("mkdir workspace");
    std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
    symlink(outside.join("target"), workspace.join("linked")).expect("symlink");

    let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
    let resolved = ctx
        .resolve_path("linked/new.txt")
        .expect("path under workspace symlink should resolve");

    let expected = outside
        .join("target")
        .canonicalize()
        .expect("canonical target")
        .join("new.txt");
    assert_eq!(resolved, normalize_path(&expected));
}

#[test]
#[cfg(unix)]
fn test_tool_context_default_mode_rejects_nonexistent_path_under_workspace_symlink() {
    let tmp = tempdir().expect("tempdir");
    let workspace = tmp.path().join("workspace");
    let outside = tmp.path().join("outside");
    std::fs::create_dir_all(&workspace).expect("mkdir workspace");
    std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
    symlink(outside.join("target"), workspace.join("linked")).expect("symlink");

    let ctx = ToolContext::new(workspace);
    let err = ctx
        .resolve_path("linked/new.txt")
        .expect_err("default mode should still reject workspace symlink escapes");

    assert!(matches!(err, ToolError::PathEscape { .. }));
}

fn scoped_authority(roots: &[&str], files: &[&str]) -> ToolAuthorityEnvelope {
    ToolAuthorityEnvelope {
        schema_version: 1,
        owner: "fleet-worker-1".to_string(),
        authority: ToolMutationAuthority::ScopedWrite,
        network_access: None,
        writable_roots: roots.iter().map(|value| (*value).to_string()).collect(),
        writable_files: files.iter().map(|value| (*value).to_string()).collect(),
        coordination_contracts: Vec::new(),
    }
    .normalized()
    .expect("valid test authority")
}

#[test]
fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() {
    let tmp = tempdir().expect("tempdir");
    std::fs::create_dir(tmp.path().join("src")).expect("src");
    let context = ToolContext::new(tmp.path().to_path_buf());
    let authority = scoped_authority(&["src"], &[]);

    assert!(
        authority
            .permits_mutation_path(&context, "src/new/nested.rs")
            .expect("normal nonexistent child")
    );
    assert!(
        !authority
            .permits_mutation_path(&context, "docs/outside.md")
            .expect("ordinary out-of-scope path")
    );
}

#[cfg(unix)]
#[test]
fn tool_authority_rejects_exact_file_symlink_aliases() {
    let tmp = tempdir().expect("tempdir");
    std::fs::create_dir(tmp.path().join("src")).expect("src");
    std::fs::create_dir(tmp.path().join("other")).expect("other");
    std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target");
    symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias");
    let context = ToolContext::new(tmp.path().to_path_buf());
    let authority = scoped_authority(&[], &["src/alias.rs"]);

    let error = authority
        .permits_mutation_path(&context, "src/alias.rs")
        .expect_err("an exact-file claim must not authorize a symlink target")
        .to_string();
    assert!(error.contains("must not traverse symlinks"), "{error}");
}

#[cfg(unix)]
#[test]
fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() {
    let tmp = tempdir().expect("tempdir");
    std::fs::create_dir(tmp.path().join("real")).expect("real");
    symlink("real", tmp.path().join("linked")).expect("linked root");
    let context = ToolContext::new(tmp.path().to_path_buf());
    let claimed_alias = scoped_authority(&["linked"], &[]);
    let claimed_real = scoped_authority(&["real"], &[]);

    for (authority, path) in [
        (&claimed_alias, "linked/new.rs"),
        (&claimed_real, "linked/new.rs"),
    ] {
        let error = authority
            .permits_mutation_path(&context, path)
            .expect_err("symlinked roots and mutation paths must fail closed")
            .to_string();
        assert!(error.contains("must not traverse symlinks"), "{error}");
    }
}

#[test]
fn nested_tool_authority_may_only_narrow_the_outer_cap() {
    let tmp = tempdir().expect("tempdir");
    let outer = scoped_authority(&["src"], &["Cargo.toml"]);
    let narrower = scoped_authority(&["src/parser"], &[]);
    let expansion = scoped_authority(&["docs"], &[]);
    ToolContext::new(tmp.path().to_path_buf())
        .with_tool_authority(outer.clone())
        .unwrap()
        .with_tool_authority(narrower)
        .expect("nested scope may narrow");
    let error = ToolContext::new(tmp.path().to_path_buf())
        .with_tool_authority(outer.clone())
        .unwrap()
        .with_tool_authority(expansion)
        .err()
        .expect("nested scope expansion must fail closed");
    assert!(error.contains("cannot expand"), "{error}");

    let read_only = ToolAuthorityEnvelope {
        schema_version: 1,
        owner: "read-only-child".to_string(),
        authority: ToolMutationAuthority::ReadOnly,
        network_access: None,
        writable_roots: Vec::new(),
        writable_files: Vec::new(),
        coordination_contracts: Vec::new(),
    };
    ToolContext::new(tmp.path().to_path_buf())
        .with_tool_authority(outer)
        .unwrap()
        .with_tool_authority(read_only)
        .expect("read-only always narrows a write cap");
}

#[test]
fn process_tool_authority_inherits_into_all_context_constructors() {
    const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD";
    if std::env::var_os(CHILD_ENV).is_some() {
        let tmp = tempdir().expect("tempdir");
        install_process_tool_authority(ToolAuthorityEnvelope {
            schema_version: 1,
            owner: "fleet-worker-child-process".to_string(),
            authority: ToolMutationAuthority::ReadOnly,
            network_access: None,
            writable_roots: Vec::new(),
            writable_files: Vec::new(),
            coordination_contracts: Vec::new(),
        })
        .expect("install process authority once in isolated child");
        let notes = tmp.path().join("notes.md");
        let mcp = tmp.path().join("mcp.json");
        let contexts = [
            ToolContext::new(tmp.path().to_path_buf()),
            ToolContext::with_options(tmp.path().to_path_buf(), false, notes.clone(), mcp.clone()),
            ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true),
        ];
        for context in contexts {
            let authority = context
                .tool_authority
                .as_ref()
                .expect("every constructor inherits process authority");
            assert_eq!(authority.owner, "fleet-worker-child-process");
            assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
        }
        return;
    }

    let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
        .arg("--exact")
        .arg("tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors")
        .arg("--nocapture")
        .env(CHILD_ENV, "1")
        .output()
        .expect("spawn isolated authority test child");
    assert!(
        output.status.success(),
        "child failed:\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn test_required_str() {
    let input = json!({"name": "test", "count": 42});
    assert_eq!(required_str(&input, "name").unwrap(), "test");
    assert!(required_str(&input, "missing").is_err());
    assert!(required_str(&input, "count").is_err()); // not a string
}

#[test]
fn test_optional_str() {
    let input = json!({"name": "test"});
    assert_eq!(optional_str(&input, "name"), Some("test"));
    assert_eq!(optional_str(&input, "missing"), None);
}

#[test]
fn test_required_u64() {
    let input = json!({"count": 42});
    assert_eq!(required_u64(&input, "count").unwrap(), 42);
    assert!(required_u64(&input, "missing").is_err());
}

#[test]
fn test_optional_u64() {
    let input = json!({"count": 42});
    assert_eq!(optional_u64(&input, "count", 0), 42);
    assert_eq!(optional_u64(&input, "missing", 100), 100);
}

#[test]
fn test_optional_bool() {
    let input = json!({"flag": true});
    assert!(optional_bool(&input, "flag", false));
    assert!(!optional_bool(&input, "missing", false));
}

#[test]
fn test_tool_error_display() {
    let err = ToolError::missing_field("path");
    assert_eq!(
        format!("{err}"),
        "Failed to validate input: missing required field 'path'"
    );

    let err = ToolError::execution_failed("boom");
    assert_eq!(format!("{err}"), "Failed to execute tool: boom");
}

#[test]
fn test_approval_requirement_default() {
    let level = ApprovalRequirement::default();
    assert_eq!(level, ApprovalRequirement::Auto);
}