vtcode_core/tools/handlers/
intercept_apply_patch.rs1use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10
11use super::apply_patch_handler::parse_apply_patch_command;
12use super::tool_handler::{ToolOutput, ToolSession, TurnContext};
13use super::turn_diff_tracker::SharedTurnDiffTracker;
14use crate::tools::apply_patch::decode_apply_patch_input;
15use crate::tools::editing::Patch;
16use serde_json::json;
17use vtcode_commons::paths::ensure_path_within_workspace;
18
19pub const CODEX_APPLY_PATCH_ARG: &str = "--codex-run-as-apply-patch";
21
22#[derive(Clone, Debug)]
24pub struct ApplyPatchRequest {
25 pub patch: String,
27 pub cwd: PathBuf,
29 pub timeout_ms: Option<u64>,
31 pub user_explicitly_approved: bool,
33 pub codex_exe: Option<PathBuf>,
35}
36
37impl ApplyPatchRequest {
38 pub fn new(patch: String, cwd: PathBuf) -> Self {
39 Self {
40 patch,
41 cwd,
42 timeout_ms: Some(30000),
43 user_explicitly_approved: false,
44 codex_exe: None,
45 }
46 }
47
48 pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
49 self.timeout_ms = Some(timeout_ms);
50 self
51 }
52
53 pub fn with_approval(mut self, approved: bool) -> Self {
54 self.user_explicitly_approved = approved;
55 self
56 }
57
58 pub fn with_codex_exe(mut self, exe: PathBuf) -> Self {
59 self.codex_exe = Some(exe);
60 self
61 }
62}
63
64pub fn maybe_parse_apply_patch_from_command(command: &[String]) -> Option<String> {
68 let (is_apply_patch, patch_content) = parse_apply_patch_command(command);
69 is_apply_patch.then_some(patch_content).flatten()
70}
71
72#[expect(clippy::too_many_arguments)]
77pub async fn intercept_apply_patch(
78 command: &[String],
79 cwd: &Path,
80 timeout_ms: Option<u64>,
81 session: &dyn ToolSession,
82 _turn: &TurnContext,
83 tracker: Option<&SharedTurnDiffTracker>,
84 call_id: &str,
85 tool_name: &str,
86) -> Result<Option<ToolOutput>, ApplyPatchError> {
87 let Some(patch) = maybe_parse_apply_patch_from_command(command) else {
88 return Ok(None);
89 };
90
91 let args = json!({ "input": &patch });
96 let decoded = match decode_apply_patch_input(&args)
97 .map_err(|e| ApplyPatchError::ParseError(e.to_string()))?
98 {
99 Some(decoded) => decoded,
100 None => return Ok(None),
101 };
102 if decoded.text.is_empty() {
103 return Ok(None);
104 }
105
106 let workspace_root = session.workspace_root();
111 if let Err(reason) = ensure_path_within_workspace(cwd, workspace_root) {
112 return Err(ApplyPatchError::ParseError(format!(
113 "intercept_apply_patch rejected cwd '{}' (workspace='{}'): {}",
114 cwd.display(),
115 workspace_root.display(),
116 reason
117 )));
118 }
119
120 let req = ApplyPatchRequest::new(decoded.text.clone(), cwd.to_path_buf())
122 .with_timeout(timeout_ms.unwrap_or(30000));
123
124 if let Some(tracker) = tracker {
126 let mut t = tracker.write().await;
127 t.on_patch_begin(Default::default());
128 }
129
130 let result = execute_patch(&req).await;
135
136 if let Some(tracker) = tracker {
138 let mut t = tracker.write().await;
139 t.on_patch_end(result.is_ok());
140 }
141
142 match result {
143 Ok(output) => Ok(Some(ToolOutput::simple(output))),
144 Err(e) => Ok(Some(ToolOutput::error(format!(
145 "{e} (call_id={call_id}, tool_name={tool_name})"
146 )))),
147 }
148}
149
150async fn execute_patch(req: &ApplyPatchRequest) -> Result<String, ApplyPatchError> {
151 let patch = Patch::parse(&req.patch).map_err(|e| ApplyPatchError::ParseError(e.to_string()))?;
152 if patch.is_empty() {
153 return Ok("Patch is empty, no changes applied".to_string());
154 }
155
156 let results = patch
157 .apply(&req.cwd)
158 .await
159 .map_err(|e| ApplyPatchError::PatchFailed(e.to_string()))?;
160 Ok(results.join("\n"))
161}
162
163#[derive(Debug, thiserror::Error)]
165pub enum ApplyPatchError {
166 #[error("Failed to parse patch: {0}")]
167 ParseError(String),
168
169 #[error("Patch application failed: {0}")]
170 PatchFailed(String),
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn maybe_parse_apply_patch_detects_direct_invocation() {
179 assert_eq!(
180 maybe_parse_apply_patch_from_command(&[
181 "apply_patch".to_string(),
182 "*** Begin Patch\n*** End Patch".to_string(),
183 ]),
184 Some("*** Begin Patch\n*** End Patch".to_string())
185 );
186 assert_eq!(
187 maybe_parse_apply_patch_from_command(&[
188 "applypatch".to_string(),
189 "*** Begin Patch\n*** End Patch".to_string(),
190 ]),
191 Some("*** Begin Patch\n*** End Patch".to_string())
192 );
193 assert_eq!(
194 maybe_parse_apply_patch_from_command(&[
195 "codex".to_string(),
196 CODEX_APPLY_PATCH_ARG.to_string(),
197 ]),
198 None
199 );
200 assert_eq!(
201 maybe_parse_apply_patch_from_command(&[
202 "git".to_string(),
203 "apply".to_string(),
204 "test.patch".to_string(),
205 ]),
206 None
207 );
208 assert_eq!(
209 maybe_parse_apply_patch_from_command(&["patch".to_string(), "-p1".to_string(),]),
210 None
211 );
212 assert_eq!(
213 maybe_parse_apply_patch_from_command(&["echo".to_string(), "hello".to_string(),]),
214 None
215 );
216 }
217
218 #[test]
219 fn test_apply_patch_request_builder() {
220 let req = ApplyPatchRequest::new("patch content".to_string(), PathBuf::from("/tmp"))
221 .with_timeout(5000)
222 .with_approval(true)
223 .with_codex_exe(PathBuf::from("/usr/bin/codex"));
224
225 assert_eq!(req.patch, "patch content");
226 assert_eq!(req.timeout_ms, Some(5000));
227 assert!(req.user_explicitly_approved);
228 assert_eq!(req.codex_exe, Some(PathBuf::from("/usr/bin/codex")));
229 }
230}