1use anyhow::Context;
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD as BASE64;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::path::PathBuf;
12
13pub use crate::tools::editing::{Patch, PatchError, PatchHunk, PatchLine, PatchOperation};
14pub use vtcode_utility_tool_specs::{
15 APPLY_PATCH_ALIAS_DESCRIPTION, APPLY_PATCH_TOOL_DESCRIPTION, DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION,
16 SEMANTIC_ANCHOR_GUIDANCE, with_semantic_anchor_guidance,
17};
18
19#[derive(Debug, Deserialize, Serialize)]
21pub struct ApplyPatchInput {
22 #[serde(alias = "patch")]
23 pub input: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DecodedApplyPatchInput {
28 pub text: String,
29 pub source_bytes: usize,
30 pub was_base64: bool,
31}
32
33pub const APPLY_PATCH_ARGUMENT_CORRECTION: &str = r#"Missing or invalid apply_patch input. Call the apply_patch tool directly with JSON such as {"input":"*** Begin Patch\n*** Update File: src/example.rs\n@@\n-old\n+new\n*** End Patch\n"}. Copy exact current context, correct the payload, and retry once; do not run apply_patch in a shell."#;
34
35pub fn patch_source_from_args(args: &Value) -> Option<&str> {
36 let input = args.get("input").and_then(|value| value.as_str());
37 let patch = args.get("patch").and_then(|value| value.as_str());
38
39 match (input, patch) {
46 (Some(i), Some(p)) => {
47 if crate::tools::editing::looks_like_vte_patch(p) && !crate::tools::editing::looks_like_vte_patch(i) {
48 Some(p)
49 } else {
50 Some(i)
51 }
52 }
53 (Some(i), None) => Some(i),
54 (None, Some(p)) => Some(p),
55 (None, None) => args.as_str(),
56 }
57}
58
59pub fn decode_apply_patch_input(args: &Value) -> anyhow::Result<Option<DecodedApplyPatchInput>> {
60 let Some(source) = patch_source_from_args(args) else {
61 return Err(anyhow::anyhow!(APPLY_PATCH_ARGUMENT_CORRECTION));
62 };
63
64 let was_base64 = source.starts_with("base64:");
65 let cap = effective_max_payload_bytes();
66 let text = if was_base64 {
67 let decoded = BASE64
68 .decode(&source[7..])
69 .with_context(|| format!("Failed to decode base64 patch. {APPLY_PATCH_ARGUMENT_CORRECTION}"))?;
70 enforce_decoded_size_limit(decoded.len(), source.len(), was_base64, cap)?;
71 String::from_utf8(decoded)
72 .with_context(|| format!("Decoded patch is not valid UTF-8. {APPLY_PATCH_ARGUMENT_CORRECTION}"))?
73 } else {
74 enforce_decoded_size_limit(source.len(), source.len(), was_base64, cap)?;
75 source.to_string()
76 };
77
78 Ok(Some(DecodedApplyPatchInput { text, source_bytes: source.len(), was_base64 }))
79}
80
81pub fn mutation_target_paths(tool_name: &str, args: &Value) -> Vec<PathBuf> {
89 if crate::tools::names::canonical_tool_name(tool_name) == crate::config::constants::tools::APPLY_PATCH {
90 return decode_apply_patch_input(args)
91 .ok()
92 .flatten()
93 .and_then(|decoded| Patch::parse(&decoded.text).ok())
94 .map(|patch| patch_mutation_target_paths(&patch))
95 .unwrap_or_default();
96 }
97
98 const SINGULAR_KEYS: [&str; 7] = [
99 "path",
100 "file_path",
101 "filepath",
102 "target_path",
103 "destination",
104 "destination_path",
105 "file",
106 ];
107 let Some(obj) = args.as_object() else {
108 return Vec::new();
109 };
110 let mut paths = SINGULAR_KEYS
111 .iter()
112 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
113 .map(str::trim)
114 .filter(|path| !path.is_empty())
115 .map(PathBuf::from)
116 .collect::<Vec<_>>();
117 for key in ["items", "paths", "files"] {
118 let Some(items) = obj.get(key).and_then(Value::as_array) else {
119 continue;
120 };
121 for item in items {
122 if let Some(path) = item.as_str().map(str::trim).filter(|path| !path.is_empty()) {
123 paths.push(PathBuf::from(path));
124 } else if let Some(item) = item.as_object() {
125 paths.extend(SINGULAR_KEYS.iter().filter_map(|key| {
126 item.get(*key)
127 .and_then(Value::as_str)
128 .map(str::trim)
129 .filter(|path| !path.is_empty())
130 .map(PathBuf::from)
131 }));
132 }
133 }
134 }
135 paths
136}
137
138pub fn patch_mutation_target_paths(patch: &Patch) -> Vec<PathBuf> {
144 let mut paths = patch
145 .operations()
146 .iter()
147 .flat_map(|operation| match operation {
148 PatchOperation::AddFile { path, .. } | PatchOperation::DeleteFile { path } => {
149 vec![PathBuf::from(path)]
150 }
151 PatchOperation::UpdateFile { path, new_path, .. } => {
152 let mut paths = vec![PathBuf::from(path)];
153 paths.extend(new_path.as_deref().map(PathBuf::from));
154 paths
155 }
156 })
157 .collect::<Vec<_>>();
158 paths.sort();
159 paths.dedup();
160 paths
161}
162
163fn enforce_decoded_size_limit(
164 decoded_bytes: usize,
165 source_bytes: usize,
166 was_base64: bool,
167 cap: usize,
168) -> anyhow::Result<()> {
169 if decoded_bytes <= cap {
172 return Ok(());
173 }
174 anyhow::bail!(
175 "apply_patch payload too large after decoding: decoded={decoded_bytes} bytes (source={source_bytes} bytes, base64={was_base64}). \
176 The per-patch cap is {cap} bytes; split the change into smaller patches or raise {UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV}."
177 )
178}
179
180pub const UNIFIED_FILE_MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
183
184pub const MAX_DECODED_PATCH_BYTES: usize = UNIFIED_FILE_MAX_PAYLOAD_BYTES;
188
189pub const UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV: &str = "VTCODE_UNIFIED_FILE_MAX_PAYLOAD_BYTES";
191
192pub fn effective_max_payload_bytes() -> usize {
198 std::env::var(UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV)
199 .ok()
200 .and_then(|v| v.trim().parse::<usize>().ok())
201 .filter(|value| *value >= 1024)
202 .unwrap_or(UNIFIED_FILE_MAX_PAYLOAD_BYTES)
203}
204
205pub fn parameter_schema(input_description: &str) -> Value {
206 vtcode_utility_tool_specs::apply_patch_parameter_schema(input_description)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::{
212 APPLY_PATCH_ALIAS_DESCRIPTION, SEMANTIC_ANCHOR_GUIDANCE, decode_apply_patch_input, mutation_target_paths,
213 parameter_schema, patch_mutation_target_paths, patch_source_from_args, with_semantic_anchor_guidance,
214 };
215 use serde_json::json;
216 use std::path::PathBuf;
217
218 #[test]
219 fn patch_source_accepts_raw_string_and_object_fields() {
220 assert_eq!(
221 patch_source_from_args(&json!("*** Begin Patch\n*** End Patch\n")),
222 Some("*** Begin Patch\n*** End Patch\n")
223 );
224 assert_eq!(patch_source_from_args(&json!({"input": "x"})), Some("x"));
225 assert_eq!(patch_source_from_args(&json!({"patch": "y"})), Some("y"));
226 }
227
228 #[test]
229 fn patch_source_prefers_patch_shaped_field_when_both_present() {
230 let raw_source = "pub fn foo() { println!(\"hi\"); }\n";
234 let vte_patch = "*** Begin Patch\n*** Update File: f.rs\n@@\n-old\n+new\n*** End Patch";
235
236 let args = json!({ "input": raw_source, "patch": vte_patch });
238 assert_eq!(patch_source_from_args(&args), Some(vte_patch));
239
240 let args = json!({ "input": vte_patch, "patch": raw_source });
242 assert_eq!(patch_source_from_args(&args), Some(vte_patch));
243
244 let args = json!({ "input": "aaa", "patch": "bbb" });
246 assert_eq!(patch_source_from_args(&args), Some("aaa"));
247 }
248
249 #[test]
250 fn decode_apply_patch_input_supports_base64_payloads() {
251 let payload = json!({
252 "patch": "base64:KioqIEJlZ2luIFBhdGNoCioqKiBFbmQgUGF0Y2gK"
253 });
254
255 let decoded = decode_apply_patch_input(&payload)
256 .expect("payload should decode")
257 .expect("payload should be present");
258
259 assert_eq!(decoded.text, "*** Begin Patch\n*** End Patch\n");
260 assert_eq!(decoded.source_bytes, 47);
261 assert!(decoded.was_base64);
262 }
263
264 #[test]
265 fn mutation_paths_cover_all_public_patch_operations_and_alias_shapes() {
266 use base64::Engine;
267 use base64::engine::general_purpose::STANDARD as BASE64;
268
269 let patch = "*** Begin Patch\n*** Add File: src/add.rs\n+new\n*** Delete File: src/delete.rs\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n*** End Patch\n";
270 let expected = vec![
271 PathBuf::from("src/add.rs"),
272 PathBuf::from("src/delete.rs"),
273 PathBuf::from("src/new.rs"),
274 PathBuf::from("src/old.rs"),
275 ];
276
277 assert_eq!(mutation_target_paths("apply_patch", &json!(patch)), expected);
278 assert_eq!(mutation_target_paths("apply_patch", &json!({"input": patch})), expected);
279 assert_eq!(
280 mutation_target_paths("apply_patch", &json!({"patch": format!("base64:{}", BASE64.encode(patch))}),),
281 expected
282 );
283
284 let duplicate_patch = super::Patch::parse(
285 "*** Begin Patch\n*** Update File: src/same.rs\n*** Move to: src/same.rs\n@@\n-old\n+new\n*** End Patch\n",
286 )
287 .expect("duplicate-path patch should parse");
288 assert_eq!(patch_mutation_target_paths(&duplicate_patch), vec![PathBuf::from("src/same.rs")]);
289 }
290
291 #[test]
292 fn mutation_paths_include_move_and_copy_destinations() {
293 assert_eq!(
294 mutation_target_paths("move_file", &json!({"path": "src/old.rs", "destination": "src/new.rs"}),),
295 vec![PathBuf::from("src/old.rs"), PathBuf::from("src/new.rs")]
296 );
297 assert_eq!(
298 mutation_target_paths(
299 "copy_file",
300 &json!({
301 "file_path": "src/original.rs",
302 "destination_path": "src/copy.rs"
303 }),
304 ),
305 vec![PathBuf::from("src/original.rs"), PathBuf::from("src/copy.rs")]
306 );
307 }
308
309 #[test]
310 fn decode_apply_patch_input_rejects_invalid_base64() {
311 let error =
312 decode_apply_patch_input(&json!({"input": "base64:not-valid"})).expect_err("invalid base64 should fail");
313
314 assert!(error.to_string().contains("Failed to decode base64 patch"));
315 }
316
317 #[test]
318 fn decode_apply_patch_input_caps_decoded_size() {
319 use base64::Engine;
320 let large = "A".repeat(1_500_000);
324 let encoded = base64::engine::general_purpose::STANDARD.encode(large.as_bytes());
325 let payload = json!({ "input": format!("base64:{encoded}") });
326
327 let error = decode_apply_patch_input(&payload).expect_err("oversized decoded payload should be rejected");
328 let message = error.to_string();
329 assert!(
330 message.contains("apply_patch payload too large after decoding"),
331 "unexpected error message: {message}"
332 );
333 assert!(message.contains("base64=true"));
334 }
335
336 #[test]
337 fn decode_apply_patch_input_caps_raw_payload_size() {
338 let large = "B".repeat(1_500_000);
341 let payload = json!({ "input": large });
342
343 let error = decode_apply_patch_input(&payload).expect_err("oversized raw payload should be rejected");
344 let message = error.to_string();
345 assert!(
346 message.contains("apply_patch payload too large after decoding"),
347 "unexpected error message: {message}"
348 );
349 assert!(message.contains("base64=false"));
350 }
351
352 #[test]
353 fn semantic_anchor_guidance_is_appended_once() {
354 let base = "Patch in VT Code format.";
355 let with_guidance = with_semantic_anchor_guidance(base);
356 assert!(with_guidance.contains(SEMANTIC_ANCHOR_GUIDANCE));
357 assert_eq!(with_semantic_anchor_guidance(&with_guidance), with_guidance, "guidance should not be duplicated");
358 }
359
360 #[test]
361 fn parameter_schema_keeps_alias_and_guidance_consistent() {
362 let schema = parameter_schema("Patch in VT Code format");
363
364 assert_eq!(
369 schema["properties"]["patch"]["description"],
370 with_semantic_anchor_guidance(APPLY_PATCH_ALIAS_DESCRIPTION)
371 );
372 let patch_description = schema["properties"]["patch"]["description"]
373 .as_str()
374 .expect("patch description");
375 assert!(patch_description.contains("*** Begin Patch"), "patch description must name the envelope format");
376 assert!(patch_description.contains("unified diff"), "patch description must warn against unified diff");
377 assert!(patch_description.contains(SEMANTIC_ANCHOR_GUIDANCE));
378
379 let input_description = schema["properties"]["input"]["description"]
380 .as_str()
381 .expect("input description");
382 assert!(input_description.contains(SEMANTIC_ANCHOR_GUIDANCE));
383 }
384}