dpc-tau-ext-shell 0.2.1

A minimal Unix-first coding agent.
Documentation
use super::*;

/// Ensures the parser keeps the Codex add-file format strict: every add-file
/// payload line must be prefixed with `+` so accidental malformed content
/// cannot be interpreted as valid file data.
#[test]
fn parse_add_file_rejects_unprefixed_content() {
    let patch = "*** Begin Patch\n*** Add File: hello.txt\nplain\n*** End Patch";
    let err = parse_patch(patch).expect_err("unprefixed add-file content should fail");

    assert_eq!(err, "invalid add-file line: plain");
}

/// Ensures `*** Move to` remains tied to update hunks and preserves the
/// destination path in the parsed hunk, because later application and lock
/// selection rely on this metadata.
#[test]
fn parse_update_hunk_with_move_destination() {
    let patch = "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch";
    let hunks = parse_patch(patch).expect("move update should parse");

    assert_eq!(
        hunks,
        vec![Hunk::Update {
            path: PathBuf::from("old.txt"),
            move_path: Some(PathBuf::from("new.txt")),
            chunks: vec![UpdateChunk {
                change_context: None,
                old_lines: vec!["old".to_owned()],
                new_lines: vec!["new".to_owned()],
                is_end_of_file: false,
            }],
        }]
    );
}

/// Ensures the grammar-declared `*** End of File` marker is parsed as chunk
/// metadata instead of being mistaken for the start of another patch operation.
#[test]
fn parse_update_hunk_end_of_file_marker() {
    let patch = "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n*** End of File\n*** End Patch";
    let hunks = parse_patch(patch).expect("end-of-file update should parse");
    let [Hunk::Update { chunks, .. }] = hunks.as_slice() else {
        panic!("expected one update hunk");
    };

    assert!(chunks[0].is_end_of_file);
}

/// Ensures delete hunks still parse as single-line operations, preventing the
/// parser refactor from requiring chunk content for file deletion.
#[test]
fn parse_delete_hunk() {
    let patch = "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch";
    let hunks = parse_patch(patch).expect("delete should parse");

    assert_eq!(
        hunks,
        vec![Hunk::Delete {
            path: PathBuf::from("old.txt")
        }]
    );
}

/// Ensures an admitted freeform delete can produce the bounded escaped-path
/// failure that exercises terminal frame rejection without oversized metadata.
#[test]
fn escaped_overlong_delete_path_produces_unreportable_terminal() {
    let path = "\u{1}".repeat(950_000);
    let patch = format!("*** Begin Patch\n*** Delete File: {path}\n*** End Patch");
    let arguments = CborValue::Text(patch);
    let mut world = ShellWorld::real();

    let failure = apply_patch(&arguments, &mut world)
        .expect_err("overlong filesystem path must fail before mutation");

    assert!(failure.message.starts_with("Failed to delete file "));
    assert!(failure.message.contains("\\u{1}"));
    let display = &failure.display;
    assert_eq!(display.status_text, failure.message);
    assert!(
        failure.message.len() + display.status_text.len()
            > usize::try_from(tau_client::MAX_OUTBOUND_FRAME_BYTES)
                .expect("frame limit fits usize"),
        "duplicated escaped failure text must exceed the complete frame budget"
    );
}

/// Ensures context-guided replacement still selects the intended original
/// range after mismatch diagnostics become structured.
#[test]
fn compute_replacements_with_context() {
    let original = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
    let chunks = vec![UpdateChunk {
        change_context: Some("a".to_owned()),
        old_lines: vec!["b".to_owned()],
        new_lines: vec!["B".to_owned()],
        is_end_of_file: false,
    }];
    let replacements = compute_replacements(&original, Path::new("file.txt"), &chunks)
        .expect("replacement plan should compute");
    assert_eq!(replacements, vec![(1, 1, vec!["B".to_owned()])]);
}

