1use schemars::JsonSchema;
2use serde::Deserialize;
3use serde_json::Value;
4
5use super::domain::{
6 validate_aggregate, EditChange, EditCommand, EditMode, EditOperation, EditSurface,
7 EditVerification, NormalizedEditCall,
8};
9use crate::errors::{Result, WinxError};
10use crate::tools::file_write_or_edit::uses_search_replace;
11use crate::types::{ApplyPatch, FileWriteOrEdit, LinePatch, MultiFileEdit, UndoEdit};
12
13#[derive(Clone, Debug, Deserialize, JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct EditFilesWire {
16 #[serde(default)]
19 pub operation: Option<EditOperation>,
20 #[schemars(length(min = 1, max = 100))]
22 pub files: Vec<EditFileWire>,
23 #[serde(default)]
25 pub verify_command: Option<String>,
26 #[serde(default)]
28 pub verify_wait_for_seconds: Option<f32>,
29 pub thread_id: String,
31 #[serde(default)]
33 pub workspace_root: Option<String>,
34}
35
36#[derive(Clone, Debug, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct EditFileWire {
39 pub file_path: String,
41 pub mode: EditMode,
47 #[serde(default)]
52 pub content: Option<String>,
53 #[serde(default)]
55 pub expected_revision: Option<String>,
56 #[serde(default)]
58 pub patches: Option<Vec<LinePatch>>,
59 #[serde(default)]
61 pub undo_id: Option<String>,
62}
63
64pub fn normalize_edit_call(surface: EditSurface, arguments: Value) -> Result<NormalizedEditCall> {
65 let original_arguments = arguments.clone();
66 if surface == EditSurface::EditFiles {
67 let request: EditFilesWire = deserialize(arguments, surface.tool_name())?;
68 return normalize_new_wire(request, original_arguments);
69 }
70 let workspace_root =
71 arguments.get("workspace_root").and_then(Value::as_str).map(str::to_string);
72 let verification = parse_verification(&arguments)?;
73 let mut domain_arguments = arguments;
74 if let Some(map) = domain_arguments.as_object_mut() {
75 map.remove("workspace_root");
76 map.remove("verify_command");
77 map.remove("verify_wait_for_seconds");
78 }
79
80 let (surface, thread_id, command) =
81 normalize_legacy_wire(surface, domain_arguments, verification.is_some())?;
82 let changes = match &command {
83 EditCommand::Apply { changes } => changes.as_slice(),
84 EditCommand::Undo { .. } => &[],
85 };
86 validate_aggregate(changes)?;
87 Ok(NormalizedEditCall {
88 surface,
89 command,
90 verification,
91 thread_id,
92 workspace_root,
93 original_arguments,
94 })
95}
96
97fn normalize_legacy_wire(
98 surface: EditSurface,
99 arguments: Value,
100 has_verification: bool,
101) -> Result<(EditSurface, String, EditCommand)> {
102 Ok(match surface {
103 EditSurface::FileWriteOrEdit => {
104 let request: FileWriteOrEdit = deserialize(arguments, surface.tool_name())?;
105 let mode = if uses_search_replace(
106 request.percentage_to_change,
107 &request.text_or_search_replace_blocks,
108 ) {
109 EditMode::SearchReplace
110 } else {
111 EditMode::Replace
112 };
113 let change =
114 text_change(mode, request.file_path, request.text_or_search_replace_blocks)?;
115 (
116 EditSurface::FileWriteOrEdit,
117 request.thread_id,
118 EditCommand::Apply { changes: vec![change] },
119 )
120 }
121 EditSurface::MultiFileEdit => {
122 let request: MultiFileEdit = deserialize(arguments, surface.tool_name())?;
123 if request.files.len() < 2 {
124 return Err(WinxError::ArgumentParseError(
125 "MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
126 .to_string(),
127 ));
128 }
129 if request.files.len() > 100 {
130 return Err(WinxError::ArgumentParseError(format!(
131 "MultiFileEdit is limited to 100 files per batch (got {}); split the change into smaller batches.",
132 request.files.len()
133 )));
134 }
135 let changes = request
136 .files
137 .into_iter()
138 .map(|entry| {
139 let mode = if uses_search_replace(
140 entry.percentage_to_change,
141 &entry.text_or_search_replace_blocks,
142 ) {
143 EditMode::SearchReplace
144 } else {
145 EditMode::Replace
146 };
147 text_change(mode, entry.file_path, entry.text_or_search_replace_blocks)
148 })
149 .collect::<Result<Vec<_>>>()?;
150 (EditSurface::MultiFileEdit, request.thread_id, EditCommand::Apply { changes })
151 }
152 EditSurface::ApplyPatch => {
153 let request: ApplyPatch = deserialize(arguments, surface.tool_name())?;
154 crate::tools::apply_patch::validate_revision(&request.expected_revision)?;
155 crate::tools::apply_patch::validate_patch_count(&request.patches)?;
156 let change = EditChange::LinePatch {
157 file_path: request.file_path,
158 expected_revision: request.expected_revision,
159 patches: request.patches,
160 };
161 (
162 EditSurface::ApplyPatch,
163 request.thread_id,
164 EditCommand::Apply { changes: vec![change] },
165 )
166 }
167 EditSurface::UndoEdit => {
168 if has_verification {
169 return Err(WinxError::InvalidInput(
170 "UndoEdit does not accept verification fields".to_string(),
171 ));
172 }
173 let request: UndoEdit = deserialize(arguments, surface.tool_name())?;
174 (
175 EditSurface::UndoEdit,
176 request.thread_id,
177 EditCommand::Undo {
178 file_path: request.file_path,
179 undo_id: None,
180 legacy_lifo: true,
181 },
182 )
183 }
184 EditSurface::EditFiles => unreachable!("EditFiles is normalized before legacy lowering"),
185 })
186}
187
188fn normalize_new_wire(
189 request: EditFilesWire,
190 original_arguments: Value,
191) -> Result<NormalizedEditCall> {
192 if request.files.is_empty() || request.files.len() > 100 {
193 return Err(WinxError::InvalidInput(
194 "EditFiles files must contain between 1 and 100 entries".to_string(),
195 ));
196 }
197 let verification =
198 parse_verification_fields(request.verify_command, request.verify_wait_for_seconds)?;
199 let operation = request.operation.unwrap_or_else(|| {
200 if request.files.len() == 1 && request.files[0].mode == EditMode::Undo {
201 EditOperation::Undo
202 } else {
203 EditOperation::Apply
204 }
205 });
206 let command = match operation {
207 EditOperation::Apply => {
208 let changes = request
209 .files
210 .into_iter()
211 .enumerate()
212 .map(|(index, file)| apply_wire_change(index, file))
213 .collect::<Result<Vec<_>>>()?;
214 validate_aggregate(&changes)?;
215 EditCommand::Apply { changes }
216 }
217 EditOperation::Undo => {
218 if verification.is_some() {
219 return Err(WinxError::InvalidInput(
220 "EditFiles verification fields are valid only for operation=apply".to_string(),
221 ));
222 }
223 if request.files.len() != 1 {
224 return Err(WinxError::InvalidInput(
225 "EditFiles operation=undo requires exactly one file".to_string(),
226 ));
227 }
228 let file = request.files.into_iter().next().ok_or_else(|| {
229 WinxError::InvalidInput("EditFiles undo target is missing".to_string())
230 })?;
231 if file.mode != EditMode::Undo {
232 return Err(WinxError::InvalidInput(
233 "EditFiles operation=undo requires files[0].mode=undo".to_string(),
234 ));
235 }
236 reject_payload_fields(&file, "undo")?;
237 let undo_id = file.undo_id.filter(|id| !id.trim().is_empty()).ok_or_else(|| {
238 WinxError::InvalidInput(
239 "EditFiles operation=undo requires the exact non-empty undo_id returned by the edit"
240 .to_string(),
241 )
242 })?;
243 EditCommand::Undo {
244 file_path: file.file_path,
245 undo_id: Some(undo_id),
246 legacy_lifo: false,
247 }
248 }
249 };
250 Ok(NormalizedEditCall {
251 surface: EditSurface::EditFiles,
252 command,
253 verification,
254 thread_id: request.thread_id,
255 workspace_root: request.workspace_root,
256 original_arguments,
257 })
258}
259
260fn apply_wire_change(index: usize, file: EditFileWire) -> Result<EditChange> {
261 if file.mode == EditMode::Undo {
262 return Err(WinxError::InvalidInput(format!(
263 "files[{index}].mode=undo requires operation=undo and cannot be mixed with apply"
264 )));
265 }
266 if file.undo_id.is_some() {
267 return Err(WinxError::InvalidInput(format!(
268 "files[{index}].undo_id is valid only for mode=undo"
269 )));
270 }
271 match file.mode {
272 EditMode::Replace | EditMode::SearchReplace => {
273 if file.expected_revision.is_some() || file.patches.is_some() {
274 return Err(WinxError::InvalidInput(format!(
275 "files[{index}] text modes accept content only"
276 )));
277 }
278 let content = file.content.ok_or_else(|| {
279 WinxError::InvalidInput(format!("files[{index}].content is required"))
280 })?;
281 text_change(file.mode, file.file_path, content)
282 }
283 EditMode::LinePatch => {
284 if file.content.is_some() {
285 return Err(WinxError::InvalidInput(format!(
286 "files[{index}].content is not valid for line_patch"
287 )));
288 }
289 let expected_revision = file.expected_revision.ok_or_else(|| {
290 WinxError::InvalidInput(format!(
291 "files[{index}].expected_revision is required for line_patch"
292 ))
293 })?;
294 let patches = file.patches.ok_or_else(|| {
295 WinxError::InvalidInput(format!(
296 "files[{index}].patches is required for line_patch"
297 ))
298 })?;
299 crate::tools::apply_patch::validate_revision(&expected_revision)?;
300 crate::tools::apply_patch::validate_patch_count(&patches)?;
301 Ok(EditChange::LinePatch { file_path: file.file_path, expected_revision, patches })
302 }
303 EditMode::Undo => unreachable!("handled above"),
304 }
305}
306
307fn reject_payload_fields(file: &EditFileWire, mode: &str) -> Result<()> {
308 if file.content.is_some() || file.expected_revision.is_some() || file.patches.is_some() {
309 return Err(WinxError::InvalidInput(format!(
310 "mode={mode} does not accept content, expected_revision, or patches"
311 )));
312 }
313 Ok(())
314}
315
316fn text_change(mode: EditMode, file_path: String, content: String) -> Result<EditChange> {
317 match mode {
318 EditMode::Replace => Ok(EditChange::Replace { file_path, content }),
319 EditMode::SearchReplace => Ok(EditChange::SearchReplace { file_path, content }),
320 _ => Err(WinxError::InvalidInput("invalid text edit mode".to_string())),
321 }
322}
323
324fn parse_verification(arguments: &Value) -> Result<Option<EditVerification>> {
325 let command = arguments
326 .get("verify_command")
327 .filter(|value| !value.is_null())
328 .map(|value| {
329 value.as_str().map(str::to_string).ok_or_else(|| {
330 WinxError::InvalidInput("verify_command must be a string".to_string())
331 })
332 })
333 .transpose()?;
334 let wait = arguments
335 .get("verify_wait_for_seconds")
336 .filter(|value| !value.is_null())
337 .map(|value| {
338 serde_json::from_value::<f32>(value.clone()).map_err(|error| {
339 WinxError::InvalidInput(format!("invalid verify_wait_for_seconds: {error}"))
340 })
341 })
342 .transpose()?;
343 parse_verification_fields(command, wait)
344}
345
346fn parse_verification_fields(
347 command: Option<String>,
348 wait_for_seconds: Option<f32>,
349) -> Result<Option<EditVerification>> {
350 let command = command.map(|command| command.trim().to_string());
351 match command {
352 Some(command) if command.is_empty() => {
353 Err(WinxError::InvalidInput("verify_command must not be empty".to_string()))
354 }
355 Some(_command)
356 if wait_for_seconds
357 .is_some_and(|wait| !wait.is_finite() || !(0.0..=60.0).contains(&wait)) =>
358 {
359 Err(WinxError::InvalidInput(
360 "verify_wait_for_seconds must be between 0 and 60".to_string(),
361 ))
362 }
363 Some(command) => Ok(Some(EditVerification { command, wait_for_seconds })),
364 None if wait_for_seconds.is_some() => Err(WinxError::InvalidInput(
365 "verify_wait_for_seconds requires verify_command".to_string(),
366 )),
367 None => Ok(None),
368 }
369}
370
371fn deserialize<T: serde::de::DeserializeOwned>(value: Value, tool: &str) -> Result<T> {
372 serde_json::from_value(value)
373 .map_err(|error| WinxError::InvalidInput(format!("Invalid {tool} parameters: {error}")))
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn unified_wire_keeps_explicit_workspace_and_verification_fields() -> Result<()> {
382 let normalized = normalize_edit_call(
383 EditSurface::EditFiles,
384 serde_json::json!({
385 "operation": "apply",
386 "files": [{
387 "file_path": "/workspace/file.rs",
388 "mode": "replace",
389 "content": "fn main() {}"
390 }],
391 "verify_command": " cargo check ",
392 "verify_wait_for_seconds": 12.5,
393 "thread_id": "thread",
394 "workspace_root": "/workspace"
395 }),
396 )?;
397 assert_eq!(normalized.workspace_root.as_deref(), Some("/workspace"));
398 let verification = normalized.verification.ok_or_else(|| {
399 WinxError::InvalidInput("expected normalized verification".to_string())
400 })?;
401 assert_eq!(verification.command, "cargo check");
402 assert_eq!(verification.wait_for_seconds, Some(12.5));
403 Ok(())
404 }
405
406 #[test]
407 fn unified_wire_rejects_unknown_entry_fields_and_mixed_undo() {
408 let unknown = serde_json::json!({
409 "operation": "apply",
410 "files": [{
411 "file_path": "/workspace/file.rs",
412 "mode": "replace",
413 "content": "content",
414 "percentage_to_change": 100
415 }],
416 "thread_id": "thread"
417 });
418 assert!(normalize_edit_call(EditSurface::EditFiles, unknown).is_err());
419
420 let mixed = serde_json::json!({
421 "operation": "apply",
422 "files": [{
423 "file_path": "/workspace/file.rs",
424 "mode": "undo",
425 "undo_id": "undo_123"
426 }],
427 "thread_id": "thread"
428 });
429 assert!(normalize_edit_call(EditSurface::EditFiles, mixed).is_err());
430 }
431
432 #[test]
433 fn unified_wire_infers_common_apply_and_single_undo_operations() -> Result<()> {
434 let apply = normalize_edit_call(
435 EditSurface::EditFiles,
436 serde_json::json!({
437 "files": [{
438 "file_path": "/workspace/new.rs",
439 "mode": "replace",
440 "content": "fn main() {}"
441 }],
442 "thread_id": "thread"
443 }),
444 )?;
445 assert_eq!(apply.command.operation(), EditOperation::Apply);
446
447 let undo = normalize_edit_call(
448 EditSurface::EditFiles,
449 serde_json::json!({
450 "files": [{
451 "file_path": "/workspace/new.rs",
452 "mode": "undo",
453 "undo_id": "undo_123"
454 }],
455 "thread_id": "thread"
456 }),
457 )?;
458 assert_eq!(undo.command.operation(), EditOperation::Undo);
459 Ok(())
460 }
461
462 #[test]
463 fn legacy_marker_heuristic_does_not_leak_into_explicit_modes() -> Result<()> {
464 let content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n";
465 let legacy = normalize_edit_call(
466 EditSurface::FileWriteOrEdit,
467 serde_json::json!({
468 "file_path": "/workspace/file.rs",
469 "percentage_to_change": 100,
470 "text_or_search_replace_blocks": content,
471 "thread_id": "thread"
472 }),
473 )?;
474 let explicit = normalize_edit_call(
475 EditSurface::EditFiles,
476 serde_json::json!({
477 "operation": "apply",
478 "files": [{
479 "file_path": "/workspace/file.rs",
480 "mode": "replace",
481 "content": content
482 }],
483 "thread_id": "thread"
484 }),
485 )?;
486 assert_eq!(legacy.command.modes(), vec![EditMode::SearchReplace]);
487 assert_eq!(explicit.command.modes(), vec![EditMode::Replace]);
488 Ok(())
489 }
490}