@@ -188,6 +188,11 @@ fn normalize_edit_arguments(map: &mut Map<String, Value>) -> Result<(), Translat
188188 }
189189
190190 let modes = edit_modes_present ( map) ;
191+ if has_orphaned_symbol_content ( map) {
192+ return Err ( invalid_request (
193+ "edit: 'content' requires a non-empty string 'symbol' when symbol mode is selected" ,
194+ ) ) ;
195+ }
191196 if modes. len ( ) > 1 {
192197 return Err ( invalid_request ( format ! (
193198 "edit: conflicting modes: {}" ,
@@ -264,26 +269,82 @@ fn normalize_edit_path_alias(map: &mut Map<String, Value>) -> Result<(), Transla
264269 }
265270}
266271
267- fn edit_modes_present ( map : & Map < String , Value > ) -> Vec < & ' static str > {
272+ fn edit_modes_present ( map : & mut Map < String , Value > ) -> Vec < & ' static str > {
273+ // Some hosts serialize every optional field with an empty sentinel. Remove
274+ // fields that cannot select a mode so later translation cannot revive them.
275+ let has_append_content = is_non_empty_string ( map. get ( "appendContent" ) ) ;
276+ if !has_append_content {
277+ map. remove ( "appendContent" ) ;
278+ }
279+
280+ let has_edits = is_non_empty_edit_array ( map. get ( "edits" ) ) ;
281+ if !has_edits {
282+ map. remove ( "edits" ) ;
283+ }
284+
285+ let has_symbol = is_non_empty_string ( map. get ( "symbol" ) ) ;
286+ if !has_symbol {
287+ map. remove ( "symbol" ) ;
288+ if map. get ( "content" ) . is_some_and ( |value| {
289+ value. is_null ( ) || matches ! ( value, Value :: String ( value) if value. is_empty( ) )
290+ } ) {
291+ map. remove ( "content" ) ;
292+ }
293+ } else if matches ! ( map. get( "content" ) , Some ( Value :: Null ) ) {
294+ map. remove ( "content" ) ;
295+ }
296+
297+ let has_single_edit = is_non_empty_string ( map. get ( "oldString" ) ) ;
298+ if !has_single_edit {
299+ for key in [ "oldString" , "newString" , "replaceAll" , "occurrence" ] {
300+ map. remove ( key) ;
301+ }
302+ } else {
303+ for key in [ "newString" , "replaceAll" , "occurrence" ] {
304+ if matches ! ( map. get( key) , Some ( Value :: Null ) ) {
305+ map. remove ( key) ;
306+ }
307+ }
308+ }
309+
268310 let mut modes = Vec :: new ( ) ;
269- if map . contains_key ( "appendContent" ) {
311+ if has_append_content {
270312 modes. push ( "appendContent" ) ;
271313 }
272- if map . contains_key ( "edits" ) {
314+ if has_edits {
273315 modes. push ( "edits" ) ;
274316 }
275- if map . contains_key ( "symbol" ) || map . contains_key ( "content" ) {
317+ if has_symbol {
276318 modes. push ( "symbol/content" ) ;
277319 }
278- if [ "oldString" , "newString" , "replaceAll" , "occurrence" ]
279- . iter ( )
280- . any ( |key| map. contains_key ( * key) )
281- {
320+ if has_single_edit {
282321 modes. push ( "oldString/newString" ) ;
283322 }
284323 modes
285324}
286325
326+ fn is_non_empty_string ( value : Option < & Value > ) -> bool {
327+ matches ! ( value, Some ( Value :: String ( value) ) if !value. is_empty( ) )
328+ }
329+
330+ fn is_non_empty_edit_array ( value : Option < & Value > ) -> bool {
331+ match value {
332+ Some ( Value :: Array ( items) ) => !items. is_empty ( ) ,
333+ Some ( Value :: String ( raw) ) if raw. is_empty ( ) => false ,
334+ Some ( Value :: String ( raw) ) => serde_json:: from_str :: < Value > ( raw)
335+ . ok ( )
336+ . map ( |value| !matches ! ( value, Value :: Array ( items) if items. is_empty( ) ) )
337+ // A non-empty malformed string is still an edits claim so the
338+ // existing parser can report its specific validation error.
339+ . unwrap_or ( true ) ,
340+ _ => false ,
341+ }
342+ }
343+
344+ fn has_orphaned_symbol_content ( map : & Map < String , Value > ) -> bool {
345+ is_non_empty_string ( map. get ( "content" ) ) && !is_non_empty_string ( map. get ( "symbol" ) )
346+ }
347+
287348fn format_unknown_keys ( mut keys : Vec < String > ) -> String {
288349 keys. sort ( ) ;
289350 format ! (
@@ -2334,9 +2395,137 @@ mod tests {
23342395 ) ;
23352396 }
23362397
2398+ #[ test]
2399+ fn edit_normalization_uses_meaningful_mode_presence ( ) {
2400+ let project = Path :: new ( "/project" ) ;
2401+ let cases = [
2402+ (
2403+ "edits ignores empty mode sentinels" ,
2404+ serde_json:: json!( {
2405+ "filePath" : "src/example.ts" ,
2406+ "edits" : [ { "oldString" : "old" , "newString" : "new" } ] ,
2407+ "appendContent" : "" ,
2408+ "symbol" : "" ,
2409+ "content" : "" ,
2410+ } ) ,
2411+ Some ( "batch" ) ,
2412+ None ,
2413+ ) ,
2414+ (
2415+ "append ignores empty edits" ,
2416+ serde_json:: json!( {
2417+ "filePath" : "src/example.ts" ,
2418+ "appendContent" : "append" ,
2419+ "edits" : [ ] ,
2420+ } ) ,
2421+ Some ( "edit_match" ) ,
2422+ None ,
2423+ ) ,
2424+ (
2425+ "symbol deletion keeps empty content" ,
2426+ serde_json:: json!( {
2427+ "filePath" : "src/example.ts" ,
2428+ "symbol" : "target" ,
2429+ "content" : "" ,
2430+ } ) ,
2431+ Some ( "edit_symbol" ) ,
2432+ None ,
2433+ ) ,
2434+ (
2435+ "content without a symbol is rejected" ,
2436+ serde_json:: json!( {
2437+ "filePath" : "src/example.ts" ,
2438+ "symbol" : "" ,
2439+ "content" : "replacement" ,
2440+ } ) ,
2441+ None ,
2442+ Some ( "requires a non-empty string 'symbol'" ) ,
2443+ ) ,
2444+ (
2445+ "two real modes conflict" ,
2446+ serde_json:: json!( {
2447+ "filePath" : "src/example.ts" ,
2448+ "appendContent" : "append" ,
2449+ "edits" : [ { "oldString" : "old" , "newString" : "new" } ] ,
2450+ } ) ,
2451+ None ,
2452+ Some ( "conflicting modes" ) ,
2453+ ) ,
2454+ (
2455+ "all empty fields have no mode" ,
2456+ serde_json:: json!( {
2457+ "filePath" : "src/example.ts" ,
2458+ "appendContent" : "" ,
2459+ "edits" : [ ] ,
2460+ "symbol" : "" ,
2461+ "content" : "" ,
2462+ "oldString" : "" ,
2463+ "newString" : "" ,
2464+ "replaceAll" : null,
2465+ "occurrence" : null,
2466+ } ) ,
2467+ None ,
2468+ Some ( "exactly one of" ) ,
2469+ ) ,
2470+ ] ;
2471+
2472+ for ( label, arguments, command, expected_error) in cases {
2473+ match ( command, expected_error) {
2474+ ( Some ( command) , None ) => {
2475+ let translated = subc_translate_owned ( "edit" , arguments, project)
2476+ . unwrap_or_else ( |error| panic ! ( "{label}: {}" , error. message) ) ;
2477+ assert_eq ! ( translated. command, command, "{label}" ) ;
2478+ match label {
2479+ "edits ignores empty mode sentinels" => {
2480+ assert_eq ! (
2481+ translated. args[ "edits" ] [ 0 ] [ "match" ] ,
2482+ Value :: String ( "old" . to_string( ) )
2483+ ) ;
2484+ assert_eq ! (
2485+ translated. args[ "edits" ] [ 0 ] [ "replacement" ] ,
2486+ Value :: String ( "new" . to_string( ) )
2487+ ) ;
2488+ }
2489+ "append ignores empty edits" => {
2490+ assert_eq ! (
2491+ translated. args[ "append_content" ] ,
2492+ Value :: String ( "append" . to_string( ) )
2493+ ) ;
2494+ }
2495+ "symbol deletion keeps empty content" => {
2496+ assert_eq ! ( translated. args[ "content" ] , Value :: String ( String :: new( ) ) ) ;
2497+ }
2498+ _ => unreachable ! ( "unexpected successful edit mode case" ) ,
2499+ }
2500+ }
2501+ ( None , Some ( expected_error) ) => {
2502+ let translation_error = subc_translate_owned ( "edit" , arguments, project)
2503+ . expect_err ( "meaningful mode case must fail" ) ;
2504+ assert ! (
2505+ translation_error. message. contains( expected_error) ,
2506+ "{label}: {}" ,
2507+ translation_error. message
2508+ ) ;
2509+ }
2510+ _ => unreachable ! ( "case must expect exactly one outcome" ) ,
2511+ }
2512+ }
2513+ }
2514+
23372515 #[ test]
23382516 fn edit_normalization_accepts_aliases_and_rejects_ambiguous_scalars ( ) {
23392517 let project = Path :: new ( "/project" ) ;
2518+ let stringified = subc_translate_owned (
2519+ "edit" ,
2520+ serde_json:: json!( {
2521+ "path" : "src/main.ts" ,
2522+ "edits" : "[{\" oldString\" :\" before\" ,\" newString\" :\" after\" }]"
2523+ } ) ,
2524+ project,
2525+ )
2526+ . expect ( "stringified non-empty edits array" ) ;
2527+ assert_eq ! ( stringified. command, "batch" ) ;
2528+
23402529 let normalized = subc_translate_owned (
23412530 "edit" ,
23422531 serde_json:: json!( {
0 commit comments