pi_agent_rust 0.8.0

Native AI coding agent CLI - Rust port of Pi Agent
use super::*;

fn position(line: u32, character: u32) -> Position {
    Position { line, character }
}

fn range(start: u32, end: u32) -> Value {
    json!({"start":{"line":0,"character":start},"end":{"line":0,"character":end}})
}

#[test]
fn concrete_ranges_preserve_unicode_text_and_cursor_at_token_end() {
    for cursor in [2, 4, 7] {
        let target = parse(&range(2, 7), "😀alpha\r\nbeta", position(0, cursor))
            .unwrap()
            .unwrap();
        assert_eq!(target.source_text, "alpha");
        assert_eq!(target.placeholder, "alpha");
        assert_eq!(target.range.start, position(0, 2));
    }
}

#[test]
fn server_placeholder_is_distinct_from_the_original_source_spelling() {
    let raw = json!({"range":range(0, 6),"placeholder":"name"});
    let target = parse(&raw, "`name`", position(0, 3)).unwrap().unwrap();
    assert_eq!(target.source_text, "`name`");
    assert_eq!(target.placeholder, "name");
}

#[test]
fn multiline_ranges_keep_original_line_terminators() {
    let raw = json!({"start":{"line":0,"character":2},"end":{"line":1,"character":4}});
    let target = parse(&raw, "😀alpha\r\nbeta\n", position(1, 2))
        .unwrap()
        .unwrap();
    assert_eq!(target.source_text, "alpha\r\nbeta");
}

#[test]
fn null_preparation_is_an_explicit_refusal() {
    assert!(
        parse(&Value::Null, "alpha", position(0, 2))
            .unwrap()
            .is_none()
    );
}

#[test]
fn malformed_or_unnegotiated_preparation_never_becomes_a_range() {
    for raw in [
        json!(false),
        json!([]),
        json!({}),
        json!({"defaultBehavior":true}),
        json!({"defaultBehavior":false}),
        json!({"range":range(0, 5)}),
        json!({"range":range(0, 5),"placeholder":7}),
        json!({"placeholder":"alpha","start":{"line":0,"character":0},"end":{"line":0,"character":5}}),
        json!({"range":range(0, 5),"placeholder":"alpha","start":{"line":0,"character":0}}),
    ] {
        assert!(parse(&raw, "alpha", position(0, 2)).is_err(), "{raw}");
    }
}

#[test]
fn nonexact_empty_reversed_and_unrelated_ranges_are_rejected() {
    for raw in [
        range(1, 7),
        range(2, 99),
        range(4, 4),
        range(7, 2),
        range(5, 7),
        json!({"start":{"line":0,"character":2},"end":{"line":9,"character":0}}),
    ] {
        assert!(parse(&raw, "😀alpha\n", position(0, 3)).is_err(), "{raw}");
    }
}

#[test]
fn preparation_bounds_both_placeholder_and_original_text() {
    let raw = json!({"range":range(0, 5),"placeholder":"x".repeat(MAX_TARGET_BYTES + 1)});
    assert!(
        parse(&raw, "alpha", position(0, 3))
            .unwrap_err()
            .to_string()
            .contains("LSP_RENAME_LIMIT")
    );
    let text = "x".repeat(MAX_TARGET_BYTES + 1);
    assert!(
        parse(
            &range(0, u32::try_from(text.len()).unwrap()),
            &text,
            position(0, 0)
        )
        .is_err()
    );
    let raw = json!({"extra":"x".repeat(super::super::MAX_ACTION_BYTES + 1)});
    assert!(
        parse(&raw, "alpha", position(0, 1))
            .unwrap_err()
            .to_string()
            .contains("LSP_EDIT_LIMIT")
    );
}

#[test]
fn preparation_requires_an_explicit_valid_capability() {
    assert!(supports_prepare(&json!({"renameProvider":{"prepareProvider":true}})).unwrap());
    for caps in [
        json!({}),
        json!({"renameProvider":true}),
        json!({"renameProvider":{}}),
        json!({"renameProvider":{"prepareProvider":false}}),
    ] {
        assert!(!supports_prepare(&caps).unwrap());
    }
    for caps in [
        json!({"renameProvider":false}),
        json!({"renameProvider":1}),
        json!({"renameProvider":{"prepareProvider":"true"}}),
        json!({"renameProvider":{"prepareProvider":true},"positionEncoding":"utf-8"}),
    ] {
        assert!(supports_prepare(&caps).is_err());
    }
}

