Compare commits

...

3 Commits

Author SHA1 Message Date
Dylan Hurd
fe074c02e3 with feature flag 2026-02-19 10:49:02 -08:00
Dylan Hurd
14cd993950 clean up scenario tests 2026-02-18 08:59:14 -08:00
Dylan Hurd
5810c1b13e fix(apply-patch) Handle CRLF gracefully 2026-02-18 00:08:15 -08:00
37 changed files with 409 additions and 40 deletions

View File

@@ -13,6 +13,7 @@ use crate::ApplyPatchArgs;
use crate::ApplyPatchError;
use crate::ApplyPatchFileChange;
use crate::ApplyPatchFileUpdate;
use crate::ApplyPatchOptions;
use crate::IoError;
use crate::MaybeApplyPatchVerified;
use crate::parser::Hunk;
@@ -100,23 +101,45 @@ fn extract_apply_patch_from_shell(
}
// TODO: make private once we remove tests in lib.rs
#[cfg_attr(not(test), allow(dead_code))]
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
maybe_parse_apply_patch_with_options(argv, ApplyPatchOptions::default())
}
pub fn maybe_parse_apply_patch_with_options(
argv: &[String],
options: ApplyPatchOptions,
) -> MaybeApplyPatch {
match argv {
// Direct invocation: apply_patch <patch>
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => {
let body = if options.preserve_crlf {
body.to_string()
} else {
body.replace("\r\n", "\n")
};
match parse_patch(&body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
}
}
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
_ => match parse_shell_script(argv) {
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
Ok((body, workdir)) => match parse_patch(&body) {
Ok(mut source) => {
source.workdir = workdir;
MaybeApplyPatch::Body(source)
Ok((body, workdir)) => {
let body = if options.preserve_crlf {
body
} else {
body.replace("\r\n", "\n")
};
match parse_patch(&body) {
Ok(mut source) => {
source.workdir = workdir;
MaybeApplyPatch::Body(source)
}
Err(e) => MaybeApplyPatch::PatchParseError(e),
}
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
}
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
MaybeApplyPatch::NotApplyPatch
}
@@ -130,6 +153,14 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
/// cwd must be an absolute path so that we can resolve relative paths in the
/// patch.
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
maybe_parse_apply_patch_verified_with_options(argv, cwd, ApplyPatchOptions::default())
}
pub fn maybe_parse_apply_patch_verified_with_options(
argv: &[String],
cwd: &Path,
options: ApplyPatchOptions,
) -> MaybeApplyPatchVerified {
// Detect a raw patch body passed directly as the command or as the body of a shell
// script. In these cases, report an explicit error rather than applying the patch.
if let [body] = argv
@@ -143,7 +174,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
match maybe_parse_apply_patch(argv) {
match maybe_parse_apply_patch_with_options(argv, options) {
MaybeApplyPatch::Body(ApplyPatchArgs {
patch,
hunks,

View File

@@ -18,12 +18,19 @@ use similar::TextDiff;
use thiserror::Error;
pub use invocation::maybe_parse_apply_patch_verified;
pub use invocation::maybe_parse_apply_patch_verified_with_options;
pub use standalone_executable::main;
use crate::invocation::ExtractHeredocError;
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
pub const PRESERVE_CRLF_FLAG: &str = "--preserve-crlf";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ApplyPatchOptions {
pub preserve_crlf: bool,
}
#[derive(Debug, Error, PartialEq)]
pub enum ApplyPatchError {
@@ -176,7 +183,21 @@ pub fn apply_patch(
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
) -> Result<(), ApplyPatchError> {
let hunks = match parse_patch(patch) {
apply_patch_with_options(patch, ApplyPatchOptions::default(), stdout, stderr)
}
pub fn apply_patch_with_options(
patch: &str,
options: ApplyPatchOptions,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
) -> Result<(), ApplyPatchError> {
let patch = if options.preserve_crlf {
patch.to_string()
} else {
patch.replace("\r\n", "\n")
};
let hunks = match parse_patch(&patch) {
Ok(source) => source.hunks,
Err(e) => {
match &e {

View File

@@ -152,7 +152,7 @@ enum ParseMode {
}
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
let lines: Vec<&str> = patch.trim().lines().collect();
let lines: Vec<&str> = patch.trim().split('\n').collect();
let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
Ok(()) => &lines,
Err(e) => match mode {
@@ -206,7 +206,9 @@ fn check_patch_boundaries_lenient<'a>(
) -> Result<&'a [&'a str], ParseError> {
match original_lines {
[first, .., last] => {
if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
let first = first.trim();
let last = last.trim();
if (first == "<<EOF" || first == "<<'EOF'" || first == "<<\"EOF\"")
&& last.ends_with("EOF")
&& original_lines.len() >= 4
{
@@ -282,9 +284,11 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
let mut parsed_lines = 1;
// Optional: move file line
let move_path = remaining_lines
.first()
.and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
let move_path = remaining_lines.first().and_then(|x| {
x.strip_suffix('\r')
.unwrap_or(x)
.strip_prefix(MOVE_TO_MARKER)
});
if move_path.is_some() {
remaining_lines = &remaining_lines[1..];
@@ -353,9 +357,10 @@ fn parse_update_file_chunk(
}
// If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
// allow treating the chunk as starting directly with diff lines.
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
let first_line = lines[0].strip_suffix('\r').unwrap_or(lines[0]);
let (change_context, start_index) = if first_line == EMPTY_CHANGE_CONTEXT_MARKER {
(None, 1)
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
} else if let Some(context) = first_line.strip_prefix(CHANGE_CONTEXT_MARKER) {
(Some(context.to_string()), 1)
} else {
if !allow_missing_context {
@@ -383,7 +388,8 @@ fn parse_update_file_chunk(
};
let mut parsed_lines = 0;
for line in &lines[start_index..] {
match *line {
let line_contents = line.strip_suffix('\r').unwrap_or(line);
match line_contents {
EOF_MARKER => {
if parsed_lines == 0 {
return Err(InvalidHunkError {
@@ -395,7 +401,7 @@ fn parse_update_file_chunk(
parsed_lines += 1;
break;
}
line_contents => {
_ => {
match line_contents.chars().next() {
None => {
// Interpret this as an empty line.
@@ -403,14 +409,14 @@ fn parse_update_file_chunk(
chunk.new_lines.push(String::new());
}
Some(' ') => {
chunk.old_lines.push(line_contents[1..].to_string());
chunk.new_lines.push(line_contents[1..].to_string());
chunk.old_lines.push(line[1..].to_string());
chunk.new_lines.push(line[1..].to_string());
}
Some('+') => {
chunk.new_lines.push(line_contents[1..].to_string());
chunk.new_lines.push(line[1..].to_string());
}
Some('-') => {
chunk.old_lines.push(line_contents[1..].to_string());
chunk.old_lines.push(line[1..].to_string());
}
_ => {
if parsed_lines == 0 {
@@ -669,6 +675,28 @@ fn test_parse_patch_lenient() {
);
}
#[test]
fn test_parse_patch_preserves_crlf() {
let patch_text = "*** Begin Patch\r\n*** Update File: sample.txt\r\n@@\r\n-one\r\n+uno\r\n two\r\n*** End Patch\r\n";
assert_eq!(
parse_patch_text(patch_text, ParseMode::Strict),
Ok(ApplyPatchArgs {
hunks: vec![UpdateFile {
path: PathBuf::from("sample.txt"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
old_lines: vec!["one\r".to_string(), "two\r".to_string()],
new_lines: vec!["uno\r".to_string(), "two\r".to_string()],
is_end_of_file: false,
}],
}],
patch: patch_text.trim().to_string(),
workdir: None,
})
);
}
#[test]
fn test_parse_one_hunk() {
assert_eq!(

View File

@@ -9,12 +9,46 @@ pub fn main() -> ! {
/// We would prefer to return `std::process::ExitCode`, but its `exit_process()`
/// method is still a nightly API and we want main() to return !.
pub fn run_main() -> i32 {
// Expect either one argument (the full apply_patch payload) or read it from stdin.
// Expect either one argument (the full apply_patch payload), optionally prefixed
// by --preserve-crlf, or read it from stdin.
let mut args = std::env::args_os();
let _argv0 = args.next();
let mut preserve_crlf = false;
let patch_arg = match args.next() {
let first_arg = args.next();
let patch_arg = match first_arg {
Some(arg) => match arg.into_string() {
Ok(arg) if arg == crate::PRESERVE_CRLF_FLAG => {
preserve_crlf = true;
match args.next() {
Some(arg) => match arg.into_string() {
Ok(s) => s,
Err(_) => {
eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
return 1;
}
},
None => {
// No patch argument after flag; attempt to read patch from stdin.
let mut buf = String::new();
match std::io::stdin().read_to_string(&mut buf) {
Ok(_) => {
if buf.is_empty() {
eprintln!(
"Usage: apply_patch [--preserve-crlf] 'PATCH'\n echo 'PATCH' | apply_patch [--preserve-crlf]"
);
return 2;
}
buf
}
Err(err) => {
eprintln!("Error: Failed to read PATCH from stdin.\n{err}");
return 1;
}
}
}
}
}
Ok(s) => s,
Err(_) => {
eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
@@ -27,7 +61,9 @@ pub fn run_main() -> i32 {
match std::io::stdin().read_to_string(&mut buf) {
Ok(_) => {
if buf.is_empty() {
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply_patch");
eprintln!(
"Usage: apply_patch [--preserve-crlf] 'PATCH'\n echo 'PATCH' | apply_patch [--preserve-crlf]"
);
return 2;
}
buf
@@ -42,13 +78,14 @@ pub fn run_main() -> i32 {
// Refuse extra args to avoid ambiguity.
if args.next().is_some() {
eprintln!("Error: apply_patch accepts exactly one argument.");
eprintln!("Error: apply_patch accepts exactly one PATCH argument.");
return 2;
}
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
let options = crate::ApplyPatchOptions { preserve_crlf };
match crate::apply_patch_with_options(&patch_arg, options, &mut stdout, &mut stderr) {
Ok(()) => {
// Flush to ensure output ordering when used in pipelines.
let _ = stdout.flush();

View File

@@ -0,0 +1 @@
--preserve-crlf

View File

@@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: sample.txt
@@
-one
+uno
two
*** End Patch

View File

@@ -0,0 +1 @@
--preserve-crlf

View File

@@ -0,0 +1,5 @@
*** Begin Patch
*** Add File: created.txt
+hello
+world
*** End Patch

View File

@@ -0,0 +1,3 @@
first
second
third

View File

@@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: lines.txt
@@
first
-second
third
*** End Patch

View File

@@ -0,0 +1 @@
--preserve-crlf

View File

@@ -0,0 +1,8 @@
*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@
-old
+new
line
*** End Patch

View File

@@ -0,0 +1 @@
--preserve-crlf

View File

@@ -0,0 +1,4 @@
one
dos
three
cuatro

View File

@@ -0,0 +1,4 @@
one
two
three
four

View File

@@ -0,0 +1,9 @@
*** Begin Patch
*** Update File: multi.txt
@@
-two
+dos
@@
-four
+cuatro
*** End Patch

View File

@@ -5,6 +5,8 @@ This directory is a collection of end to end tests for the apply-patch specifica
# Specification
Each test case is one directory, composed of input state (input/), the patch operation (patch.txt), and the expected final state (expected/). This structure is designed to keep tests simple (i.e. test exactly one patch at a time) while still providing enough flexibility to test any given operation across files.
An optional `cli_flags.txt` may be provided with one CLI flag per line when a scenario needs non-default `apply_patch` arguments.
Here's what this would look like for a simple test apply-patch test case to create a new file:
```

View File

@@ -1,4 +1,5 @@
use assert_cmd::Command;
use codex_apply_patch::PRESERVE_CRLF_FLAG;
use std::fs;
use tempfile::tempdir;
@@ -89,3 +90,48 @@ fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn test_apply_patch_cli_normalizes_crlf_without_flag() -> anyhow::Result<()> {
let tmp = tempdir()?;
let file = "crlf_default.txt";
let absolute_path = tmp.path().join(file);
fs::write(&absolute_path, b"one\r\ntwo\r\n")?;
let patch = format!(
"*** Begin Patch\r\n*** Update File: {file}\r\n@@\r\n-one\r\n+uno\r\n two\r\n*** End Patch\r\n"
);
apply_patch_command()?
.arg(patch)
.current_dir(tmp.path())
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
assert_eq!(fs::read(absolute_path)?, b"uno\ntwo\n");
Ok(())
}
#[test]
fn test_apply_patch_cli_preserves_crlf_with_flag() -> anyhow::Result<()> {
let tmp = tempdir()?;
let file = "crlf_flag.txt";
let absolute_path = tmp.path().join(file);
fs::write(&absolute_path, b"one\r\ntwo\r\n")?;
let patch = format!(
"*** Begin Patch\r\n*** Update File: {file}\r\n@@\r\n-one\r\n+uno\r\n two\r\n*** End Patch\r\n"
);
apply_patch_command()?
.arg(PRESERVE_CRLF_FLAG)
.arg(patch)
.current_dir(tmp.path())
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
assert_eq!(fs::read(absolute_path)?, b"uno\r\ntwo\r\n");
Ok(())
}

View File

@@ -38,14 +38,19 @@ fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
// Read the patch.txt file
let patch = fs::read_to_string(dir.join("patch.txt"))?;
let cli_flags = dir.join("cli_flags.txt");
// Run apply_patch in the temporary directory. We intentionally do not assert
// on the exit status here; the scenarios are specified purely in terms of
// final filesystem state, which we compare below.
Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?)
.arg(patch)
.current_dir(tmp.path())
.output()?;
let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?);
if cli_flags.is_file() {
let flags = fs::read_to_string(&cli_flags)?;
for flag in flags.lines().map(str::trim).filter(|line| !line.is_empty()) {
command.arg(flag);
}
}
command.arg(patch).current_dir(tmp.path()).output()?;
// Assert that the final state matches the expected state exactly
let expected_dir = dir.join("expected");

View File

@@ -48,16 +48,34 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
let argv1 = args.next().unwrap_or_default();
if argv1 == CODEX_APPLY_PATCH_ARG1 {
let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned));
let mut preserve_crlf = false;
let patch_arg = match args.next() {
Some(arg) if arg.to_str() == Some(codex_apply_patch::PRESERVE_CRLF_FLAG) => {
preserve_crlf = true;
args.next().and_then(|s| s.to_str().map(str::to_owned))
}
Some(arg) => arg.to_str().map(str::to_owned),
None => None,
};
let exit_code = match patch_arg {
Some(patch_arg) => {
Some(patch_arg) if args.next().is_none() => {
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
let options = codex_apply_patch::ApplyPatchOptions { preserve_crlf };
match codex_apply_patch::apply_patch_with_options(
&patch_arg,
options,
&mut stdout,
&mut stderr,
) {
Ok(()) => 0,
Err(_) => 1,
}
}
Some(_) => {
eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} accepts exactly one PATCH argument.");
1
}
None => {
eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument.");
1

View File

@@ -88,6 +88,8 @@ pub enum Feature {
ShellZshFork,
/// Include the freeform apply_patch tool.
ApplyPatchFreeform,
/// Preserve CRLF line endings when applying CRLF patches.
ApplyPatchCrlf,
/// Allow the model to request web searches that fetch live content.
WebSearchRequest,
/// Allow the model to request web searches that fetch cached content.
@@ -512,6 +514,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApplyPatchCrlf,
key: "apply_patch_crlf",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::UseLinuxSandboxBwrap,
key: "use_linux_sandbox_bwrap",

View File

@@ -11,6 +11,7 @@ use crate::client_common::tools::ResponsesApiTool;
use crate::client_common::tools::ToolSpec;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
@@ -30,6 +31,7 @@ use crate::tools::spec::JsonSchema;
use async_trait::async_trait;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::ApplyPatchOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
pub struct ApplyPatchHandler;
@@ -103,8 +105,15 @@ impl ToolHandler for ApplyPatchHandler {
// Re-parse and verify the patch so we can compute changes and approval.
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let apply_patch_options = ApplyPatchOptions {
preserve_crlf: turn.features.enabled(Feature::ApplyPatchCrlf),
};
let command = vec!["apply_patch".to_string(), patch_input.clone()];
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd) {
match codex_apply_patch::maybe_parse_apply_patch_verified_with_options(
&command,
&cwd,
apply_patch_options,
) {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
match apply_patch::apply_patch(turn.as_ref(), changes).await {
InternalApplyPatchInvocation::Output(item) => {
@@ -129,6 +138,7 @@ impl ToolHandler for ApplyPatchHandler {
let req = ApplyPatchRequest {
action: apply.action,
preserve_crlf: turn.features.enabled(Feature::ApplyPatchCrlf),
file_paths,
changes,
exec_approval_requirement: apply.exec_approval_requirement,
@@ -194,7 +204,14 @@ pub(crate) async fn intercept_apply_patch(
call_id: &str,
tool_name: &str,
) -> Result<Option<ToolOutput>, FunctionCallError> {
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd) {
let apply_patch_options = ApplyPatchOptions {
preserve_crlf: turn.features.enabled(Feature::ApplyPatchCrlf),
};
match codex_apply_patch::maybe_parse_apply_patch_verified_with_options(
command,
cwd,
apply_patch_options,
) {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
session
.record_model_warning(
@@ -220,6 +237,7 @@ pub(crate) async fn intercept_apply_patch(
let req = ApplyPatchRequest {
action: apply.action,
preserve_crlf: turn.features.enabled(Feature::ApplyPatchCrlf),
file_paths: approval_keys,
changes,
exec_approval_requirement: apply.exec_approval_requirement,

View File

@@ -20,6 +20,7 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::PRESERVE_CRLF_FLAG;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
@@ -31,6 +32,7 @@ use std::path::PathBuf;
#[derive(Debug)]
pub struct ApplyPatchRequest {
pub action: ApplyPatchAction,
pub preserve_crlf: bool,
pub file_paths: Vec<AbsolutePathBuf>,
pub changes: std::collections::HashMap<PathBuf, FileChange>,
pub exec_approval_requirement: ExecApprovalRequirement,
@@ -55,9 +57,14 @@ impl ApplyPatchRuntime {
.map_err(|e| ToolError::Rejected(format!("failed to determine codex exe: {e}")))?
};
let program = exe.to_string_lossy().to_string();
let mut args = vec![CODEX_APPLY_PATCH_ARG1.to_string()];
if req.preserve_crlf {
args.push(PRESERVE_CRLF_FLAG.to_string());
}
args.push(req.action.patch.clone());
Ok(CommandSpec {
program,
args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), req.action.patch.clone()],
args,
cwd: req.action.cwd.clone(),
expiration: req.timeout_ms.into(),
// Run apply_patch with a minimal environment for determinism and to avoid leaks.
@@ -159,3 +166,80 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CODEX_APPLY_PATCH_ARG1;
use codex_apply_patch::PRESERVE_CRLF_FLAG;
use codex_protocol::protocol::FileChange;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use tempfile::tempdir;
#[test]
fn build_command_spec_omits_crlf_flag_by_default() {
let dir = tempdir().expect("tmp");
let path = dir.path().join("a.txt");
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let req = ApplyPatchRequest {
action,
preserve_crlf: false,
file_paths: vec![AbsolutePathBuf::try_from(path.clone()).expect("abs path")],
changes: HashMap::from([(
path.clone(),
FileChange::Add {
content: "hello".to_string(),
},
)]),
exec_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
timeout_ms: None,
codex_exe: Some(path),
};
let spec = ApplyPatchRuntime::build_command_spec(&req).expect("command spec");
assert_eq!(
spec.args.first().map(String::as_str),
Some(CODEX_APPLY_PATCH_ARG1)
);
assert_eq!(spec.args.len(), 2);
}
#[test]
fn build_command_spec_includes_crlf_flag_when_requested() {
let dir = tempdir().expect("tmp");
let path = dir.path().join("a.txt");
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let req = ApplyPatchRequest {
action,
preserve_crlf: true,
file_paths: vec![AbsolutePathBuf::try_from(path.clone()).expect("abs path")],
changes: HashMap::from([(
path.clone(),
FileChange::Add {
content: "hello".to_string(),
},
)]),
exec_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
timeout_ms: None,
codex_exe: Some(path),
};
let spec = ApplyPatchRuntime::build_command_spec(&req).expect("command spec");
assert_eq!(
spec.args,
vec![
CODEX_APPLY_PATCH_ARG1.to_string(),
PRESERVE_CRLF_FLAG.to_string(),
req.action.patch
]
);
}
}