/// Ensures a missing expected sequence keeps the provider error header
/// single-line and returns bounded multiline recovery text separately.
#[test]
fn expected_line_mismatch_separates_header_and_recovery_output() {
    let original = vec!["present".to_owned()];
    let chunks = vec![UpdateChunk {
        change_context: None,
        old_lines: vec!["missing one".to_owned(), "missing two".to_owned()],
        new_lines: vec!["replacement".to_owned()],
        is_end_of_file: false,
    }];

    let error = compute_replacements(&original, Path::new("line\tbreak.txt"), &chunks)
        .expect_err("missing expected lines must fail");
    assert_eq!(
        error.message,
        "Failed to find expected lines in line\\tbreak.txt"
    );
    assert!(!error.message.contains(['\n', '\r']));
    assert_eq!(
        error.recovery_output.as_deref(),
        Some("Expected lines in line\\tbreak.txt:\nmissing one\nmissing two")
    );
}

/// Ensures mismatch recovery cannot echo an unbounded patch excerpt while
/// preserving valid UTF-8 at the truncation boundary.
#[test]
fn expected_line_mismatch_recovery_is_bounded() {
    let expected_lines = vec!["🙂".repeat(MAX_CONTEXT_MISMATCH_DETAIL_BYTES)];
    let output = bounded_context_mismatch_output("file.txt", &expected_lines);

    assert!(output.len() <= MAX_CONTEXT_MISMATCH_DETAIL_BYTES);
    assert!(output.ends_with("...(context truncated)"));

    let long_path = "p".repeat(MAX_CONTEXT_MISMATCH_DETAIL_BYTES * 2);
    let output = bounded_context_mismatch_output(&long_path, &["expected".to_owned()]);
    assert!(output.len() <= MAX_CONTEXT_MISMATCH_DETAIL_BYTES);
    assert!(output.starts_with("Expected lines in "));
    assert!(output.contains("...(path truncated):\nexpected"));
    assert!(output.ends_with("expected"));
}

#[test]
fn context_only_chunk_can_position_later_update_chunk() {
    // Codex-style patches sometimes use an initial context-only chunk as a
    // cursor before a later chunk performs the real edit. Accept that shape so
    // Tau can apply patches generated for the same apply_patch format.
    let patch = "*** Begin Patch\n*** Update File: file.txt\n@@\n fn anchor() {\n@@\n }\n\n+#[test]\n+fn inserted() {}\n+\n #[test]\n fn next() {}\n*** End Patch";
    let hunks = parse_patch(patch).expect("context-only chunk should parse");
    let [Hunk::Update { chunks, .. }] = hunks.as_slice() else {
        panic!("expected one update hunk");
    };

    let original = "fn before() {}\n\nfn anchor() {\n}\n\n#[test]\nfn next() {}\n";
    let new_contents = derive_new_contents_from_chunks(Path::new("file.txt"), original, chunks)
        .expect("context-only chunk should guide the later insertion");

    assert_eq!(
        new_contents,
        "fn before() {}\n\nfn anchor() {\n}\n\n#[test]\nfn inserted() {}\n\n#[test]\nfn next() {}\n"
    );
}

/// Ensures a one-file freeform patch retains its semantically escaped path in
/// the UI-only diff payload, because no compact input argument identifies it.
#[test]
fn single_file_diff_payload_keeps_the_escaped_file_path() {
    let changes = [AppliedChange {
        display_path: "line\\tbreak.txt".to_owned(),
        path: PathBuf::from("file.txt"),
        status: ChangeStatus::Modify,
        old_content: "before\n".to_owned(),
        new_content: Some("after\n".to_owned()),
    }];
    let summary = format_summary(&changes);
    let payload = display_payload_for_changes(&changes, &summary);
    let Some(ToolUsePayload::Diffs { files }) = payload else {
        panic!("single apply_patch change must retain its file path");
    };
    assert_eq!(files.len(), 1);
    assert_eq!(files[0].path, "line\\tbreak.txt");
    assert_eq!(files[0].diff.added, 1);
    assert_eq!(files[0].diff.removed, 1);
}

/// Ensures one changed file replaces the redundant tool-name argument and
/// carries an explicit file-count chip.
#[test]
fn single_file_display_names_the_changed_path() {
    let changes = [AppliedChange {
        display_path: "line\\tbreak.txt".to_owned(),
        path: PathBuf::from("file.txt"),
        status: ChangeStatus::Modify,
        old_content: "before\n".to_owned(),
        new_content: Some("after\n".to_owned()),
    }];
    let mut display = tau_proto::ToolUseState::default();

    populate_change_display(&mut display, &changes);

    assert_eq!(display.args, "line\\tbreak.txt");
    assert_eq!(display.info_chips, ["1F"]);
}

