Skip to main content

winx_code_agent/tools/edit_files/
wire.rs

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    /// Optional explicit operation. Omit it for normal edits; exactly one `mode=undo`
17    /// entry infers `undo`. An explicit value must agree with the listed modes.
18    #[serde(default)]
19    pub operation: Option<EditOperation>,
20    /// One unique target per entry. All apply entries are validated before writing begins.
21    #[schemars(length(min = 1, max = 100))]
22    pub files: Vec<EditFileWire>,
23    /// Optional finite foreground check run once after a successful apply.
24    #[serde(default)]
25    pub verify_command: Option<String>,
26    /// Seconds to wait inline for `verify_command`, from 0 through 60.
27    #[serde(default)]
28    pub verify_wait_for_seconds: Option<f32>,
29    /// Exact project-session identifier returned by `Initialize`.
30    pub thread_id: String,
31    /// Exact project identity returned alongside `thread_id` by `Initialize`.
32    #[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    /// Target path. Relative paths resolve from the session's current shell directory.
40    pub file_path: String,
41    /// `line_patch` is the default for an existing file after `ReadFiles`; copy its
42    /// revision and visible coordinates. Reserve `search_replace` for intentional
43    /// text anchoring copied exactly from the current read, use `replace` for new
44    /// files or deliberate whole-file rewrites, and `undo` only as the sole entry
45    /// with its exact `undo_id`.
46    pub mode: EditMode,
47    /// Required by `replace` and `search_replace`; forbidden by other modes. For
48    /// `search_replace`, use one or more exact
49    /// `<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE` blocks. An optional
50    /// `@line` or `@start-end` may follow `SEARCH` to disambiguate repeated text.
51    #[serde(default)]
52    pub content: Option<String>,
53    /// Exact `ReadFiles.files[].revision`; required only by `line_patch`.
54    #[serde(default)]
55    pub expected_revision: Option<String>,
56    /// Ordered, non-overlapping, one-based changes; required only by `line_patch`.
57    #[serde(default)]
58    pub patches: Option<Vec<LinePatch>>,
59    /// Exact receipt returned by the edit being undone; required only by `undo`.
60    #[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}