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)]
21 pub operation: Option<EditOperation>,
22 #[schemars(length(min = 1, max = 100))]
24 pub files: Vec<EditFileWire>,
25 #[serde(default)]
27 pub verify_command: Option<String>,
28 #[serde(default)]
30 pub verify_wait_for_seconds: Option<f32>,
31 pub thread_id: String,
33 #[serde(default)]
35 pub workspace_root: Option<String>,
36}
37
38#[derive(Clone, Debug, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct EditFileWire {
41 pub file_path: String,
43 pub mode: EditMode,
50 #[serde(default)]
55 pub content: Option<String>,
56 #[serde(default)]
59 pub expected_revision: Option<String>,
60 #[serde(default)]
62 pub patches: Option<Vec<LinePatch>>,
63 #[serde(default)]
65 pub undo_id: Option<String>,
66 #[serde(default)]
69 pub artifact_generation: Option<String>,
70}
71
72pub fn normalize_edit_call(surface: EditSurface, arguments: Value) -> Result<NormalizedEditCall> {
73 let original_arguments = arguments.clone();
74 if surface == EditSurface::EditFiles {
75 let request: EditFilesWire = deserialize(arguments, surface.tool_name())?;
76 return normalize_new_wire(request, original_arguments);
77 }
78 let workspace_root =
79 arguments.get("workspace_root").and_then(Value::as_str).map(str::to_string);
80 let verification = parse_verification(&arguments)?;
81 let mut domain_arguments = arguments;
82 if let Some(map) = domain_arguments.as_object_mut() {
83 map.remove("workspace_root");
84 map.remove("verify_command");
85 map.remove("verify_wait_for_seconds");
86 }
87
88 let (surface, thread_id, command) =
89 normalize_legacy_wire(surface, domain_arguments, verification.is_some())?;
90 let changes = match &command {
91 EditCommand::Apply { changes } => changes.as_slice(),
92 EditCommand::Undo { .. } | EditCommand::ReleaseTemporary { .. } => &[],
93 };
94 validate_aggregate(changes)?;
95 Ok(NormalizedEditCall {
96 surface,
97 command,
98 verification,
99 thread_id,
100 workspace_root,
101 original_arguments,
102 })
103}
104
105fn normalize_legacy_wire(
106 surface: EditSurface,
107 arguments: Value,
108 has_verification: bool,
109) -> Result<(EditSurface, String, EditCommand)> {
110 Ok(match surface {
111 EditSurface::FileWriteOrEdit => {
112 let request: FileWriteOrEdit = deserialize(arguments, surface.tool_name())?;
113 let mode = if uses_search_replace(
114 request.percentage_to_change,
115 &request.text_or_search_replace_blocks,
116 ) {
117 EditMode::SearchReplace
118 } else {
119 EditMode::Replace
120 };
121 let change =
122 text_change(mode, request.file_path, request.text_or_search_replace_blocks)?;
123 (
124 EditSurface::FileWriteOrEdit,
125 request.thread_id,
126 EditCommand::Apply { changes: vec![change] },
127 )
128 }
129 EditSurface::MultiFileEdit => {
130 let request: MultiFileEdit = deserialize(arguments, surface.tool_name())?;
131 if request.files.len() < 2 {
132 return Err(WinxError::ArgumentParseError(
133 "MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
134 .to_string(),
135 ));
136 }
137 if request.files.len() > 100 {
138 return Err(WinxError::ArgumentParseError(format!(
139 "MultiFileEdit is limited to 100 files per batch (got {}); split the change into smaller batches.",
140 request.files.len()
141 )));
142 }
143 let changes = request
144 .files
145 .into_iter()
146 .map(|entry| {
147 let mode = if uses_search_replace(
148 entry.percentage_to_change,
149 &entry.text_or_search_replace_blocks,
150 ) {
151 EditMode::SearchReplace
152 } else {
153 EditMode::Replace
154 };
155 text_change(mode, entry.file_path, entry.text_or_search_replace_blocks)
156 })
157 .collect::<Result<Vec<_>>>()?;
158 (EditSurface::MultiFileEdit, request.thread_id, EditCommand::Apply { changes })
159 }
160 EditSurface::ApplyPatch => {
161 let request: ApplyPatch = deserialize(arguments, surface.tool_name())?;
162 crate::tools::apply_patch::validate_revision(&request.expected_revision)?;
163 crate::tools::apply_patch::validate_patch_count(&request.patches)?;
164 let change = EditChange::LinePatch {
165 file_path: request.file_path,
166 expected_revision: request.expected_revision,
167 patches: request.patches,
168 };
169 (
170 EditSurface::ApplyPatch,
171 request.thread_id,
172 EditCommand::Apply { changes: vec![change] },
173 )
174 }
175 EditSurface::UndoEdit => {
176 if has_verification {
177 return Err(WinxError::InvalidInput(
178 "UndoEdit does not accept verification fields".to_string(),
179 ));
180 }
181 let request: UndoEdit = deserialize(arguments, surface.tool_name())?;
182 (
183 EditSurface::UndoEdit,
184 request.thread_id,
185 EditCommand::Undo {
186 file_path: request.file_path,
187 undo_id: None,
188 legacy_lifo: true,
189 },
190 )
191 }
192 EditSurface::EditFiles => unreachable!("EditFiles is normalized before legacy lowering"),
193 })
194}
195
196fn normalize_new_wire(
197 request: EditFilesWire,
198 original_arguments: Value,
199) -> Result<NormalizedEditCall> {
200 if request.files.is_empty() || request.files.len() > 100 {
201 return Err(WinxError::InvalidInput(
202 "EditFiles files must contain between 1 and 100 entries".to_string(),
203 ));
204 }
205 let verification =
206 parse_verification_fields(request.verify_command, request.verify_wait_for_seconds)?;
207 let operation = request.operation.unwrap_or_else(|| {
208 if request.files.len() == 1 && request.files[0].mode == EditMode::Undo {
209 EditOperation::Undo
210 } else {
211 EditOperation::Apply
212 }
213 });
214 let command = match operation {
215 EditOperation::ReleaseTemporary => {
216 if request.workspace_root.as_deref().is_none_or(str::is_empty) || verification.is_some()
217 {
218 return Err(WinxError::InvalidInput(
219 "release_temporary requires workspace_root and forbids verification".into(),
220 ));
221 }
222 release_wire_command(request.files)?
223 }
224 EditOperation::Apply => {
225 let changes = request
226 .files
227 .into_iter()
228 .enumerate()
229 .map(|(index, file)| apply_wire_change(index, file))
230 .collect::<Result<Vec<_>>>()?;
231 validate_aggregate(&changes)?;
232 EditCommand::Apply { changes }
233 }
234 EditOperation::Undo => {
235 if verification.is_some() {
236 return Err(WinxError::InvalidInput(
237 "EditFiles verification fields are valid only for operation=apply".to_string(),
238 ));
239 }
240 if request.files.len() != 1 {
241 return Err(WinxError::InvalidInput(
242 "EditFiles operation=undo requires exactly one file".to_string(),
243 ));
244 }
245 let file = request.files.into_iter().next().ok_or_else(|| {
246 WinxError::InvalidInput("EditFiles undo target is missing".to_string())
247 })?;
248 if file.mode != EditMode::Undo {
249 return Err(WinxError::InvalidInput(
250 "EditFiles operation=undo requires files[0].mode=undo".to_string(),
251 ));
252 }
253 reject_payload_fields(&file, "undo")?;
254 let undo_id = file.undo_id.filter(|id| !id.trim().is_empty()).ok_or_else(|| {
255 WinxError::InvalidInput(
256 "EditFiles operation=undo requires the exact non-empty undo_id returned by the edit"
257 .to_string(),
258 )
259 })?;
260 EditCommand::Undo {
261 file_path: file.file_path,
262 undo_id: Some(undo_id),
263 legacy_lifo: false,
264 }
265 }
266 };
267 Ok(NormalizedEditCall {
268 surface: EditSurface::EditFiles,
269 command,
270 verification,
271 thread_id: request.thread_id,
272 workspace_root: request.workspace_root,
273 original_arguments,
274 })
275}
276
277fn release_wire_command(files: Vec<EditFileWire>) -> Result<EditCommand> {
278 let mut targets = Vec::with_capacity(files.len());
279 for file in files {
280 if file.mode != EditMode::ReleaseTemporary
281 || file.content.is_some()
282 || file.patches.is_some()
283 || file.undo_id.is_some()
284 {
285 return Err(WinxError::InvalidInput(
286 "release_temporary accepts only exact revision and generation targets".into(),
287 ));
288 }
289 let expected_revision = file.expected_revision.ok_or_else(|| {
290 WinxError::InvalidInput("release_temporary requires expected_revision".into())
291 })?;
292 crate::tools::apply_patch::validate_revision(&expected_revision)?;
293 let artifact_generation = file
294 .artifact_generation
295 .filter(|value| !value.is_empty() && value.len() <= 256)
296 .ok_or_else(|| {
297 WinxError::InvalidInput(
298 "release_temporary requires artifact_generation from ReadFiles".into(),
299 )
300 })?;
301 targets.push(crate::utils::agent_temp::release::TemporaryReleaseTarget {
302 file_path: file.file_path,
303 expected_revision,
304 artifact_generation,
305 });
306 }
307 Ok(EditCommand::ReleaseTemporary { files: targets })
308}
309
310fn apply_wire_change(index: usize, file: EditFileWire) -> Result<EditChange> {
311 if file.artifact_generation.is_some() || file.mode == EditMode::ReleaseTemporary {
312 return Err(WinxError::InvalidInput(
313 "temporary generation and mode require explicit operation=release_temporary".into(),
314 ));
315 }
316 if file.mode == EditMode::Undo {
317 return Err(WinxError::InvalidInput(format!(
318 "files[{index}].mode=undo requires operation=undo and cannot be mixed with apply"
319 )));
320 }
321 if file.undo_id.is_some() {
322 return Err(WinxError::InvalidInput(format!(
323 "files[{index}].undo_id is valid only for mode=undo"
324 )));
325 }
326 match file.mode {
327 EditMode::Replace | EditMode::SearchReplace => {
328 if file.expected_revision.is_some() || file.patches.is_some() {
329 return Err(WinxError::InvalidInput(format!(
330 "files[{index}] text modes accept content only"
331 )));
332 }
333 let content = file.content.ok_or_else(|| {
334 WinxError::InvalidInput(format!("files[{index}].content is required"))
335 })?;
336 text_change(file.mode, file.file_path, content)
337 }
338 EditMode::LinePatch => {
339 if file.content.is_some() {
340 return Err(WinxError::InvalidInput(format!(
341 "files[{index}].content is not valid for line_patch"
342 )));
343 }
344 let expected_revision = file.expected_revision.ok_or_else(|| {
345 WinxError::InvalidInput(format!(
346 "files[{index}].expected_revision is required for line_patch"
347 ))
348 })?;
349 let patches = file.patches.ok_or_else(|| {
350 WinxError::InvalidInput(format!(
351 "files[{index}].patches is required for line_patch"
352 ))
353 })?;
354 crate::tools::apply_patch::validate_revision(&expected_revision)?;
355 crate::tools::apply_patch::validate_patch_count(&patches)?;
356 Ok(EditChange::LinePatch { file_path: file.file_path, expected_revision, patches })
357 }
358 EditMode::ReleaseTemporary => unreachable!("release handled separately"),
359 EditMode::Undo => unreachable!("handled above"),
360 }
361}
362
363fn reject_payload_fields(file: &EditFileWire, mode: &str) -> Result<()> {
364 if file.artifact_generation.is_some() {
365 return Err(WinxError::InvalidInput(
366 "artifact_generation is valid only for release_temporary".into(),
367 ));
368 }
369 if file.content.is_some() || file.expected_revision.is_some() || file.patches.is_some() {
370 return Err(WinxError::InvalidInput(format!(
371 "mode={mode} does not accept content, expected_revision, or patches"
372 )));
373 }
374 Ok(())
375}
376
377fn text_change(mode: EditMode, file_path: String, content: String) -> Result<EditChange> {
378 match mode {
379 EditMode::Replace => Ok(EditChange::Replace { file_path, content }),
380 EditMode::SearchReplace => Ok(EditChange::SearchReplace { file_path, content }),
381 _ => Err(WinxError::InvalidInput("invalid text edit mode".to_string())),
382 }
383}
384
385fn parse_verification(arguments: &Value) -> Result<Option<EditVerification>> {
386 let command = arguments
387 .get("verify_command")
388 .filter(|value| !value.is_null())
389 .map(|value| {
390 value.as_str().map(str::to_string).ok_or_else(|| {
391 WinxError::InvalidInput("verify_command must be a string".to_string())
392 })
393 })
394 .transpose()?;
395 let wait = arguments
396 .get("verify_wait_for_seconds")
397 .filter(|value| !value.is_null())
398 .map(|value| {
399 serde_json::from_value::<f32>(value.clone()).map_err(|error| {
400 WinxError::InvalidInput(format!("invalid verify_wait_for_seconds: {error}"))
401 })
402 })
403 .transpose()?;
404 parse_verification_fields(command, wait)
405}
406
407fn parse_verification_fields(
408 command: Option<String>,
409 wait_for_seconds: Option<f32>,
410) -> Result<Option<EditVerification>> {
411 let command = command.map(|command| command.trim().to_string());
412 match command {
413 Some(command) if command.is_empty() => {
414 Err(WinxError::InvalidInput("verify_command must not be empty".to_string()))
415 }
416 Some(_command)
417 if wait_for_seconds
418 .is_some_and(|wait| !wait.is_finite() || !(0.0..=60.0).contains(&wait)) =>
419 {
420 Err(WinxError::InvalidInput(
421 "verify_wait_for_seconds must be between 0 and 60".to_string(),
422 ))
423 }
424 Some(command) => Ok(Some(EditVerification { command, wait_for_seconds })),
425 None if wait_for_seconds.is_some() => Err(WinxError::InvalidInput(
426 "verify_wait_for_seconds requires verify_command".to_string(),
427 )),
428 None => Ok(None),
429 }
430}
431
432fn deserialize<T: serde::de::DeserializeOwned>(value: Value, tool: &str) -> Result<T> {
433 serde_json::from_value(value)
434 .map_err(|error| WinxError::InvalidInput(format!("Invalid {tool} parameters: {error}")))
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn unified_wire_keeps_explicit_workspace_and_verification_fields() -> Result<()> {
443 let normalized = normalize_edit_call(
444 EditSurface::EditFiles,
445 serde_json::json!({
446 "operation": "apply",
447 "files": [{
448 "file_path": "/workspace/file.rs",
449 "mode": "replace",
450 "content": "fn main() {}"
451 }],
452 "verify_command": " cargo check ",
453 "verify_wait_for_seconds": 12.5,
454 "thread_id": "thread",
455 "workspace_root": "/workspace"
456 }),
457 )?;
458 assert_eq!(normalized.workspace_root.as_deref(), Some("/workspace"));
459 let verification = normalized.verification.ok_or_else(|| {
460 WinxError::InvalidInput("expected normalized verification".to_string())
461 })?;
462 assert_eq!(verification.command, "cargo check");
463 assert_eq!(verification.wait_for_seconds, Some(12.5));
464 Ok(())
465 }
466
467 #[test]
468 fn unified_wire_rejects_unknown_entry_fields_and_mixed_undo() {
469 let unknown = serde_json::json!({
470 "operation": "apply",
471 "files": [{
472 "file_path": "/workspace/file.rs",
473 "mode": "replace",
474 "content": "content",
475 "percentage_to_change": 100
476 }],
477 "thread_id": "thread"
478 });
479 assert!(normalize_edit_call(EditSurface::EditFiles, unknown).is_err());
480
481 let mixed = serde_json::json!({
482 "operation": "apply",
483 "files": [{
484 "file_path": "/workspace/file.rs",
485 "mode": "undo",
486 "undo_id": "undo_123"
487 }],
488 "thread_id": "thread"
489 });
490 assert!(normalize_edit_call(EditSurface::EditFiles, mixed).is_err());
491 }
492
493 #[test]
494 fn unified_wire_infers_common_apply_and_single_undo_operations() -> Result<()> {
495 let apply = normalize_edit_call(
496 EditSurface::EditFiles,
497 serde_json::json!({
498 "files": [{
499 "file_path": "/workspace/new.rs",
500 "mode": "replace",
501 "content": "fn main() {}"
502 }],
503 "thread_id": "thread"
504 }),
505 )?;
506 assert_eq!(apply.command.operation(), EditOperation::Apply);
507
508 let undo = normalize_edit_call(
509 EditSurface::EditFiles,
510 serde_json::json!({
511 "files": [{
512 "file_path": "/workspace/new.rs",
513 "mode": "undo",
514 "undo_id": "undo_123"
515 }],
516 "thread_id": "thread"
517 }),
518 )?;
519 assert_eq!(undo.command.operation(), EditOperation::Undo);
520 Ok(())
521 }
522
523 #[test]
524 fn legacy_marker_heuristic_does_not_leak_into_explicit_modes() -> Result<()> {
525 let content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n";
526 let legacy = normalize_edit_call(
527 EditSurface::FileWriteOrEdit,
528 serde_json::json!({
529 "file_path": "/workspace/file.rs",
530 "percentage_to_change": 100,
531 "text_or_search_replace_blocks": content,
532 "thread_id": "thread"
533 }),
534 )?;
535 let explicit = normalize_edit_call(
536 EditSurface::EditFiles,
537 serde_json::json!({
538 "operation": "apply",
539 "files": [{
540 "file_path": "/workspace/file.rs",
541 "mode": "replace",
542 "content": content
543 }],
544 "thread_id": "thread"
545 }),
546 )?;
547 assert_eq!(legacy.command.modes(), vec![EditMode::SearchReplace]);
548 assert_eq!(explicit.command.modes(), vec![EditMode::Replace]);
549 Ok(())
550 }
551}
552
553#[cfg(test)]
554mod temporary_release_contract_tests {
555 #![allow(clippy::unwrap_used, clippy::expect_used)]
556 use super::*;
557 use serde_json::json;
558
559 fn release_request() -> Value {
560 json!({
561 "thread_id": "initialized-thread",
562 "workspace_root": "/initialized/workspace",
563 "operation": "release_temporary",
564 "files": [{
565 "file_path": "/initialized/workspace/.winx/tmp/session/helper.py",
566 "mode": "release_temporary",
567 "expected_revision": format!("sha256:{}", "a".repeat(64)),
568 "artifact_generation": "opaque-observed-generation"
569 }]
570 })
571 }
572
573 #[test]
574 fn explicit_temporary_release_normalizes_without_verification_or_edit_payload() {
575 let normalized = normalize_edit_call(EditSurface::EditFiles, release_request())
576 .expect("the explicit revision-and-generation release contract must normalize");
577 assert_eq!(normalized.command.operation().as_str(), "release_temporary");
578 assert_eq!(normalized.command.modes()[0].as_str(), "release_temporary");
579 assert_eq!(normalized.command.file_count(), 1);
580 assert!(normalized.verification.is_none());
581 }
582
583 #[test]
584 fn temporary_release_requires_explicit_operation_workspace_revision_and_generation() {
585 for field in ["operation", "workspace_root"] {
586 let mut request = release_request();
587 request.as_object_mut().unwrap().remove(field);
588 assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
589 }
590 for field in ["expected_revision", "artifact_generation"] {
591 for replacement in [None, Some(json!("")), Some(Value::Null)] {
592 let mut request = release_request();
593 let file = request["files"][0].as_object_mut().unwrap();
594 if let Some(value) = replacement {
595 file.insert(field.into(), value);
596 } else {
597 file.remove(field);
598 }
599 assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
600 }
601 }
602 }
603
604 #[test]
605 fn temporary_release_rejects_edit_and_verification_payloads() {
606 for (field, value) in [
607 ("content", json!("must not write")),
608 ("patches", json!([])),
609 ("undo_id", json!("old-checkpoint")),
610 ] {
611 let mut request = release_request();
612 request["files"][0][field] = value;
613 assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
614 }
615 for (field, value) in
616 [("verify_command", json!("must-not-execute")), ("verify_wait_for_seconds", json!(0))]
617 {
618 let mut request = release_request();
619 request[field] = value;
620 assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
621 }
622 let mut mixed = release_request();
623 mixed["files"].as_array_mut().unwrap().push(json!({
624 "file_path": "/initialized/workspace/source.rs", "mode": "replace", "content": ""
625 }));
626 assert!(normalize_edit_call(EditSurface::EditFiles, mixed).is_err());
627 }
628
629 #[test]
630 fn temporary_generation_is_rejected_for_normal_edits() {
631 let request = json!({
632 "thread_id": "initialized-thread",
633 "files": [{"file_path": "source.rs", "mode": "replace", "content": "source",
634 "artifact_generation": "not-an-edit-field"}]
635 });
636 assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err());
637 }
638}