#[test]
fn targeting_accepts_one_position_selector_without_guessing() {
    for action in ["rename", "prepare_rename"] {
        for selector in [
            json!({"position":{"line":0,"character":5}}),
            json!({"symbol":"alpha#2"}),
            json!({"symbol":"alpha","line":1}),
        ] {
            let mut raw = json!({"action":action,"file":"a.rs"});
            raw.as_object_mut()
                .unwrap()
                .extend(selector.as_object().unwrap().clone());
            if action == "rename" {
                raw["newName"] = json!("renamed");
            }
            validate_input(&serde_json::from_value(raw).unwrap()).unwrap();
        }
    }
    for extra in [
        json!({}),
        json!({"line":1}),
        json!({"symbol":""}),
        json!({"position":{"line":0,"character":1},"symbol":"alpha"}),
        json!({"position":{"line":0,"character":1},"line":1}),
    ] {
        let mut raw = json!({"action":"prepare_rename","file":"a.rs"});
        raw.as_object_mut()
            .unwrap()
            .extend(extra.as_object().unwrap().clone());
        assert!(validate_input(&serde_json::from_value(raw).unwrap()).is_err());
    }
}

#[test]
fn inspection_cannot_accept_mutation_or_unrelated_selectors() {
    for (key, value) in [
        ("apply", json!(false)),
        ("apply", json!(true)),
        ("newName", json!("renamed")),
        ("query", json!("1")),
        ("actionId", json!("a")),
        ("refactorId", json!("r")),
        ("newFile", json!("b.rs")),
        ("method", json!("workspace/executeCommand")),
        ("payload", json!({})),
        ("range", range(0, 5)),
        ("only", json!(["refactor"])),
        ("completionId", json!("c")),
        ("hierarchyId", json!("h")),
        ("snippetValues", json!({})),
        ("resolve", json!(false)),
        ("limit", json!(1)),
        ("after", json!("x")),
        ("formatOptions", json!({})),
    ] {
        let mut raw = json!({"action":"prepare_rename","file":"a.rs","symbol":"alpha"});
        raw[key] = value;
        assert!(
            validate_input(&serde_json::from_value(raw).unwrap()).is_err(),
            "{key}"
        );
    }
}

#[test]
fn sources_are_bounded_before_reading_and_special_files_are_rejected() {
    let temp = tempfile::tempdir().unwrap();
    assert!(read_source(temp.path()).is_err());
    let large = temp.path().join("large.rs");
    std::fs::File::create(&large)
        .unwrap()
        .set_len(MAX_SOURCE_BYTES as u64 + 1)
        .unwrap();
    assert!(
        read_source(&large)
            .unwrap_err()
            .to_string()
            .contains("LSP_RENAME_LIMIT")
    );
    let source = temp.path().join("a.rs");
    std::fs::write(&source, "😀alpha\r\n").unwrap();
    assert_eq!(read_source(&source).unwrap(), "😀alpha\r\n");
    #[cfg(unix)]
    {
        let link = temp.path().join("link.rs");
        std::os::unix::fs::symlink(&source, &link).unwrap();
        assert!(read_source(&link).is_err());
    }
}

#[test]
fn request_budget_and_cancellation_do_not_reset_between_phases() {
    let mut budget = Budget::new(Duration::from_secs(2));
    budget.started = Instant::now().checked_sub(Duration::from_secs(3)).unwrap();
    assert!(
        budget
            .remaining()
            .unwrap_err()
            .to_string()
            .contains("LSP_TIMEOUT")
    );
    let mut budget = Budget::new(Duration::from_secs(20));
    budget.owner = AgentCx::for_request();
    budget
        .owner
        .cancel_with(asupersync::types::CancelKind::User, Some("cancel rename"));
    assert!(
        budget
            .remaining()
            .unwrap_err()
            .to_string()
            .contains("LSP_CANCELLED")
    );
}

mod protocol;