/// Ensures multiple distinct changed files keep the first applied path,
/// advertise omitted paths, and report the distinct file total.
#[test]
fn multi_file_display_summarizes_distinct_paths() {
    let changes = [
        AppliedChange {
            display_path: "first.rs".to_owned(),
            path: PathBuf::from("first.rs"),
            status: ChangeStatus::Modify,
            old_content: "old\n".to_owned(),
            new_content: Some("new\n".to_owned()),
        },
        AppliedChange {
            display_path: "second.rs".to_owned(),
            path: PathBuf::from("second.rs"),
            status: ChangeStatus::Add,
            old_content: String::new(),
            new_content: Some("new\n".to_owned()),
        },
        AppliedChange {
            display_path: "first.rs".to_owned(),
            path: PathBuf::from("first.rs"),
            status: ChangeStatus::Modify,
            old_content: "new\n".to_owned(),
            new_content: Some("newer\n".to_owned()),
        },
    ];
    let mut display = tau_proto::ToolUseState::default();

    populate_change_display(&mut display, &changes);

    assert_eq!(display.args, "first.rs,…");
    assert_eq!(display.info_chips, ["2F"]);
}

/// Ensures failures before the first filesystem effect do not fabricate a path
/// or zero-file chip.
#[test]
fn empty_change_display_stays_unlabelled() {
    let mut display = tau_proto::ToolUseState::default();

    populate_change_display(&mut display, &[]);

    assert!(display.args.is_empty());
    assert!(display.info_chips.is_empty());
}

/// Ensures `*** Add File` cannot silently clobber an existing path; callers
/// must use an update hunk when they intend to overwrite content.
#[test]
fn add_file_rejects_existing_target() {
    let temp = tempfile::tempdir().expect("tempdir");
    let path = temp.path().join("exists.txt");
    std::fs::write(&path, "original\n").expect("write original");

    let mut world = ShellWorld::real();
    let err = apply_hunks(
        &[Hunk::Add {
            path: path.clone(),
            contents: "replacement\n".to_owned(),
        }],
        &mut world,
    )
    .expect_err("add file should reject existing target");

    assert!(
        err.message.contains("Add File target already exists"),
        "unexpected error: {}",
        err.message
    );
    assert_eq!(
        std::fs::read_to_string(&path).expect("read original"),
        "original\n"
    );
}

/// Ensures a failed move after destination write reports the destination as a
/// partial Add and does not claim the full move/update succeeded.
#[test]
fn move_update_remove_failure_records_destination_as_partial_add() {
    let temp = tempfile::tempdir().expect("tempdir");
    let source_dir = temp.path().join("source");
    let dest_dir = temp.path().join("writable-dest");
    std::fs::create_dir_all(&source_dir).expect("create source dir");
    std::fs::create_dir_all(&dest_dir).expect("create destination dir");
    let source = source_dir.join("source.txt");
    let destination = dest_dir.join("destination.txt");
    std::fs::write(&source, "old\n").expect("write source");

    let mut world = ShellWorld::real();
    world.fail_next_remove_file_for(source.clone());
    let result = apply_hunks(
        &[Hunk::Update {
            path: source.clone(),
            move_path: Some(destination.clone()),
            chunks: vec![UpdateChunk {
                change_context: None,
                old_lines: vec!["old".to_owned()],
                new_lines: vec!["new".to_owned()],
                is_end_of_file: false,
            }],
        }],
        &mut world,
    );

    let err = result.expect_err("source removal should fail after writing destination");

    assert!(
        err.message.contains("Failed to remove original"),
        "unexpected error: {}",
        err.message
    );
    assert_eq!(
        std::fs::read_to_string(&destination).expect("read destination"),
        "new\n"
    );
    assert_eq!(
        std::fs::read_to_string(&source).expect("read source"),
        "old\n"
    );
    assert_eq!(
        err.changes,
        vec![AppliedChange {
            display_path: render_path(&destination),
            path: destination,
            status: ChangeStatus::Add,
            old_content: String::new(),
            new_content: Some("new\n".to_owned()),
        }]
    );
}