Skip to main content

aft/
subc_translate.rs

1//! Agent-facing tool → native command translation (subc edge only).
2
3use std::borrow::Cow;
4use std::path::{Path, PathBuf};
5
6use serde_json::{Map, Value};
7
8const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct Translated {
12    pub command: String,
13    pub args: Map<String, Value>,
14}
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct TranslateContext {
18    pub diagnostics_on_edit: bool,
19    pub preview: bool,
20    pub effective_hashline: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct TranslateError {
25    pub code: &'static str,
26    pub message: String,
27}
28
29fn invalid_request(message: impl Into<String>) -> TranslateError {
30    TranslateError {
31        code: "invalid_request",
32        message: message.into(),
33    }
34}
35
36fn path_string<'a>(value: Option<&'a Value>, property: &str) -> Result<&'a str, TranslateError> {
37    value
38        .and_then(Value::as_str)
39        .filter(|value| !value.is_empty())
40        .ok_or_else(|| {
41            invalid_request(format!(
42                "'{property}' must be a non-empty well-formed Unicode string"
43            ))
44        })
45}
46
47fn normalize_path_alias_pair(
48    map: &mut Map<String, Value>,
49    canonical: &str,
50    legacy: &str,
51    required: bool,
52) -> Result<(), TranslateError> {
53    // An empty-string or null value on a path-shaped field means the field was
54    // not supplied in substance. Strip it before presence counting so hosts
55    // that serialize unused optional fields (e.g. `path: ""`) do not trip
56    // validation, and a required field that is only an empty sentinel reports
57    // the missing-required error rather than the well-formed-Unicode one.
58    if is_null_or_empty_string(map.get(canonical)) {
59        map.remove(canonical);
60    }
61    if is_null_or_empty_string(map.get(legacy)) {
62        map.remove(legacy);
63    }
64
65    let has_canonical = map.contains_key(canonical);
66    let has_legacy = map.contains_key(legacy);
67    if !has_canonical && !has_legacy {
68        if required {
69            return Err(invalid_request(format!("'{canonical}' is required")));
70        }
71        return Ok(());
72    }
73
74    if has_canonical && has_legacy {
75        let canonical_value = path_string(map.get(canonical), canonical).map(str::to_owned);
76        let legacy_value = path_string(map.get(legacy), legacy).map(str::to_owned);
77        let (Ok(canonical_value), Ok(legacy_value)) = (canonical_value, legacy_value) else {
78            return Err(invalid_request(format!(
79                "Invalid request: '{canonical}' and '{legacy}' must both be non-empty well-formed Unicode strings"
80            )));
81        };
82        if canonical_value != legacy_value {
83            return Err(invalid_request(format!(
84                "Invalid request: '{canonical}' and '{legacy}' must contain equal decoded strings"
85            )));
86        }
87        map.remove(legacy);
88        return Ok(());
89    }
90
91    if has_canonical {
92        path_string(map.get(canonical), canonical)?;
93    } else if let Ok(legacy_value) = path_string(map.get(legacy), legacy) {
94        map.insert(
95            canonical.to_string(),
96            Value::String(legacy_value.to_string()),
97        );
98        map.remove(legacy);
99    } else {
100        path_string(map.get(legacy), legacy)?;
101    }
102    Ok(())
103}
104
105fn normalize_zoom_target_aliases(target: &mut Value, index: usize) -> Result<(), TranslateError> {
106    let Some(object) = target.as_object_mut() else {
107        return Err(invalid_request(format!(
108            "'targets[{index}].path' must be a non-empty string"
109        )));
110    };
111    normalize_path_alias_pair(object, "path", "filePath", true)
112}
113
114fn normalize_zoom_aliases(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
115    normalize_path_alias_pair(map, "path", "filePath", false)?;
116    let Some(targets) = map.get_mut("targets") else {
117        return Ok(());
118    };
119    match targets {
120        Value::Array(items) => {
121            for (index, target) in items.iter_mut().enumerate() {
122                normalize_zoom_target_aliases(target, index)?;
123            }
124        }
125        Value::Object(_) => normalize_zoom_target_aliases(targets, 0)?,
126        _ => {}
127    }
128    Ok(())
129}
130
131fn normalize_path_arguments(bare_name: &str, args: Value) -> Result<Value, TranslateError> {
132    let mut map = match args {
133        Value::Object(map) => map,
134        _ => return Err(invalid_request("tool arguments must be an object")),
135    };
136
137    match bare_name {
138        "read" | "write" | "move" | "import" => {
139            normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
140        }
141        "edit" => normalize_edit_arguments(&mut map)?,
142        "refactor" => {
143            normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
144        }
145        "zoom" => normalize_zoom_aliases(&mut map)?,
146        "callgraph" => {
147            normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
148            normalize_path_alias_pair(&mut map, "toPath", "toFile", false)?;
149        }
150        "safety" => normalize_path_alias_pair(&mut map, "path", "filePath", false)?,
151        "grep" | "search" | "conflicts" => {
152            // An empty-string or null `path` on these optional-path tools means
153            // the field was not supplied; strip it so it cannot trip the
154            // well-formed check.
155            if is_null_or_empty_string(map.get("path")) {
156                map.remove("path");
157            } else if map.contains_key("path") {
158                path_string(map.get("path"), "path")?;
159            }
160        }
161        _ => {}
162    }
163
164    Ok(Value::Object(map))
165}
166
167fn normalize_edit_arguments(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
168    normalize_edit_path_alias(map)?;
169
170    let supplied_line_fields = ["startLine", "endLine"]
171        .into_iter()
172        .filter(|key| map.contains_key(*key))
173        .collect::<Vec<_>>();
174    if !supplied_line_fields.is_empty() {
175        let fields = supplied_line_fields
176            .iter()
177            .map(|field| format!("'{field}'"))
178            .collect::<Vec<_>>()
179            .join(" and ");
180        return Err(invalid_request(format!(
181            "edit: top-level {fields} are invalid; line-range fields are valid only inside 'edits[]'. Use edits: [{{ startLine, endLine, content }}]."
182        )));
183    }
184
185    let unknown_root_keys = map
186        .keys()
187        .filter(|key| {
188            !matches!(
189                key.as_str(),
190                "path"
191                    | "filePath"
192                    | "appendContent"
193                    | "edits"
194                    | "symbol"
195                    | "content"
196                    | "oldString"
197                    | "newString"
198                    | "replaceAll"
199                    | "occurrence"
200            )
201        })
202        .cloned()
203        .collect::<Vec<_>>();
204    if !unknown_root_keys.is_empty() {
205        return Err(invalid_request(format_unknown_keys(unknown_root_keys)));
206    }
207
208    let modes = edit_modes_present(map);
209    if has_orphaned_symbol_content(map) {
210        return Err(invalid_request(
211            "edit: 'content' requires a non-empty string 'symbol' when symbol mode is selected",
212        ));
213    }
214    if modes.len() > 1 {
215        return Err(invalid_request(format!(
216            "edit: conflicting modes: {}. Omit unused optional fields entirely; do not send empty strings or empty arrays for them.",
217            modes.join(", ")
218        )));
219    }
220    let Some(mode) = modes.first().copied() else {
221        return Err(invalid_request(
222            "edit: exactly one of `appendContent`, `edits`, or `symbol` plus `content` is required. Omit unused optional fields entirely; do not send empty strings or empty arrays for them.",
223        ));
224    };
225
226    match mode {
227        "appendContent" => {
228            if !matches!(map.get("appendContent"), Some(Value::String(_))) {
229                return Err(invalid_request("edit: 'appendContent' must be a string"));
230            }
231        }
232        "edits" => {
233            let items = parse_edit_array(map.remove("edits"))?;
234            let normalized = items
235                .into_iter()
236                .enumerate()
237                .map(|(index, item)| normalize_edit_item(item, index))
238                .collect::<Result<Vec<_>, _>>()?;
239            map.insert(
240                "edits".to_string(),
241                Value::Array(normalized.into_iter().map(Value::Object).collect()),
242            );
243        }
244        "symbol/content" => {
245            if !matches!(map.get("symbol"), Some(Value::String(_))) {
246                return Err(invalid_request(
247                    "edit: 'symbol' must be a string when symbol mode is selected",
248                ));
249            }
250            if !matches!(map.get("content"), Some(Value::String(_))) {
251                return Err(invalid_request(
252                    "edit: symbol mode requires both 'symbol' and 'content' string properties",
253                ));
254            }
255        }
256        "oldString/newString" => {
257            let mut item = Map::new();
258            for key in ["oldString", "newString", "replaceAll", "occurrence"] {
259                if let Some(value) = map.get(key) {
260                    item.insert(key.to_string(), value.clone());
261                }
262                map.remove(key);
263            }
264            let normalized = normalize_edit_item(Value::Object(item), 0)?;
265            map.insert(
266                "edits".to_string(),
267                Value::Array(vec![Value::Object(normalized)]),
268            );
269        }
270        _ => unreachable!("edit mode list contains an unknown mode"),
271    }
272
273    let path = map
274        .get("path")
275        .ok_or_else(|| invalid_request("'path' is required"))?;
276    path_string(Some(path), "path")?;
277    Ok(())
278}
279
280fn normalize_edit_path_alias(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
281    // An empty-string or null path means the field was not supplied in
282    // substance. Strip it before alias resolution so a legacy-only empty
283    // sentinel reports the missing-required error rather than the
284    // well-formed-Unicode one.
285    if is_null_or_empty_string(map.get("path")) {
286        map.remove("path");
287    }
288    if is_null_or_empty_string(map.get("filePath")) {
289        map.remove("filePath");
290    }
291    let has_path = map.contains_key("path");
292    let has_file_path = map.contains_key("filePath");
293    match (has_path, has_file_path) {
294        (true, true) => normalize_path_alias_pair(map, "path", "filePath", false),
295        (false, true) => normalize_path_alias_pair(map, "path", "filePath", false),
296        (false, false) | (true, false) => Ok(()),
297    }
298}
299
300fn edit_modes_present(map: &mut Map<String, Value>) -> Vec<&'static str> {
301    // Some hosts serialize every optional field with an empty sentinel. Remove
302    // fields that cannot select a mode so later translation cannot revive them.
303    let has_append_content = is_non_empty_string(map.get("appendContent"));
304    if !has_append_content {
305        map.remove("appendContent");
306    }
307
308    let has_edits = normalize_edit_array_sentinels(map);
309    if !has_edits {
310        map.remove("edits");
311    }
312
313    let has_symbol = is_non_empty_string(map.get("symbol"));
314    if !has_symbol {
315        map.remove("symbol");
316        if is_null_or_empty_string(map.get("content")) {
317            map.remove("content");
318        }
319    } else if matches!(map.get("content"), Some(Value::Null)) {
320        map.remove("content");
321    }
322
323    let has_single_edit = is_non_empty_string(map.get("oldString"));
324    if !has_single_edit {
325        for key in ["oldString", "newString", "replaceAll", "occurrence"] {
326            map.remove(key);
327        }
328    } else {
329        for key in ["newString", "replaceAll", "occurrence"] {
330            if matches!(map.get(key), Some(Value::Null)) {
331                map.remove(key);
332            }
333        }
334    }
335
336    let mut modes = Vec::new();
337    if has_append_content {
338        modes.push("appendContent");
339    }
340    if has_edits {
341        modes.push("edits");
342    }
343    if has_symbol {
344        modes.push("symbol/content");
345    }
346    if has_single_edit {
347        modes.push("oldString/newString");
348    }
349    modes
350}
351
352fn is_non_empty_string(value: Option<&Value>) -> bool {
353    matches!(value, Some(Value::String(value)) if !value.is_empty())
354}
355
356fn is_null_or_empty_string(value: Option<&Value>) -> bool {
357    match value {
358        None | Some(Value::Null) => true,
359        Some(Value::String(value)) => value.is_empty(),
360        Some(_) => false,
361    }
362}
363
364/// An edits item is a serialization sentinel when every payload field carries
365/// its type-default value and the real payload lives in a sibling field. Such
366/// an item carries no real edit intent and must not claim the `edits` mode.
367///
368/// A pure line-range item ({startLine,endLine,content}) is never a sentinel,
369/// even when `content` is "", because deleting lines is real edit intent. A
370/// null `oldString` with a non-null range boundary is treated the same way. A
371/// real replacement has a non-empty `oldString`, so it is never a sentinel.
372/// `{oldString:"", newString:"non-empty"}` is deliberately NOT a sentinel:
373/// it is kept so the batch parser reports its specific empty-match error
374/// instead of silently discarding a broken but intentional edit.
375fn is_edit_sentinel_item(item: &Value) -> bool {
376    let Some(obj) = item.as_object() else {
377        return false;
378    };
379    // `oldString` must be present with an empty-string or null value.
380    let old_string_empty =
381        obj.contains_key("oldString") && is_null_or_empty_string(obj.get("oldString"));
382    if !old_string_empty {
383        return false;
384    }
385    // A non-null range boundary proves that a null oldString belongs to a
386    // line-range item, not an all-null serialization sentinel.
387    if matches!(obj.get("oldString"), Some(Value::Null))
388        && ["startLine", "endLine"]
389            .iter()
390            .any(|key| !matches!(obj.get(*key), None | Some(Value::Null)))
391    {
392        return false;
393    }
394    // Every other payload field must also carry its omitted-value sentinel.
395    // Meaningful occurrence or replaceAll values must reach item-family
396    // validation instead of being silently discarded.
397    is_null_or_empty_string(obj.get("newString"))
398        && is_null_or_empty_string(obj.get("content"))
399        && matches!(
400            obj.get("replaceAll"),
401            None | Some(Value::Null) | Some(Value::Bool(false))
402        )
403        && is_default_occurrence(obj.get("occurrence"))
404}
405
406fn has_meaningful_find_payload(item: &Map<String, Value>) -> bool {
407    is_non_empty_string(item.get("oldString"))
408}
409
410fn is_default_occurrence(value: Option<&Value>) -> bool {
411    match value {
412        None | Some(Value::Null) => true,
413        Some(Value::Number(value)) => value.as_u64() == Some(1),
414        _ => false,
415    }
416}
417
418/// Filter serialization-sentinel items out of the `edits` array (or its
419/// stringified form) and rewrite `map["edits"]` to the survivors. Returns
420/// whether any real edit items remain, i.e. whether the `edits` mode is still
421/// claimed. A non-empty malformed string (or a non-array root) stays an edits
422/// claim so the existing parser can report its specific validation error.
423fn normalize_edit_array_sentinels(map: &mut Map<String, Value>) -> bool {
424    let Some(value) = map.get("edits") else {
425        return false;
426    };
427    match value {
428        Value::Array(items) => {
429            let survivors: Vec<Value> = items
430                .iter()
431                .filter(|item| !is_edit_sentinel_item(item))
432                .cloned()
433                .collect();
434            if survivors.is_empty() {
435                false
436            } else {
437                map.insert("edits".to_string(), Value::Array(survivors));
438                true
439            }
440        }
441        Value::String(raw) if raw.is_empty() => false,
442        Value::String(raw) => match serde_json::from_str::<Value>(raw) {
443            Ok(Value::Array(items)) => {
444                let survivors: Vec<Value> = items
445                    .iter()
446                    .filter(|item| !is_edit_sentinel_item(item))
447                    .cloned()
448                    .collect();
449                if survivors.is_empty() {
450                    false
451                } else {
452                    map.insert("edits".to_string(), Value::Array(survivors));
453                    true
454                }
455            }
456            _ => true,
457        },
458        _ => false,
459    }
460}
461
462fn has_orphaned_symbol_content(map: &Map<String, Value>) -> bool {
463    is_non_empty_string(map.get("content")) && !is_non_empty_string(map.get("symbol"))
464}
465
466fn format_unknown_keys(mut keys: Vec<String>) -> String {
467    keys.sort();
468    format!(
469        "Unrecognized keys: {}",
470        keys.iter()
471            .map(|key| format!("\"{key}\""))
472            .collect::<Vec<_>>()
473            .join(", ")
474    )
475}
476
477fn parse_edit_array(value: Option<Value>) -> Result<Vec<Value>, TranslateError> {
478    let Some(value) = value else {
479        return Err(invalid_request("edit: 'edits' must be a non-empty array"));
480    };
481    let value = if let Value::String(raw) = value {
482        serde_json::from_str::<Value>(&raw).map_err(|_| {
483            invalid_request("edit: 'edits' must contain valid JSON representing an array")
484        })?
485    } else {
486        value
487    };
488    let Value::Array(items) = value else {
489        return Err(invalid_request(
490            "edit: 'edits' JSON must have an array root",
491        ));
492    };
493    if items.is_empty() {
494        return Err(invalid_request("edit: 'edits' array must not be empty"));
495    }
496    Ok(items)
497}
498
499/// Normalize default fields before selecting an edit family.
500///
501/// A family whose payload is only omitted-value sentinels yields to the family
502/// with meaningful payload. This preserves intentional line-range deletes
503/// while allowing hosts that serialize unused fields to submit find/replace
504/// requests without a false mixed-mode error.
505fn normalize_edit_item_sentinels(item: &mut Map<String, Value>) {
506    let had_range_fields = ["startLine", "endLine", "content"]
507        .iter()
508        .any(|key| item.contains_key(*key));
509
510    // Null is how some hosts serialize an omitted optional property. Remove it
511    // before counting either edit family so it cannot create a false conflict.
512    for key in [
513        "oldString",
514        "newString",
515        "replaceAll",
516        "occurrence",
517        "startLine",
518        "endLine",
519        "content",
520    ] {
521        if matches!(item.get(key), Some(Value::Null)) {
522            item.remove(key);
523        }
524    }
525
526    let content_is_empty =
527        matches!(item.get("content"), Some(Value::String(value)) if value.is_empty());
528    if has_meaningful_find_payload(item) && (!item.contains_key("content") || content_is_empty) {
529        // A meaningful match wins over blank or absent line-range payload. The
530        // boundaries are serializer defaults too when no range content exists.
531        for key in ["startLine", "endLine", "content"] {
532            item.remove(key);
533        }
534        // Hosts commonly emit false and 1 alongside an omitted range. They
535        // have no effect on a find/replace edit, so keep the established
536        // canonical form while preserving meaningful find options.
537        if had_range_fields {
538            if matches!(item.get("replaceAll"), Some(Value::Bool(false))) {
539                item.remove("replaceAll");
540            }
541            if is_default_occurrence(item.get("occurrence")) && item.contains_key("occurrence") {
542                item.remove("occurrence");
543            }
544        }
545        return;
546    }
547
548    if !is_non_empty_string(item.get("content")) {
549        return;
550    }
551
552    // A meaningful range replacement wins over default find/replace fields.
553    // Non-default find fields remain so the mixed-mode validator can reject
554    // genuinely ambiguous requests.
555    if matches!(item.get("oldString"), Some(Value::String(value)) if value.is_empty()) {
556        item.remove("oldString");
557    }
558    if matches!(item.get("newString"), Some(Value::String(value)) if value.is_empty()) {
559        item.remove("newString");
560    }
561    if matches!(item.get("replaceAll"), Some(Value::Bool(false))) {
562        item.remove("replaceAll");
563    }
564    if is_default_occurrence(item.get("occurrence")) && item.contains_key("occurrence") {
565        item.remove("occurrence");
566    }
567}
568
569fn normalize_edit_item(value: Value, index: usize) -> Result<Map<String, Value>, TranslateError> {
570    let Value::Object(mut item) = value else {
571        return Err(invalid_request(format!(
572            "edit: edits[{index}] must be an object"
573        )));
574    };
575
576    normalize_item_alias(&mut item, "oldString", "oldText");
577    normalize_item_alias(&mut item, "newString", "newText");
578    normalize_edit_item_sentinels(&mut item);
579
580    let has_find = ["oldString", "newString", "replaceAll", "occurrence"]
581        .iter()
582        .any(|key| item.contains_key(*key));
583    let has_range = ["startLine", "endLine", "content"]
584        .iter()
585        .any(|key| item.contains_key(*key));
586    if has_find && has_range {
587        return Err(invalid_request(format!(
588            "edit: edits[{index}] mixes find/replace and line-range fields"
589        )));
590    }
591
592    if has_find {
593        if !matches!(item.get("oldString"), Some(Value::String(_))) {
594            return Err(invalid_request(format!(
595                "edit: edits[{index}] requires string 'oldString'"
596            )));
597        }
598        if item.contains_key("newString")
599            && !matches!(item.get("newString"), Some(Value::String(_)))
600        {
601            return Err(invalid_request(format!(
602                "edit: edits[{index}].newString must be a string"
603            )));
604        }
605        coerce_edit_scalars(&mut item, index)?;
606        validate_edit_item_keys(&item, index)?;
607        return Ok(item);
608    }
609
610    if has_range {
611        for key in ["startLine", "endLine"] {
612            let valid = item
613                .get(key)
614                .and_then(Value::as_u64)
615                .is_some_and(|value| value >= 1 && value <= MAX_SAFE_INTEGER as u64);
616            if !valid {
617                return Err(invalid_request(format!(
618                    "edit: edits[{index}].{key} must be a positive integer"
619                )));
620            }
621        }
622        let start = item.get("startLine").and_then(Value::as_u64).unwrap();
623        let end = item.get("endLine").and_then(Value::as_u64).unwrap();
624        if start > end {
625            return Err(invalid_request(format!(
626                "edit: edits[{index}] requires startLine <= endLine"
627            )));
628        }
629        if !matches!(item.get("content"), Some(Value::String(_))) {
630            return Err(invalid_request(format!(
631                "edit: edits[{index}] requires string 'content'"
632            )));
633        }
634        validate_edit_item_keys(&item, index)?;
635        return Ok(item);
636    }
637
638    Err(invalid_request(format!(
639        "edit: edits[{index}] must be a find/replace or line-range item"
640    )))
641}
642
643fn normalize_item_alias(item: &mut Map<String, Value>, canonical: &str, legacy: &str) {
644    if let Some(legacy_value) = item.remove(legacy) {
645        if !item.contains_key(canonical) {
646            item.insert(canonical.to_string(), legacy_value);
647        }
648    }
649}
650
651fn validate_edit_item_keys(item: &Map<String, Value>, index: usize) -> Result<(), TranslateError> {
652    let unknown = item
653        .keys()
654        .filter(|key| {
655            !matches!(
656                key.as_str(),
657                "oldString"
658                    | "newString"
659                    | "replaceAll"
660                    | "occurrence"
661                    | "startLine"
662                    | "endLine"
663                    | "content"
664            )
665        })
666        .cloned()
667        .collect::<Vec<_>>();
668    if unknown.is_empty() {
669        Ok(())
670    } else {
671        Err(invalid_request(format!(
672            "edit: edits[{index}] contains {}",
673            format_unknown_keys(unknown)
674        )))
675    }
676}
677
678fn coerce_edit_scalars(item: &mut Map<String, Value>, index: usize) -> Result<(), TranslateError> {
679    if item.contains_key("replaceAll") && item.contains_key("occurrence") {
680        return Err(invalid_request(format!(
681            "edit: edits[{index}] cannot contain both 'replaceAll' and 'occurrence'"
682        )));
683    }
684    if let Some(value) = item.get("replaceAll") {
685        let coerced = match value {
686            Value::Bool(value) => Some(*value),
687            Value::Number(number) if number.as_f64() == Some(0.0) => Some(false),
688            Value::Number(number) if number.as_f64() == Some(1.0) => Some(true),
689            Value::String(value) if value == "0" => Some(false),
690            Value::String(value) if value == "1" => Some(true),
691            Value::String(value) if value.eq_ignore_ascii_case("true") => Some(true),
692            Value::String(value) if value.eq_ignore_ascii_case("false") => Some(false),
693            _ => None,
694        };
695        let Some(coerced) = coerced else {
696            return Err(invalid_request(format!(
697                "edit: edits[{index}].replaceAll must be a boolean, true/false string, or 0/1"
698            )));
699        };
700        item.insert("replaceAll".to_string(), Value::Bool(coerced));
701    }
702
703    if item.contains_key("occurrence") {
704        let value = item.get("occurrence").cloned().unwrap();
705        match coerce_edit_occurrence(&value, index)? {
706            Some(value) => {
707                item.insert("occurrence".to_string(), Value::Number(value.into()));
708            }
709            None => {
710                item.remove("occurrence");
711            }
712        }
713    }
714    Ok(())
715}
716
717fn coerce_edit_occurrence(value: &Value, index: usize) -> Result<Option<u64>, TranslateError> {
718    if value.is_null() {
719        return Ok(None);
720    }
721    let parsed = match value {
722        Value::Number(number) => number
723            .as_u64()
724            .filter(|value| *value <= MAX_SAFE_INTEGER as u64)
725            .or_else(|| {
726                number.as_f64().and_then(|value| {
727                    (value.is_finite()
728                        && value.fract() == 0.0
729                        && value >= 1.0
730                        && value <= MAX_SAFE_INTEGER as f64)
731                        .then_some(value as u64)
732                })
733            }),
734        Value::String(raw) => {
735            let trimmed = raw.trim_matches(|ch: char| ch.is_ascii_whitespace());
736            if trimmed.is_empty() {
737                return Ok(None);
738            }
739            let digits = trimmed.strip_prefix('+').unwrap_or(trimmed);
740            if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
741                None
742            } else {
743                digits
744                    .parse::<u64>()
745                    .ok()
746                    .filter(|value| *value <= MAX_SAFE_INTEGER as u64)
747            }
748        }
749        _ => None,
750    };
751    match parsed {
752        Some(value) if value >= 1 => Ok(Some(value)),
753        _ => Err(invalid_request(format!(
754            "edit: edits[{index}].occurrence must be a positive integer"
755        ))),
756    }
757}
758
759fn unsupported_tool(message: impl Into<String>) -> TranslateError {
760    TranslateError {
761        code: "unsupported_tool",
762        message: message.into(),
763    }
764}
765
766fn resolve_home_dir() -> Option<PathBuf> {
767    let raw = std::env::var_os("HOME")
768        .or_else(|| std::env::var_os("USERPROFILE"))
769        .map(PathBuf::from)?;
770    Some(raw)
771}
772
773fn expand_tilde(target: &str) -> Cow<'_, str> {
774    if target == "~" {
775        return resolve_home_dir()
776            .map(|h| Cow::Owned(h.to_string_lossy().into_owned()))
777            .unwrap_or(Cow::Borrowed(target));
778    }
779    if let Some(rest) = target.strip_prefix("~/") {
780        if let Some(home) = resolve_home_dir() {
781            return Cow::Owned(home.join(rest).to_string_lossy().into_owned());
782        }
783    }
784    // Ordinary paths (the overwhelmingly common case) borrow — no allocation.
785    Cow::Borrowed(target)
786}
787
788/// Decode an RFC 8089 `file:` URL to a local filesystem path.
789///
790/// Agents (and users pasting editor links) routinely spell local paths as
791/// `file:///path`, `file:/path`, or `file://localhost/path`; rejecting those
792/// only produces failed tool calls. Accepts empty/`localhost` authorities and
793/// percent-decodes the path. A `file://server/share` form (non-local
794/// authority) becomes a UNC path on Windows and is left undecoded elsewhere.
795/// Grants no extra access: the decoded path flows through the same
796/// resolution and permission checks as any literal path — the plugins apply
797/// the SAME decoding before their permission gates so both layers judge the
798/// identical target.
799fn decode_file_url(target: &str) -> Option<String> {
800    let rest = target.strip_prefix("file:")?;
801    let path_part = if let Some(after) = rest.strip_prefix("//") {
802        let (authority, path) = match after.find('/') {
803            Some(index) => after.split_at(index),
804            None => (after, ""),
805        };
806        match authority {
807            "" | "localhost" => path.to_string(),
808            server if cfg!(windows) => format!("//{server}{path}"),
809            _ => return None,
810        }
811    } else {
812        // RFC 8089 minimal form: `file:/path` (exactly one slash).
813        if !rest.starts_with('/') {
814            return None;
815        }
816        rest.to_string()
817    };
818    let decoded = percent_decode(&path_part);
819    // `file:///C:/path` decodes to `/C:/path`; strip the leading slash so the
820    // drive-letter form is a valid Windows absolute path.
821    if cfg!(windows) {
822        let bytes = decoded.as_bytes();
823        if bytes.len() >= 3
824            && bytes[0] == b'/'
825            && bytes[1].is_ascii_alphabetic()
826            && bytes[2] == b':'
827        {
828            return Some(decoded[1..].to_string());
829        }
830    }
831    Some(decoded)
832}
833
834fn percent_decode(input: &str) -> String {
835    let bytes = input.as_bytes();
836    let mut out = Vec::with_capacity(bytes.len());
837    let mut index = 0;
838    while index < bytes.len() {
839        if bytes[index] == b'%' && index + 2 < bytes.len() {
840            let hex = &input[index + 1..index + 3];
841            if let Ok(value) = u8::from_str_radix(hex, 16) {
842                out.push(value);
843                index += 3;
844                continue;
845            }
846        }
847        out.push(bytes[index]);
848        index += 1;
849    }
850    String::from_utf8_lossy(&out).into_owned()
851}
852
853pub fn resolve_path_from_project_root(project_root: &Path, target: &str) -> PathBuf {
854    let target = decode_file_url(target)
855        .map(std::borrow::Cow::Owned)
856        .unwrap_or(std::borrow::Cow::Borrowed(target));
857    let expanded = expand_tilde(&target);
858    let path = Path::new(expanded.as_ref());
859    let joined = if path.is_absolute() {
860        path.to_path_buf()
861    } else {
862        project_root.join(path)
863    };
864    normalize_lexically(&joined)
865}
866
867fn normalize_lexically(path: &Path) -> PathBuf {
868    use std::path::Component;
869
870    let mut out = PathBuf::new();
871    for component in path.components() {
872        match component {
873            Component::CurDir => {}
874            Component::ParentDir => {
875                if !out.pop() {
876                    out.push(component.as_os_str());
877                }
878            }
879            Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
880                out.push(component.as_os_str());
881            }
882        }
883    }
884    if out.as_os_str().is_empty() {
885        PathBuf::from(".")
886    } else {
887        out
888    }
889}
890
891fn is_empty_param(value: &Value) -> bool {
892    match value {
893        Value::Null => true,
894        Value::String(s) => s.is_empty(),
895        Value::Array(a) => a.is_empty(),
896        Value::Object(o) => o.is_empty(),
897        _ => false,
898    }
899}
900
901fn coerce_optional_int_result(
902    value: Option<&Value>,
903    param_name: &str,
904    min: i64,
905    max: i64,
906) -> Result<Option<u64>, TranslateError> {
907    let Some(value) = value else {
908        return Ok(None);
909    };
910    if value.is_null()
911        || matches!(value, Value::String(s) if s.is_empty())
912        || matches!(value, Value::Array(a) if a.is_empty())
913        || matches!(value, Value::Object(o) if o.is_empty())
914    {
915        return Ok(None);
916    }
917    if matches!(value, Value::Number(num) if num.as_i64() == Some(0) && min > 0) {
918        return Ok(None);
919    }
920
921    let int_error = || {
922        invalid_request(format!(
923            "{param_name} must be an integer between {min} and {max}"
924        ))
925    };
926    let n = match value {
927        Value::Number(num) => num.as_i64().ok_or_else(int_error)?,
928        Value::String(s) => {
929            let parsed = s.parse::<f64>().map_err(|_| int_error())?;
930            if !parsed.is_finite() || parsed.fract() != 0.0 {
931                return Err(int_error());
932            }
933            parsed as i64
934        }
935        _ => return Err(int_error()),
936    };
937    if n < min || n > max {
938        return Err(invalid_request(format!(
939            "{param_name} must be between {min} and {max}"
940        )));
941    }
942    Ok(Some(n as u64))
943}
944
945fn agent_args_map(args: Value) -> Map<String, Value> {
946    match args {
947        Value::Object(map) => map,
948        _ => Map::new(),
949    }
950}
951
952pub(crate) fn supports_tool(bare_name: &str) -> bool {
953    matches!(
954        bare_name,
955        "bash"
956            | "powershell"
957            | "status"
958            | "read"
959            | "write"
960            | "edit"
961            | "apply_patch"
962            | "grep"
963            | "glob"
964            | "search"
965            | "outline"
966            | "zoom"
967            | "inspect"
968            | "callgraph"
969            | "conflicts"
970            | "ast_search"
971            | "ast_replace"
972            | "delete"
973            | "move"
974            | "import"
975            | "refactor"
976            | "safety"
977    )
978}
979
980fn insert_resolved_file(map: &mut Map<String, Value>, project_root: &Path, file_path: &str) {
981    let resolved = resolve_path_from_project_root(project_root, file_path);
982    map.insert(
983        "file".to_string(),
984        Value::String(resolved.to_string_lossy().into_owned()),
985    );
986}
987
988fn insert_read_file(map: &mut Map<String, Value>, project_root: &Path, file_path: &str) {
989    if file_path.starts_with("issue://") || file_path.starts_with("pr://") {
990        map.insert("file".to_string(), Value::String(file_path.to_string()));
991    } else {
992        insert_resolved_file(map, project_root, file_path);
993    }
994}
995
996pub fn subc_translate(
997    bare_name: &str,
998    agent_args: &Value,
999    project_root: &Path,
1000) -> Result<Translated, TranslateError> {
1001    subc_translate_owned(bare_name, agent_args.clone(), project_root)
1002}
1003
1004pub fn subc_translate_owned(
1005    bare_name: &str,
1006    agent_args: Value,
1007    project_root: &Path,
1008) -> Result<Translated, TranslateError> {
1009    subc_translate_owned_with_context(
1010        bare_name,
1011        agent_args,
1012        project_root,
1013        TranslateContext::default(),
1014    )
1015}
1016
1017pub fn subc_translate_with_context(
1018    bare_name: &str,
1019    agent_args: &Value,
1020    project_root: &Path,
1021    ctx: TranslateContext,
1022) -> Result<Translated, TranslateError> {
1023    subc_translate_owned_with_context(bare_name, agent_args.clone(), project_root, ctx)
1024}
1025
1026pub fn subc_translate_owned_with_context(
1027    bare_name: &str,
1028    agent_args: Value,
1029    project_root: &Path,
1030    ctx: TranslateContext,
1031) -> Result<Translated, TranslateError> {
1032    if bare_name == "edit" && ctx.effective_hashline {
1033        return crate::hashline::integration::translate_gate_on_edit(&agent_args)
1034            .map(|translation| {
1035                let mut args = translation
1036                    .to_native_args()
1037                    .as_object()
1038                    .cloned()
1039                    .expect("hashline native arguments are always an object");
1040                if ctx.preview {
1041                    args.insert("preview".to_string(), Value::Bool(true));
1042                }
1043                Translated {
1044                    command: translation.command.to_string(),
1045                    args,
1046                }
1047            })
1048            .map_err(|rejection| TranslateError {
1049                code: rejection.code.as_str(),
1050                message: format!(
1051                    "{} at {}: {}\n{}",
1052                    rejection.code.as_str(),
1053                    rejection.stage.as_str(),
1054                    rejection.message,
1055                    rejection.steering
1056                ),
1057            });
1058    }
1059    let agent_args = normalize_path_arguments(bare_name, agent_args)?;
1060    match bare_name {
1061        "bash" => translate_bash(agent_args, project_root),
1062        "powershell" => translate_powershell(agent_args, project_root),
1063        "status" => Ok(Translated {
1064            command: "status".into(),
1065            args: Map::new(),
1066        }),
1067        "read" => translate_read(agent_args, project_root),
1068        "write" => translate_write(agent_args, project_root, ctx),
1069        "edit" => translate_edit(agent_args, project_root, ctx),
1070        "apply_patch" => translate_apply_patch(agent_args),
1071        "grep" => translate_grep(agent_args, project_root),
1072        "glob" => translate_glob(agent_args),
1073        "search" => translate_search(agent_args),
1074        "outline" => translate_outline(agent_args, project_root),
1075        "zoom" => translate_zoom(agent_args, project_root),
1076        "inspect" => translate_inspect(agent_args, project_root),
1077        "callgraph" => translate_callgraph(agent_args, project_root),
1078        "conflicts" => translate_conflicts(agent_args),
1079        "ast_search" => translate_ast_search(agent_args),
1080        "ast_replace" => translate_ast_replace(agent_args),
1081        "delete" => translate_delete(agent_args, project_root),
1082        "move" => translate_move(agent_args, project_root),
1083        "import" => translate_import(agent_args),
1084        "refactor" => translate_refactor(agent_args),
1085        "safety" => translate_safety(agent_args, project_root),
1086        other => Err(unsupported_tool(format!(
1087            "subc_translate: unsupported tool {other:?}"
1088        ))),
1089    }
1090}
1091
1092fn coerce_boolean(value: &Value) -> bool {
1093    match value {
1094        Value::Bool(value) => *value,
1095        Value::Number(num) => num.as_i64() == Some(1) || num.as_u64() == Some(1),
1096        Value::String(raw) => {
1097            let normalized = raw.trim().to_ascii_lowercase();
1098            normalized == "true" || normalized == "1"
1099        }
1100        _ => false,
1101    }
1102}
1103
1104fn translate_bash(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1105    let mut map_in = agent_args_map(args);
1106    if let Some(Value::Object(params)) = map_in.remove("params") {
1107        map_in = params;
1108    }
1109    let command = map_in
1110        .get("command")
1111        .and_then(Value::as_str)
1112        .ok_or_else(|| invalid_request("'command' is required"))?;
1113
1114    let mut out = Map::new();
1115    out.insert("command".to_string(), Value::String(command.to_string()));
1116
1117    if let Some(shell) = map_in.get("shell") {
1118        if shell.as_str() != Some("powershell") {
1119            return Err(invalid_request("bash: 'shell' must be 'powershell'"));
1120        }
1121        out.insert("shell".to_string(), Value::String("powershell".to_string()));
1122    }
1123
1124    if let Some(timeout) =
1125        coerce_optional_int_result(map_in.get("timeout"), "timeout", 1, MAX_SAFE_INTEGER)?
1126    {
1127        out.insert("timeout".to_string(), Value::Number(timeout.into()));
1128    }
1129
1130    if let Some(workdir) = map_in
1131        .get("workdir")
1132        .and_then(Value::as_str)
1133        .filter(|value| !value.is_empty())
1134    {
1135        let resolved = resolve_path_from_project_root(project_root, workdir);
1136        out.insert(
1137            "workdir".to_string(),
1138            Value::String(resolved.to_string_lossy().into_owned()),
1139        );
1140    }
1141
1142    if let Some(description) = map_in
1143        .get("description")
1144        .and_then(Value::as_str)
1145        .filter(|value| !value.is_empty())
1146    {
1147        out.insert(
1148            "description".to_string(),
1149            Value::String(description.to_string()),
1150        );
1151    }
1152
1153    let background = map_in.get("background").is_some_and(coerce_boolean);
1154    let pty = map_in.get("pty").is_some_and(coerce_boolean);
1155    let wait = map_in.get("wait").is_some_and(coerce_boolean);
1156    if wait && pty {
1157        return Err(invalid_request(
1158            "bash: wait:true cannot be used with pty:true because PTY sessions run in background",
1159        ));
1160    }
1161    if wait && background {
1162        return Err(invalid_request(
1163            "bash: wait:true cannot be used with background:true",
1164        ));
1165    }
1166    out.insert("background".to_string(), Value::Bool(background));
1167    out.insert("pty".to_string(), Value::Bool(pty));
1168    out.insert("wait".to_string(), Value::Bool(wait));
1169    out.insert(
1170        "notify_on_completion".to_string(),
1171        Value::Bool(background || pty),
1172    );
1173
1174    if let Some(rows) = coerce_optional_int_result(
1175        map_in.get("ptyRows").or_else(|| map_in.get("pty_rows")),
1176        "ptyRows",
1177        1,
1178        60,
1179    )? {
1180        out.insert("pty_rows".to_string(), Value::Number(rows.into()));
1181    }
1182    if let Some(cols) = coerce_optional_int_result(
1183        map_in.get("ptyCols").or_else(|| map_in.get("pty_cols")),
1184        "ptyCols",
1185        1,
1186        140,
1187    )? {
1188        out.insert("pty_cols".to_string(), Value::Number(cols.into()));
1189    }
1190
1191    if let Some(compressed) = map_in.get("compressed") {
1192        out.insert(
1193            "compressed".to_string(),
1194            Value::Bool(coerce_boolean(compressed)),
1195        );
1196    }
1197
1198    let foreground_orchestrate = map_in
1199        .get("foreground_orchestrate")
1200        .map(coerce_boolean)
1201        .unwrap_or(true);
1202    let block_to_completion = map_in
1203        .get("block_to_completion")
1204        .map(coerce_boolean)
1205        .unwrap_or(false);
1206    out.insert(
1207        "foreground_orchestrate".to_string(),
1208        Value::Bool(foreground_orchestrate),
1209    );
1210    out.insert(
1211        "block_to_completion".to_string(),
1212        Value::Bool(block_to_completion),
1213    );
1214
1215    if let Some(permissions_granted) = map_in.get("permissions_granted") {
1216        out.insert(
1217            "permissions_granted".to_string(),
1218            permissions_granted.clone(),
1219        );
1220    }
1221    if let Some(permissions_requested) = map_in.get("permissions_requested") {
1222        out.insert(
1223            "permissions_requested".to_string(),
1224            Value::Bool(coerce_boolean(permissions_requested)),
1225        );
1226    }
1227    if let Some(env) = map_in.get("env") {
1228        out.insert("env".to_string(), env.clone());
1229    }
1230    if let Some(sandbox) = map_in.get("sandbox") {
1231        if sandbox.as_str() != Some("host") {
1232            return Err(invalid_request("bash: 'sandbox' must be 'host'"));
1233        }
1234        out.insert("sandbox".to_string(), sandbox.clone());
1235    }
1236
1237    Ok(Translated {
1238        command: "bash".into(),
1239        args: out,
1240    })
1241}
1242
1243fn translate_powershell(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1244    let mut translated = translate_bash(args, project_root)?;
1245    translated
1246        .args
1247        .insert("shell".to_string(), Value::String("powershell".to_string()));
1248    Ok(translated)
1249}
1250
1251fn translate_callgraph(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1252    let map_in = agent_args_map(args);
1253    let op = map_in
1254        .get("op")
1255        .and_then(Value::as_str)
1256        .filter(|s| !s.is_empty())
1257        .ok_or_else(|| invalid_request("'op' is required"))?;
1258    if !matches!(
1259        op,
1260        "call_tree" | "callers" | "trace_to" | "trace_to_symbol" | "impact" | "trace_data"
1261    ) {
1262        return Err(invalid_request(format!("callgraph: invalid op '{op}'")));
1263    }
1264
1265    let file_path = map_in
1266        .get("path")
1267        .and_then(Value::as_str)
1268        .filter(|s| !s.is_empty())
1269        .ok_or_else(|| invalid_request("'path' is required"))?;
1270    let symbol = map_in
1271        .get("symbol")
1272        .and_then(Value::as_str)
1273        .filter(|s| !s.is_empty())
1274        .ok_or_else(|| invalid_request("'symbol' is required"))?;
1275
1276    if op == "trace_data" && map_in.get("expression").is_none_or(is_empty_param) {
1277        return Err(invalid_request(
1278            "'expression' is required for 'trace_data' op",
1279        ));
1280    }
1281    if op == "trace_to_symbol" && map_in.get("toSymbol").is_none_or(is_empty_param) {
1282        return Err(invalid_request(
1283            "'toSymbol' is required for 'trace_to_symbol' op",
1284        ));
1285    }
1286
1287    let mut out = Map::new();
1288    insert_resolved_file(&mut out, project_root, file_path);
1289    out.insert("symbol".to_string(), Value::String(symbol.to_string()));
1290
1291    if let Some(depth) =
1292        coerce_optional_int_result(map_in.get("depth"), "depth", 1, 9_007_199_254_740_991)?
1293    {
1294        out.insert("depth".to_string(), Value::Number(depth.into()));
1295    }
1296    if let Some(expression) = map_in.get("expression") {
1297        if !is_empty_param(expression) {
1298            out.insert("expression".to_string(), expression.clone());
1299        }
1300    }
1301    if let Some(to_symbol) = map_in.get("toSymbol") {
1302        if !is_empty_param(to_symbol) {
1303            out.insert("toSymbol".to_string(), to_symbol.clone());
1304        }
1305    }
1306    if let Some(to_file) = map_in.get("toPath") {
1307        if !is_empty_param(to_file) {
1308            let to_file = to_file
1309                .as_str()
1310                .ok_or_else(|| invalid_request("'toPath' must be a string"))?;
1311            let resolved = resolve_path_from_project_root(project_root, to_file);
1312            out.insert(
1313                "toFile".to_string(),
1314                Value::String(resolved.to_string_lossy().into_owned()),
1315            );
1316        }
1317    }
1318    if let Some(include_tests) = map_in.get("includeTests") {
1319        if !is_empty_param(include_tests) {
1320            out.insert(
1321                "include_tests".to_string(),
1322                Value::Bool(coerce_boolean(include_tests)),
1323            );
1324        }
1325    }
1326
1327    Ok(Translated {
1328        command: op.to_string(),
1329        args: out,
1330    })
1331}
1332
1333fn insert_common_mutation_flags(out: &mut Map<String, Value>, ctx: TranslateContext) {
1334    out.insert(
1335        "diagnostics".to_string(),
1336        Value::Bool(ctx.diagnostics_on_edit),
1337    );
1338    out.insert("include_diff_content".to_string(), Value::Bool(true));
1339    out.insert("preview".to_string(), Value::Bool(ctx.preview));
1340}
1341
1342fn translate_read(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1343    let map_in = agent_args_map(args);
1344    let file_path = map_in
1345        .get("path")
1346        .and_then(Value::as_str)
1347        .filter(|s| !s.is_empty())
1348        .ok_or_else(|| invalid_request("'path' is required"))?;
1349
1350    let mut out = Map::new();
1351    insert_read_file(&mut out, project_root, file_path);
1352
1353    let mut start_line = map_in.get("startLine").and_then(Value::as_u64);
1354    let mut end_line = map_in.get("endLine").and_then(Value::as_u64);
1355
1356    if start_line.is_none() {
1357        if let Some(offset) = map_in.get("offset").and_then(Value::as_u64) {
1358            start_line = Some(offset);
1359            if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
1360                end_line = Some(offset.saturating_add(limit).saturating_sub(1));
1361            }
1362        }
1363    }
1364
1365    if let Some(sl) = start_line {
1366        out.insert("start_line".to_string(), Value::Number(sl.into()));
1367    }
1368    if let Some(el) = end_line {
1369        out.insert("end_line".to_string(), Value::Number(el.into()));
1370    }
1371    if map_in.get("offset").is_none() {
1372        if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
1373            out.insert("limit".to_string(), Value::Number(limit.into()));
1374        }
1375    }
1376    if let Some(vision_capability) = map_in.get("vision_capability").and_then(Value::as_bool) {
1377        out.insert(
1378            "vision_capability".to_string(),
1379            Value::Bool(vision_capability),
1380        );
1381    }
1382
1383    Ok(Translated {
1384        command: "read".into(),
1385        args: out,
1386    })
1387}
1388
1389fn translate_write(
1390    args: Value,
1391    project_root: &Path,
1392    ctx: TranslateContext,
1393) -> Result<Translated, TranslateError> {
1394    let mut map_in = agent_args_map(args);
1395    let file_path = match map_in.remove("path") {
1396        Some(Value::String(path)) if !path.is_empty() => path,
1397        _ => return Err(invalid_request("'path' is required")),
1398    };
1399    let content = match map_in.remove("content") {
1400        Some(Value::String(content)) => content,
1401        _ => return Err(invalid_request("write: missing required param 'content'")),
1402    };
1403
1404    let mut out = Map::new();
1405    insert_resolved_file(&mut out, project_root, &file_path);
1406    out.insert("content".to_string(), Value::String(content));
1407    out.insert("create_dirs".to_string(), Value::Bool(true));
1408    insert_common_mutation_flags(&mut out, ctx);
1409
1410    Ok(Translated {
1411        command: "write".into(),
1412        args: out,
1413    })
1414}
1415
1416fn translate_edit(
1417    args: Value,
1418    project_root: &Path,
1419    ctx: TranslateContext,
1420) -> Result<Translated, TranslateError> {
1421    let map_in = agent_args_map(args);
1422
1423    if map_in.get("startLine").is_some() || map_in.get("endLine").is_some() {
1424        return Err(invalid_request(
1425            "edit: 'startLine'/'endLine' are not top-level parameters. \
1426             For line-range edits, nest them inside the `edits` array. \
1427             For find/replace, use 'oldString'/'newString'.",
1428        ));
1429    }
1430
1431    let file_path = map_in
1432        .get("path")
1433        .and_then(Value::as_str)
1434        .filter(|s| !s.is_empty())
1435        .ok_or_else(|| invalid_request("'path' is required"))?;
1436
1437    let file_str = resolve_path_from_project_root(project_root, file_path)
1438        .to_string_lossy()
1439        .into_owned();
1440
1441    if let Some(append) = map_in.get("appendContent").and_then(Value::as_str) {
1442        let mut out = Map::new();
1443        out.insert("file".to_string(), Value::String(file_str));
1444        out.insert("op".to_string(), Value::String("append".into()));
1445        out.insert(
1446            "append_content".to_string(),
1447            Value::String(append.to_string()),
1448        );
1449        out.insert("create_dirs".to_string(), Value::Bool(true));
1450        insert_common_mutation_flags(&mut out, ctx);
1451        return Ok(Translated {
1452            command: "edit_match".into(),
1453            args: out,
1454        });
1455    }
1456
1457    if let Some(edits) = map_in.get("edits").and_then(Value::as_array) {
1458        // The batch command is single-file only; glob targets are an
1459        // edit_match capability. A glob path with one find/replace item
1460        // (the folded single-edit form included) must keep routing to
1461        // edit_match or glob edits silently break with "file not found".
1462        if path_is_glob_pattern(file_path) {
1463            if let [single] = edits.as_slice() {
1464                if let Some(obj) = single.as_object() {
1465                    let is_find_replace = obj.contains_key("oldString")
1466                        && !obj.contains_key("startLine")
1467                        && !obj.contains_key("endLine");
1468                    if is_find_replace {
1469                        return translate_single_edit_match(obj, file_str, ctx);
1470                    }
1471                }
1472            }
1473            return Err(invalid_request(
1474                "edit: glob targets support exactly one find/replace edit \
1475                 (oldString/newString); line-range and multi-item batches \
1476                 need a concrete file path",
1477            ));
1478        }
1479        let mut out = Map::new();
1480        out.insert("file".to_string(), Value::String(file_str));
1481        let translated_edits: Vec<Value> = edits
1482            .iter()
1483            .filter_map(|edit| {
1484                let obj = edit.as_object()?;
1485                let mut t = Map::new();
1486                for (key, value) in obj {
1487                    let native_key = match key.as_str() {
1488                        "oldString" => "match",
1489                        "newString" => "replacement",
1490                        "startLine" => "line_start",
1491                        "endLine" => "line_end",
1492                        other => other,
1493                    };
1494                    t.insert(native_key.to_string(), value.clone());
1495                }
1496                Some(Value::Object(t))
1497            })
1498            .collect();
1499        out.insert("edits".to_string(), Value::Array(translated_edits));
1500        insert_common_mutation_flags(&mut out, ctx);
1501        return Ok(Translated {
1502            command: "batch".into(),
1503            args: out,
1504        });
1505    }
1506
1507    let symbol_is_string = map_in.get("symbol").and_then(Value::as_str).is_some();
1508    let old_string_is_string = map_in.get("oldString").and_then(Value::as_str).is_some();
1509    let has_content = map_in.get("content").is_some();
1510
1511    if symbol_is_string && !old_string_is_string && has_content {
1512        let mut out = Map::new();
1513        out.insert("file".to_string(), Value::String(file_str));
1514        out.insert(
1515            "symbol".to_string(),
1516            map_in.get("symbol").cloned().unwrap_or(Value::Null),
1517        );
1518        out.insert("operation".to_string(), Value::String("replace".into()));
1519        out.insert(
1520            "content".to_string(),
1521            map_in.get("content").cloned().unwrap_or(Value::Null),
1522        );
1523        insert_common_mutation_flags(&mut out, ctx);
1524        return Ok(Translated {
1525            command: "edit_symbol".into(),
1526            args: out,
1527        });
1528    }
1529
1530    if old_string_is_string {
1531        return translate_single_edit_match(&map_in, file_str, ctx);
1532    }
1533
1534    Err(invalid_request(
1535        "edit: no edit mode resolved from arguments.",
1536    ))
1537}
1538
1539/// A glob spelling in an edit target (single-file batch is the alternative).
1540fn path_is_glob_pattern(path: &str) -> bool {
1541    path.contains('*') || path.contains('?') || path.contains('{') || path.contains('[')
1542}
1543
1544/// Route one find/replace edit to the `edit_match` command, which owns both
1545/// concrete-file and glob targets.
1546fn translate_single_edit_match(
1547    fields: &Map<String, Value>,
1548    file_str: String,
1549    ctx: TranslateContext,
1550) -> Result<Translated, TranslateError> {
1551    let mut out = Map::new();
1552    out.insert("file".to_string(), Value::String(file_str));
1553    out.insert(
1554        "match".to_string(),
1555        Value::String(
1556            fields
1557                .get("oldString")
1558                .and_then(Value::as_str)
1559                .unwrap_or("")
1560                .to_string(),
1561        ),
1562    );
1563    let replacement = fields
1564        .get("newString")
1565        .and_then(Value::as_str)
1566        .unwrap_or("");
1567    out.insert(
1568        "replacement".to_string(),
1569        Value::String(replacement.to_string()),
1570    );
1571    if let Some(v) = fields.get("replaceAll") {
1572        out.insert("replace_all".to_string(), v.clone());
1573    }
1574    if let Some(v) = fields.get("occurrence") {
1575        out.insert("occurrence".to_string(), v.clone());
1576    }
1577    insert_common_mutation_flags(&mut out, ctx);
1578    Ok(Translated {
1579        command: "edit_match".into(),
1580        args: out,
1581    })
1582}
1583
1584fn translate_apply_patch(args: Value) -> Result<Translated, TranslateError> {
1585    let map_in = agent_args_map(args);
1586    let patch_text = map_in
1587        .get("patchText")
1588        .and_then(Value::as_str)
1589        .filter(|s| !s.is_empty())
1590        .ok_or_else(|| invalid_request("apply_patch: missing required param 'patchText'"))?;
1591
1592    let mut out = Map::new();
1593    out.insert(
1594        "patch_text".to_string(),
1595        Value::String(patch_text.to_string()),
1596    );
1597    Ok(Translated {
1598        command: "apply_patch".into(),
1599        args: out,
1600    })
1601}
1602
1603fn translate_grep(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1604    let map_in = agent_args_map(args);
1605    let pattern = map_in
1606        .get("pattern")
1607        .and_then(Value::as_str)
1608        .filter(|s| !s.is_empty())
1609        .ok_or_else(|| invalid_request("grep: missing required param 'pattern'"))?;
1610
1611    let mut out = Map::new();
1612    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1613    out.insert("case_sensitive".to_string(), Value::Bool(true));
1614    if let Some(include) = map_in.get("include") {
1615        if !is_empty_param(include) {
1616            let include_arg = include.as_str().ok_or_else(|| {
1617                invalid_request("grep: 'include' must be a comma-separated string")
1618            })?;
1619            let includes = split_include_arg(include_arg)
1620                .into_iter()
1621                .map(|pattern| Value::String(normalize_glob(&pattern)))
1622                .collect::<Vec<_>>();
1623            if !includes.is_empty() {
1624                out.insert("include".to_string(), Value::Array(includes));
1625            }
1626        }
1627    }
1628    if let Some(path_val) = map_in.get("path") {
1629        if !is_empty_param(path_val) {
1630            if let Some(path_str) = path_val.as_str() {
1631                out.insert(
1632                    "path".to_string(),
1633                    Value::String(resolve_grep_path_arg(project_root, path_str)),
1634                );
1635            }
1636        }
1637    }
1638    out.insert("max_results".to_string(), Value::Number(100u64.into()));
1639
1640    Ok(Translated {
1641        command: "grep".into(),
1642        args: out,
1643    })
1644}
1645
1646fn translate_ast_search(args: Value) -> Result<Translated, TranslateError> {
1647    let map_in = agent_args_map(args);
1648    let pattern = map_in
1649        .get("pattern")
1650        .and_then(Value::as_str)
1651        .filter(|s| !s.is_empty())
1652        .ok_or_else(|| invalid_request("ast_search: missing required param 'pattern'"))?;
1653    let lang = map_in
1654        .get("lang")
1655        .and_then(Value::as_str)
1656        .filter(|s| !s.is_empty())
1657        .ok_or_else(|| invalid_request("ast_search: missing required param 'lang'"))?;
1658
1659    let mut out = Map::new();
1660    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1661    out.insert("lang".to_string(), Value::String(lang.to_string()));
1662    insert_non_empty_array(&mut out, &map_in, "paths");
1663    insert_non_empty_array(&mut out, &map_in, "globs");
1664    if let Some(context) = coerce_optional_int_result(
1665        map_in.get("contextLines"),
1666        "contextLines",
1667        1,
1668        9_007_199_254_740_991,
1669    )? {
1670        out.insert("context".to_string(), Value::Number(context.into()));
1671    }
1672
1673    Ok(Translated {
1674        command: "ast_search".into(),
1675        args: out,
1676    })
1677}
1678
1679fn translate_ast_replace(args: Value) -> Result<Translated, TranslateError> {
1680    let map_in = agent_args_map(args);
1681    let pattern = map_in
1682        .get("pattern")
1683        .and_then(Value::as_str)
1684        .filter(|s| !s.is_empty())
1685        .ok_or_else(|| invalid_request("ast_replace: missing required param 'pattern'"))?;
1686    let rewrite = map_in
1687        .get("rewrite")
1688        .and_then(Value::as_str)
1689        .ok_or_else(|| invalid_request("ast_replace: missing required param 'rewrite'"))?;
1690    let lang = map_in
1691        .get("lang")
1692        .and_then(Value::as_str)
1693        .filter(|s| !s.is_empty())
1694        .ok_or_else(|| invalid_request("ast_replace: missing required param 'lang'"))?;
1695
1696    let mut out = Map::new();
1697    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1698    out.insert("rewrite".to_string(), Value::String(rewrite.to_string()));
1699    out.insert("lang".to_string(), Value::String(lang.to_string()));
1700    insert_non_empty_array(&mut out, &map_in, "paths");
1701    insert_non_empty_array(&mut out, &map_in, "globs");
1702    let dry_run = map_in
1703        .get("dryRun")
1704        .or_else(|| map_in.get("dry_run"))
1705        .is_some_and(coerce_boolean);
1706    out.insert("dry_run".to_string(), Value::Bool(dry_run));
1707
1708    Ok(Translated {
1709        command: "ast_replace".into(),
1710        args: out,
1711    })
1712}
1713
1714fn insert_present_renamed(
1715    out: &mut Map<String, Value>,
1716    map_in: &Map<String, Value>,
1717    from: &str,
1718    to: &str,
1719) {
1720    if let Some(value) = map_in.get(from) {
1721        out.insert(to.to_string(), value.clone());
1722    }
1723}
1724
1725fn translate_delete(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1726    let map_in = agent_args_map(args);
1727    let files = map_in
1728        .get("files")
1729        .and_then(Value::as_array)
1730        .filter(|items| !items.is_empty())
1731        .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
1732
1733    let mut resolved_files = Vec::with_capacity(files.len());
1734    for file in files {
1735        let file = file
1736            .as_str()
1737            .filter(|path| !path.is_empty())
1738            .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
1739        let resolved = resolve_path_from_project_root(project_root, file);
1740        resolved_files.push(Value::String(resolved.to_string_lossy().into_owned()));
1741    }
1742
1743    let mut out = Map::new();
1744    out.insert("files".to_string(), Value::Array(resolved_files));
1745    out.insert(
1746        "recursive".to_string(),
1747        Value::Bool(map_in.get("recursive").is_some_and(coerce_boolean)),
1748    );
1749
1750    Ok(Translated {
1751        command: "delete_file".into(),
1752        args: out,
1753    })
1754}
1755
1756fn translate_move(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1757    let map_in = agent_args_map(args);
1758    let file_path = map_in
1759        .get("path")
1760        .and_then(Value::as_str)
1761        .filter(|s| !s.is_empty())
1762        .ok_or_else(|| invalid_request("aft_move: missing required param 'path'"))?;
1763    let destination = map_in
1764        .get("destination")
1765        .and_then(Value::as_str)
1766        .filter(|s| !s.is_empty())
1767        .ok_or_else(|| invalid_request("aft_move: missing required param 'destination'"))?;
1768
1769    let file_path = resolve_path_from_project_root(project_root, file_path);
1770    let destination = resolve_path_from_project_root(project_root, destination);
1771
1772    let mut out = Map::new();
1773    out.insert(
1774        "file".to_string(),
1775        Value::String(file_path.to_string_lossy().into_owned()),
1776    );
1777    out.insert(
1778        "destination".to_string(),
1779        Value::String(destination.to_string_lossy().into_owned()),
1780    );
1781
1782    Ok(Translated {
1783        command: "move_file".into(),
1784        args: out,
1785    })
1786}
1787
1788fn translate_import(args: Value) -> Result<Translated, TranslateError> {
1789    let map_in = agent_args_map(args);
1790    let op = map_in
1791        .get("op")
1792        .and_then(Value::as_str)
1793        .ok_or_else(|| invalid_request("aft_import: missing required param 'op'"))?;
1794    let command = match op {
1795        "add" => "add_import",
1796        "remove" => "remove_import",
1797        "organize" => "organize_imports",
1798        other => {
1799            return Err(invalid_request(format!(
1800                "aft_import: invalid op {other:?}; expected 'add', 'remove', or 'organize'"
1801            )));
1802        }
1803    };
1804
1805    let file_path = map_in
1806        .get("path")
1807        .and_then(Value::as_str)
1808        .filter(|s| !s.is_empty())
1809        .ok_or_else(|| invalid_request("aft_import: missing required param 'filePath'"))?;
1810
1811    if matches!(op, "add" | "remove") && map_in.get("module").map_or(true, is_empty_param) {
1812        return Err(invalid_request(format!(
1813            "'module' is required for '{op}' op"
1814        )));
1815    }
1816
1817    let mut out = Map::new();
1818    out.insert("file".to_string(), Value::String(file_path.to_string()));
1819    insert_present_renamed(&mut out, &map_in, "module", "module");
1820    insert_present_renamed(&mut out, &map_in, "names", "names");
1821    insert_present_renamed(&mut out, &map_in, "defaultImport", "default_import");
1822    insert_present_renamed(&mut out, &map_in, "namespace", "namespace");
1823    insert_present_renamed(&mut out, &map_in, "alias", "alias");
1824    insert_present_renamed(&mut out, &map_in, "modifiers", "modifiers");
1825    insert_present_renamed(&mut out, &map_in, "importKind", "import_kind");
1826    insert_present_renamed(&mut out, &map_in, "typeOnly", "type_only");
1827    insert_present_renamed(&mut out, &map_in, "removeName", "name");
1828    insert_present_renamed(&mut out, &map_in, "validate", "validate");
1829
1830    Ok(Translated {
1831        command: command.into(),
1832        args: out,
1833    })
1834}
1835
1836fn translate_refactor(args: Value) -> Result<Translated, TranslateError> {
1837    let map_in = agent_args_map(args);
1838    let op = map_in
1839        .get("op")
1840        .and_then(Value::as_str)
1841        .ok_or_else(|| invalid_request("aft_refactor: missing required param 'op'"))?;
1842    let command = match op {
1843        "move" => "move_symbol",
1844        "extract" => "extract_function",
1845        "inline" => "inline_symbol",
1846        other => {
1847            return Err(invalid_request(format!(
1848                "aft_refactor: invalid op {other:?}; expected 'move', 'extract', or 'inline'"
1849            )));
1850        }
1851    };
1852
1853    let file_path = map_in
1854        .get("path")
1855        .and_then(Value::as_str)
1856        .filter(|s| !s.is_empty())
1857        .ok_or_else(|| invalid_request("aft_refactor: missing required param 'filePath'"))?;
1858
1859    if matches!(op, "move" | "inline") && map_in.get("symbol").is_none_or(is_empty_param) {
1860        return Err(invalid_request(format!(
1861            "'symbol' is required for '{op}' op"
1862        )));
1863    }
1864    if op == "move" && map_in.get("destination").is_none_or(is_empty_param) {
1865        return Err(invalid_request("'destination' is required for 'move' op"));
1866    }
1867
1868    let mut out = Map::new();
1869    out.insert("file".to_string(), Value::String(file_path.to_string()));
1870
1871    match op {
1872        "move" => {
1873            insert_present_renamed(&mut out, &map_in, "symbol", "symbol");
1874            insert_present_renamed(&mut out, &map_in, "destination", "destination");
1875            insert_present_renamed(&mut out, &map_in, "scope", "scope");
1876        }
1877        "extract" => {
1878            if map_in.get("name").is_none_or(is_empty_param) {
1879                return Err(invalid_request("'name' is required for 'extract' op"));
1880            }
1881            let start_line = coerce_optional_int_result(
1882                map_in.get("startLine"),
1883                "startLine",
1884                1,
1885                MAX_SAFE_INTEGER,
1886            )?
1887            .ok_or_else(|| invalid_request("'startLine' is required for 'extract' op"))?;
1888            let end_line =
1889                coerce_optional_int_result(map_in.get("endLine"), "endLine", 1, MAX_SAFE_INTEGER)?
1890                    .ok_or_else(|| invalid_request("'endLine' is required for 'extract' op"))?;
1891
1892            insert_present_renamed(&mut out, &map_in, "name", "name");
1893            out.insert("start_line".to_string(), Value::Number(start_line.into()));
1894            out.insert("end_line".to_string(), Value::Number((end_line + 1).into()));
1895        }
1896        "inline" => {
1897            let call_site_line = coerce_optional_int_result(
1898                map_in.get("callSiteLine"),
1899                "callSiteLine",
1900                1,
1901                MAX_SAFE_INTEGER,
1902            )?
1903            .ok_or_else(|| invalid_request("'callSiteLine' is required for 'inline' op"))?;
1904
1905            insert_present_renamed(&mut out, &map_in, "symbol", "symbol");
1906            out.insert(
1907                "call_site_line".to_string(),
1908                Value::Number(call_site_line.into()),
1909            );
1910        }
1911        _ => unreachable!("validated refactor op"),
1912    }
1913
1914    insert_present_renamed(&mut out, &map_in, "lsp_hints", "lsp_hints");
1915
1916    Ok(Translated {
1917        command: command.into(),
1918        args: out,
1919    })
1920}
1921
1922fn translate_safety(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1923    let map_in = agent_args_map(args);
1924    let op = map_in
1925        .get("op")
1926        .and_then(Value::as_str)
1927        .ok_or_else(|| invalid_request("aft_safety: missing required param 'op'"))?;
1928    let command = match op {
1929        "undo" => "undo",
1930        "history" => "edit_history",
1931        "checkpoint" => "checkpoint",
1932        "restore" => "restore_checkpoint",
1933        "list" => "list_checkpoints",
1934        other => {
1935            return Err(invalid_request(format!(
1936                "aft_safety: invalid op {other:?}; expected 'undo', 'history', 'checkpoint', 'restore', or 'list'"
1937            )));
1938        }
1939    };
1940
1941    if op == "history" && map_in.get("path").and_then(Value::as_str).is_none() {
1942        return Err(invalid_request("'path' is required for 'history' op"));
1943    }
1944    if matches!(op, "checkpoint" | "restore")
1945        && map_in.get("name").and_then(Value::as_str).is_none()
1946    {
1947        return Err(invalid_request(format!("'name' is required for '{op}' op")));
1948    }
1949
1950    let resolve_path = |value: &Value| -> Result<Value, TranslateError> {
1951        let path = value
1952            .as_str()
1953            .filter(|path| !path.is_empty())
1954            .ok_or_else(|| invalid_request("aft_safety: paths must be non-empty strings"))?;
1955        Ok(Value::String(
1956            resolve_path_from_project_root(project_root, path)
1957                .to_string_lossy()
1958                .into_owned(),
1959        ))
1960    };
1961
1962    let mut out = Map::new();
1963    insert_present_renamed(&mut out, &map_in, "name", "name");
1964    let files = map_in
1965        .get("files")
1966        .and_then(Value::as_array)
1967        .filter(|items| !items.is_empty())
1968        .map(|items| {
1969            items
1970                .iter()
1971                .map(resolve_path)
1972                .collect::<Result<Vec<_>, _>>()
1973        })
1974        .transpose()?;
1975
1976    if op == "checkpoint" {
1977        if let Some(files) = files {
1978            out.insert("files".to_string(), Value::Array(files));
1979        } else if let Some(file_path) = map_in.get("path") {
1980            out.insert(
1981                "files".to_string(),
1982                Value::Array(vec![resolve_path(file_path)?]),
1983            );
1984        }
1985    } else {
1986        if let Some(file_path) = map_in.get("path") {
1987            out.insert("file".to_string(), resolve_path(file_path)?);
1988        }
1989        if let Some(files) = files {
1990            out.insert("files".to_string(), Value::Array(files));
1991        }
1992    }
1993
1994    Ok(Translated {
1995        command: command.into(),
1996        args: out,
1997    })
1998}
1999
2000fn insert_non_empty_array(out: &mut Map<String, Value>, map_in: &Map<String, Value>, key: &str) {
2001    if let Some(value) = map_in.get(key) {
2002        if let Some(items) = value.as_array() {
2003            if !items.is_empty() {
2004                out.insert(key.to_string(), Value::Array(items.clone()));
2005            }
2006        }
2007    }
2008}
2009
2010fn translate_glob(args: Value) -> Result<Translated, TranslateError> {
2011    let map_in = agent_args_map(args);
2012    let pattern = map_in
2013        .get("pattern")
2014        .and_then(Value::as_str)
2015        .filter(|s| !s.is_empty())
2016        .ok_or_else(|| invalid_request("glob: missing required param 'pattern'"))?;
2017
2018    let mut out = Map::new();
2019    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
2020    if let Some(path_val) = map_in.get("path") {
2021        if !is_empty_param(path_val) {
2022            if let Some(path_str) = path_val.as_str() {
2023                out.insert("path".to_string(), Value::String(path_str.to_string()));
2024            }
2025        }
2026    }
2027
2028    Ok(Translated {
2029        command: "glob".into(),
2030        args: out,
2031    })
2032}
2033
2034fn normalize_glob(pattern: &str) -> String {
2035    if !pattern.contains('/') && !pattern.starts_with("**/") {
2036        format!("**/{pattern}")
2037    } else {
2038        pattern.to_string()
2039    }
2040}
2041
2042fn split_include_arg(raw: &str) -> Vec<String> {
2043    let mut out = Vec::new();
2044    let mut depth = 0usize;
2045    let mut buf = String::new();
2046    for ch in raw.chars() {
2047        match ch {
2048            '{' => {
2049                depth += 1;
2050                buf.push(ch);
2051            }
2052            '}' => {
2053                depth = depth.saturating_sub(1);
2054                buf.push(ch);
2055            }
2056            ',' if depth == 0 => {
2057                let trimmed = buf.trim();
2058                if !trimmed.is_empty() {
2059                    out.push(trimmed.to_string());
2060                }
2061                buf.clear();
2062            }
2063            _ => buf.push(ch),
2064        }
2065    }
2066    let trimmed = buf.trim();
2067    if !trimmed.is_empty() {
2068        out.push(trimmed.to_string());
2069    }
2070    out
2071}
2072
2073fn search_path_exists(project_root: &Path, raw: &str) -> bool {
2074    resolve_path_from_project_root(project_root, raw).exists()
2075}
2076
2077fn split_search_path_arg(project_root: &Path, raw: &str) -> Vec<String> {
2078    if search_path_exists(project_root, raw) || !raw.chars().any(char::is_whitespace) {
2079        return vec![raw.to_string()];
2080    }
2081
2082    let fragments = raw
2083        .split_whitespace()
2084        .filter(|fragment| !fragment.is_empty())
2085        .collect::<Vec<_>>();
2086    if fragments.len() < 2 {
2087        return vec![raw.to_string()];
2088    }
2089
2090    let existing = fragments
2091        .iter()
2092        .filter(|fragment| search_path_exists(project_root, fragment))
2093        .map(|fragment| (*fragment).to_string())
2094        .collect::<Vec<_>>();
2095    if existing.is_empty() {
2096        vec![raw.to_string()]
2097    } else {
2098        existing
2099    }
2100}
2101
2102fn resolve_grep_path_arg(project_root: &Path, raw: &str) -> String {
2103    split_search_path_arg(project_root, raw)
2104        .iter()
2105        .map(|target| {
2106            resolve_path_from_project_root(project_root, target)
2107                .to_string_lossy()
2108                .into_owned()
2109        })
2110        .collect::<Vec<_>>()
2111        .join(" ")
2112}
2113
2114fn translate_search(args: Value) -> Result<Translated, TranslateError> {
2115    let map_in = agent_args_map(args);
2116    let query = map_in
2117        .get("query")
2118        .and_then(Value::as_str)
2119        .filter(|s| !s.trim().is_empty())
2120        .ok_or_else(|| {
2121            invalid_request("semantic_search: invalid params: `query` must be a non-empty string")
2122        })?;
2123
2124    let mut out = Map::new();
2125    out.insert("query".to_string(), Value::String(query.to_string()));
2126    let top_k = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)?.unwrap_or(10);
2127    out.insert("top_k".to_string(), Value::Number(top_k.into()));
2128    if let Some(include_tests) = map_in.get("includeTests").and_then(Value::as_bool) {
2129        out.insert("include_tests".to_string(), Value::Bool(include_tests));
2130    }
2131    if let Some(path) = map_in
2132        .get("path")
2133        .and_then(Value::as_str)
2134        .map(str::trim)
2135        .filter(|path| !path.is_empty())
2136    {
2137        out.insert("path".to_string(), Value::String(path.to_string()));
2138    }
2139
2140    Ok(Translated {
2141        command: "semantic_search".into(),
2142        args: out,
2143    })
2144}
2145
2146fn translate_outline(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2147    let map_in = agent_args_map(args);
2148    let files_flag = map_in
2149        .get("files")
2150        .and_then(Value::as_bool)
2151        .unwrap_or(false);
2152
2153    let target = map_in
2154        .get("target")
2155        .ok_or_else(|| invalid_request("outline: missing required param 'target'"))?;
2156
2157    if is_empty_param(target) {
2158        return Err(invalid_request(
2159            "'target' must be a non-empty string or array of strings",
2160        ));
2161    }
2162
2163    let mut out = Map::new();
2164    if let Some(include_tests) = map_in
2165        .get("includeTests")
2166        .or_else(|| map_in.get("include_tests"))
2167        .and_then(Value::as_bool)
2168    {
2169        out.insert("includeTests".to_string(), Value::Bool(include_tests));
2170    }
2171
2172    if let Some(arr) = target.as_array() {
2173        if arr.is_empty() {
2174            return Err(invalid_request(
2175                "'target' must be a non-empty string or array of strings",
2176            ));
2177        }
2178        if files_flag {
2179            let resolved: Vec<Value> = arr
2180                .iter()
2181                .filter_map(|v| v.as_str())
2182                .map(|entry| {
2183                    let p = resolve_path_from_project_root(project_root, entry);
2184                    Value::String(p.to_string_lossy().into_owned())
2185                })
2186                .collect();
2187            out.insert("target".to_string(), Value::Array(resolved));
2188            out.insert("files".to_string(), Value::Bool(true));
2189            return Ok(Translated {
2190                command: "outline".into(),
2191                args: out,
2192            });
2193        }
2194        let resolved: Vec<Value> = arr
2195            .iter()
2196            .filter_map(|v| v.as_str())
2197            .map(|entry| {
2198                let p = resolve_path_from_project_root(project_root, entry);
2199                Value::String(p.to_string_lossy().into_owned())
2200            })
2201            .collect();
2202        out.insert("files".to_string(), Value::Array(resolved));
2203        return Ok(Translated {
2204            command: "outline".into(),
2205            args: out,
2206        });
2207    }
2208
2209    if let Some(url) = target.as_str() {
2210        if !files_flag && (url.starts_with("http://") || url.starts_with("https://")) {
2211            out.insert("file".to_string(), Value::String(url.to_string()));
2212            return Ok(Translated {
2213                command: "outline".into(),
2214                args: out,
2215            });
2216        }
2217    }
2218
2219    let target_str = target.as_str().ok_or_else(|| {
2220        invalid_request("'target' must be a non-empty string or array of strings")
2221    })?;
2222
2223    let resolved = resolve_path_from_project_root(project_root, target_str);
2224    let is_dir = std::fs::metadata(&resolved)
2225        .map(|m| m.is_dir())
2226        .unwrap_or(false);
2227
2228    if files_flag {
2229        if is_dir {
2230            out.insert(
2231                "directory".to_string(),
2232                Value::String(resolved.to_string_lossy().into_owned()),
2233            );
2234        } else {
2235            out.insert(
2236                "file".to_string(),
2237                Value::String(resolved.to_string_lossy().into_owned()),
2238            );
2239        }
2240        out.insert("files".to_string(), Value::Bool(true));
2241    } else if is_dir {
2242        out.insert(
2243            "directory".to_string(),
2244            Value::String(resolved.to_string_lossy().into_owned()),
2245        );
2246    } else {
2247        out.insert(
2248            "file".to_string(),
2249            Value::String(resolved.to_string_lossy().into_owned()),
2250        );
2251    }
2252
2253    Ok(Translated {
2254        command: "outline".into(),
2255        args: out,
2256    })
2257}
2258
2259fn zoom_target_entry_is_empty(entry: &Value) -> bool {
2260    let Some(obj) = entry.as_object() else {
2261        return true;
2262    };
2263    let file_path_empty = obj
2264        .get("path")
2265        .and_then(Value::as_str)
2266        .is_none_or(str::is_empty);
2267    let symbol_empty = obj
2268        .get("symbol")
2269        .and_then(Value::as_str)
2270        .is_none_or(str::is_empty);
2271    file_path_empty && symbol_empty
2272}
2273
2274fn zoom_targets_provided(value: Option<&Value>) -> bool {
2275    let Some(value) = value else {
2276        return false;
2277    };
2278    if is_empty_param(value) {
2279        return false;
2280    }
2281    match value {
2282        Value::Array(items) => !items.iter().all(zoom_target_entry_is_empty),
2283        Value::Object(_) => !zoom_target_entry_is_empty(value),
2284        _ => false,
2285    }
2286}
2287
2288fn translate_zoom_targets(
2289    targets_value: &Value,
2290    project_root: &Path,
2291) -> Result<Vec<Value>, TranslateError> {
2292    let target_values: Vec<&Value> = match targets_value {
2293        Value::Array(items) => items.iter().collect(),
2294        Value::Object(_) => vec![targets_value],
2295        _ => {
2296            return Err(invalid_request(
2297                "'targets' must be a non-empty object or array",
2298            ))
2299        }
2300    };
2301
2302    if target_values.is_empty() {
2303        return Err(invalid_request(
2304            "'targets' must be a non-empty object or array",
2305        ));
2306    }
2307
2308    let mut out = Vec::with_capacity(target_values.len());
2309    for (index, target) in target_values.into_iter().enumerate() {
2310        let obj = target.as_object();
2311        let file_path = obj
2312            .and_then(|obj| obj.get("path"))
2313            .and_then(Value::as_str)
2314            .filter(|file_path| !file_path.is_empty())
2315            .ok_or_else(|| {
2316                invalid_request(format!(
2317                    "targets[{index}].filePath must be a non-empty string"
2318                ))
2319            })?;
2320        let symbol = obj
2321            .and_then(|obj| obj.get("symbol"))
2322            .and_then(Value::as_str)
2323            .filter(|symbol| !symbol.is_empty())
2324            .ok_or_else(|| {
2325                invalid_request(format!(
2326                    "targets[{index}].symbol must be a non-empty string"
2327                ))
2328            })?;
2329        let resolved = resolve_path_from_project_root(project_root, file_path);
2330        let mut target_out = Map::new();
2331        target_out.insert(
2332            "file".to_string(),
2333            Value::String(resolved.to_string_lossy().into_owned()),
2334        );
2335        target_out.insert("symbol".to_string(), Value::String(symbol.to_string()));
2336        target_out.insert(
2337            "target_label".to_string(),
2338            Value::String(file_path.to_string()),
2339        );
2340        out.push(Value::Object(target_out));
2341    }
2342    Ok(out)
2343}
2344
2345fn translate_zoom(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2346    let map_in = agent_args_map(args);
2347
2348    let has_targets = zoom_targets_provided(map_in.get("targets"));
2349    let has_file_path = map_in
2350        .get("path")
2351        .is_some_and(|value| !is_empty_param(value));
2352    let has_url = map_in
2353        .get("url")
2354        .is_some_and(|value| !is_empty_param(value));
2355    let has_symbols = map_in
2356        .get("symbols")
2357        .is_some_and(|value| !is_empty_param(value));
2358
2359    let mut out = Map::new();
2360
2361    if has_targets {
2362        if has_file_path || has_url || has_symbols {
2363            return Err(invalid_request(
2364                "'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'",
2365            ));
2366        }
2367        let targets_value = map_in
2368            .get("targets")
2369            .expect("has_targets implies a targets value exists");
2370        out.insert(
2371            "targets".to_string(),
2372            Value::Array(translate_zoom_targets(targets_value, project_root)?),
2373        );
2374
2375        if let Some(context_lines) = coerce_optional_int_result(
2376            map_in.get("contextLines"),
2377            "contextLines",
2378            1,
2379            9_007_199_254_740_991,
2380        )? {
2381            out.insert(
2382                "context_lines".to_string(),
2383                Value::Number(context_lines.into()),
2384            );
2385        }
2386
2387        if map_in.get("callgraph").is_some_and(coerce_boolean) {
2388            out.insert("callgraph".to_string(), Value::Bool(true));
2389        }
2390
2391        return Ok(Translated {
2392            command: "zoom".into(),
2393            args: out,
2394        });
2395    }
2396
2397    let file_path = map_in
2398        .get("path")
2399        .and_then(Value::as_str)
2400        .filter(|s| !s.is_empty());
2401    let url = map_in
2402        .get("url")
2403        .and_then(Value::as_str)
2404        .filter(|s| !s.is_empty());
2405
2406    match (file_path, url) {
2407        (None, None) => {
2408            return Err(invalid_request(
2409                "Provide exactly one of 'filePath', 'url', or 'targets'",
2410            ));
2411        }
2412        (Some(_), Some(_)) => {
2413            return Err(invalid_request(
2414                "Provide exactly ONE of 'filePath' or 'url' — not both",
2415            ));
2416        }
2417        _ => {}
2418    }
2419
2420    if let Some(url) = url {
2421        out.insert("file".to_string(), Value::String(url.to_string()));
2422    } else if let Some(file_path) = file_path {
2423        insert_resolved_file(&mut out, project_root, file_path);
2424    }
2425
2426    if let Some(symbols) = map_in.get("symbols") {
2427        if !is_empty_param(symbols) {
2428            match symbols {
2429                Value::String(symbol) => {
2430                    out.insert("symbol".to_string(), Value::String(symbol.to_string()));
2431                }
2432                Value::Array(items) => {
2433                    // Pass the array THROUGH to the leaf (handle_zoom's
2434                    // parse_zoom_symbol_names handles a `symbols` array natively,
2435                    // one lookup per element). Joining into one space-separated
2436                    // string would break multi-heading markdown/HTML zoom, whose
2437                    // heading names legitimately contain spaces.
2438                    let names: Vec<Value> = items
2439                        .iter()
2440                        .filter_map(Value::as_str)
2441                        .filter(|name| !name.is_empty())
2442                        .map(|name| Value::String(name.to_string()))
2443                        .collect();
2444                    if !names.is_empty() {
2445                        out.insert("symbols".to_string(), Value::Array(names));
2446                    }
2447                }
2448                _ => {
2449                    return Err(invalid_request(
2450                        "'symbols' must be a string or array of strings",
2451                    ))
2452                }
2453            }
2454        }
2455    }
2456
2457    if let Some(context_lines) = coerce_optional_int_result(
2458        map_in.get("contextLines"),
2459        "contextLines",
2460        1,
2461        9_007_199_254_740_991,
2462    )? {
2463        out.insert(
2464            "context_lines".to_string(),
2465            Value::Number(context_lines.into()),
2466        );
2467    }
2468
2469    if map_in.get("callgraph").is_some_and(coerce_boolean) {
2470        out.insert("callgraph".to_string(), Value::Bool(true));
2471    }
2472
2473    Ok(Translated {
2474        command: "zoom".into(),
2475        args: out,
2476    })
2477}
2478
2479fn translate_conflicts(args: Value) -> Result<Translated, TranslateError> {
2480    let map_in = agent_args_map(args);
2481    let mut out = Map::new();
2482    if let Some(path_val) = map_in.get("path") {
2483        if !is_empty_param(path_val) {
2484            if let Some(path_str) = path_val.as_str() {
2485                out.insert("path".to_string(), Value::String(path_str.to_string()));
2486            }
2487        }
2488    }
2489
2490    Ok(Translated {
2491        command: "git_conflicts".into(),
2492        args: out,
2493    })
2494}
2495
2496fn translate_inspect(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2497    let map_in = agent_args_map(args);
2498    let mut out = Map::new();
2499
2500    if let Some(sections) = map_in.get("sections") {
2501        if !is_empty_param(sections) {
2502            out.insert("sections".to_string(), sections.clone());
2503        }
2504    }
2505
2506    if let Some(scope) = map_in.get("scope") {
2507        if !is_empty_param(scope) {
2508            match scope {
2509                Value::String(s) if !s.is_empty() => {
2510                    let resolved = resolve_path_from_project_root(project_root, s);
2511                    out.insert(
2512                        "scope".to_string(),
2513                        Value::String(resolved.to_string_lossy().into_owned()),
2514                    );
2515                }
2516                Value::Array(arr) => {
2517                    let resolved: Vec<Value> = arr
2518                        .iter()
2519                        .filter_map(|v| v.as_str())
2520                        .map(|entry| {
2521                            let p = resolve_path_from_project_root(project_root, entry);
2522                            Value::String(p.to_string_lossy().into_owned())
2523                        })
2524                        .collect();
2525                    out.insert("scope".to_string(), Value::Array(resolved));
2526                }
2527                other => {
2528                    out.insert("scope".to_string(), other.clone());
2529                }
2530            }
2531        }
2532    }
2533
2534    if let Some(top_k) = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)? {
2535        out.insert("topK".to_string(), Value::Number(top_k.into()));
2536    }
2537
2538    Ok(Translated {
2539        command: "inspect".into(),
2540        args: out,
2541    })
2542}
2543
2544#[cfg(test)]
2545mod tests {
2546    use super::*;
2547
2548    #[test]
2549    fn path_aliases_normalize_equal_and_reject_conflicts() {
2550        let project = Path::new("/project");
2551        let legacy = serde_json::json!({"filePath": "src/main.ts", "content": "x"});
2552        let canonical = serde_json::json!({"path": "src/main.ts", "content": "x"});
2553        assert_eq!(
2554            subc_translate_owned("write", legacy, project).expect("legacy path"),
2555            subc_translate_owned("write", canonical, project).expect("canonical path")
2556        );
2557
2558        let conflict = serde_json::json!({"path": "src/a.ts", "filePath": "src/b.ts"});
2559        let error = subc_translate_owned("read", conflict, project).expect_err("conflict");
2560        assert_eq!(error.code, "invalid_request");
2561        assert!(error.message.contains("path"));
2562        assert!(error.message.contains("filePath"));
2563    }
2564
2565    #[test]
2566    fn path_aliases_keep_unicode_scalar_equality_strict() {
2567        let project = Path::new("/project");
2568        let equal = serde_json::json!({"path": "src/😀.ts", "filePath": "src/😀.ts"});
2569        assert!(subc_translate_owned("read", equal, project).is_ok());
2570
2571        let canonically_different = serde_json::json!({
2572            "path": "src/é.ts",
2573            "filePath": "src/e\u{301}.ts"
2574        });
2575        let error = subc_translate_owned("read", canonically_different, project)
2576            .expect_err("different Unicode normalization");
2577        assert_eq!(error.code, "invalid_request");
2578    }
2579
2580    #[test]
2581    fn empty_or_null_optional_path_is_stripped_as_absent() {
2582        let project = Path::new("/project");
2583        // Optional-path tools: an empty-string or null `path` is treated as
2584        // not supplied, so the translated args carry no `path` key at all.
2585        // Each tool still needs its own required fields, so supply them and
2586        // assert the empty path sentinel is gone from the output.
2587        let conflicts =
2588            subc_translate_owned("conflicts", serde_json::json!({ "path": "" }), project)
2589                .expect("conflicts with empty path");
2590        assert!(!conflicts.args.contains_key("path"));
2591
2592        let grep = subc_translate_owned(
2593            "grep",
2594            serde_json::json!({ "pattern": "x", "path": "" }),
2595            project,
2596        )
2597        .expect("grep with empty path");
2598        assert!(!grep.args.contains_key("path"));
2599
2600        let search = subc_translate_owned(
2601            "search",
2602            serde_json::json!({ "query": "x", "path": null }),
2603            project,
2604        )
2605        .expect("search with null path");
2606        assert!(!search.args.contains_key("path"));
2607
2608        let safety = subc_translate_owned(
2609            "safety",
2610            serde_json::json!({ "op": "list", "path": "" }),
2611            project,
2612        )
2613        .expect("safety with empty path");
2614        assert!(!safety.args.contains_key("path"));
2615
2616        // zoom with only empty path sentinels has no target at all, which is
2617        // the "absent" outcome: it reports the missing-target error rather than
2618        // the well-formed-Unicode one.
2619        let zoom = subc_translate_owned(
2620            "zoom",
2621            serde_json::json!({ "path": "", "filePath": "" }),
2622            project,
2623        )
2624        .expect_err("zoom with only empty path sentinels");
2625        assert!(zoom.message.contains("Provide exactly one"));
2626
2627        // callgraph's optional `toPath` alias is stripped the same way.
2628        let callgraph = subc_translate_owned(
2629            "callgraph",
2630            serde_json::json!({
2631                "path": "src/main.ts",
2632                "toPath": "",
2633                "op": "callers",
2634                "symbol": "main"
2635            }),
2636            project,
2637        )
2638        .expect("callgraph with empty toPath");
2639        assert!(!callgraph.args.contains_key("to_path"));
2640    }
2641
2642    #[test]
2643    fn required_tools_report_empty_path_as_missing_not_malformed() {
2644        let project = Path::new("/project");
2645        // `edit` is excluded: its full translation runs the edit contract
2646        // (mode selection) before path validation, so an empty path reports
2647        // the no-mode error, matching the TypeScript prepareCanonicalEditArguments.
2648        for tool in ["read", "write", "move", "import", "refactor"] {
2649            let error = subc_translate_owned(tool, serde_json::json!({ "path": "" }), project)
2650                .expect_err("empty required path");
2651            assert_eq!(error.code, "invalid_request");
2652            assert!(
2653                error.message.contains("'path' is required")
2654                    || error.message.contains("missing required param"),
2655                "{tool} empty path message: {}",
2656                error.message
2657            );
2658            assert!(
2659                !error.message.contains("well-formed Unicode"),
2660                "{tool} must not report the well-formed error for an empty path"
2661            );
2662        }
2663        // edit with only an empty path sentinel reports the no-mode error, not
2664        // the well-formed-Unicode one.
2665        let edit = subc_translate_owned("edit", serde_json::json!({ "path": "" }), project)
2666            .expect_err("edit empty path");
2667        assert!(edit.message.contains("exactly one of"));
2668        assert!(!edit.message.contains("well-formed Unicode"));
2669    }
2670
2671    #[test]
2672    fn empty_optional_path_sentinel_does_not_mask_a_non_string_error() {
2673        let project = Path::new("/project");
2674        // The strip is bounded to empty/null, not "anything falsy": a
2675        // non-string value on an optional field still throws the well-formed
2676        // error. (Rust strings cannot hold a lone UTF-16 surrogate, so a number
2677        // is the closest non-string control.)
2678        for tool in ["grep", "search", "conflicts", "zoom", "safety"] {
2679            let error = subc_translate_owned(tool, serde_json::json!({ "path": 42 }), project)
2680                .expect_err("non-string optional path");
2681            assert_eq!(error.code, "invalid_request");
2682            assert!(
2683                error.message.contains("well-formed Unicode"),
2684                "{tool} non-string path message: {}",
2685                error.message
2686            );
2687        }
2688    }
2689
2690    #[test]
2691    fn owned_write_translation_moves_content_buffer() {
2692        let content = "x".repeat(256 * 1024);
2693        let content_ptr = content.as_ptr();
2694        let content_len = content.len();
2695        let mut arguments = Map::new();
2696        arguments.insert(
2697            "filePath".to_string(),
2698            Value::String("src/generated.ts".to_string()),
2699        );
2700        arguments.insert("content".to_string(), Value::String(content));
2701
2702        let translated =
2703            subc_translate_owned("write", Value::Object(arguments), Path::new("/project"))
2704                .expect("write translation succeeds");
2705        let translated_content = translated
2706            .args
2707            .get("content")
2708            .and_then(Value::as_str)
2709            .expect("translated write keeps content");
2710
2711        assert_eq!(translated_content.len(), content_len);
2712        assert_eq!(translated_content.as_ptr(), content_ptr);
2713    }
2714
2715    #[test]
2716    fn edit_normalization_orders_contract_checks_before_path_resolution() {
2717        let project = Path::new("/project");
2718        let conflict = subc_translate_owned(
2719            "edit",
2720            serde_json::json!({
2721                "path": "src/main.ts",
2722                "appendContent": "x",
2723                "edits": "not-json"
2724            }),
2725            project,
2726        )
2727        .expect_err("mode conflict");
2728        assert_eq!(conflict.code, "invalid_request");
2729        assert!(conflict.message.contains("conflicting modes"));
2730
2731        let line_error = subc_translate_owned(
2732            "edit",
2733            serde_json::json!({ "path": 42, "startLine": 1 }),
2734            project,
2735        )
2736        .expect_err("top-level line range");
2737        assert!(line_error.message.contains("startLine"));
2738
2739        let no_mode = subc_translate_owned("edit", serde_json::json!({ "path": "x" }), project)
2740            .expect_err("missing mode");
2741        assert!(no_mode.message.contains("exactly one of"));
2742
2743        let retired_fields = subc_translate_owned(
2744            "edit",
2745            serde_json::json!({ "mode": "write", "file": "src/main.ts" }),
2746            project,
2747        )
2748        .expect_err("retired fields are ordinary unknown keys outside OpenCode aft_edit");
2749        assert_eq!(
2750            retired_fields.message,
2751            "Unrecognized keys: \"file\", \"mode\""
2752        );
2753    }
2754
2755    #[test]
2756    fn edit_normalization_uses_meaningful_mode_presence() {
2757        let project = Path::new("/project");
2758        let cases = [
2759            (
2760                "edits ignores empty mode sentinels",
2761                serde_json::json!({
2762                    "filePath": "src/example.ts",
2763                    "edits": [{ "oldString": "old", "newString": "new" }],
2764                    "appendContent": "",
2765                    "symbol": "",
2766                    "content": "",
2767                }),
2768                Some("batch"),
2769                None,
2770            ),
2771            (
2772                "append ignores empty edits",
2773                serde_json::json!({
2774                    "filePath": "src/example.ts",
2775                    "appendContent": "append",
2776                    "edits": [],
2777                }),
2778                Some("edit_match"),
2779                None,
2780            ),
2781            (
2782                "symbol deletion keeps empty content",
2783                serde_json::json!({
2784                    "filePath": "src/example.ts",
2785                    "symbol": "target",
2786                    "content": "",
2787                }),
2788                Some("edit_symbol"),
2789                None,
2790            ),
2791            (
2792                "content without a symbol is rejected",
2793                serde_json::json!({
2794                    "filePath": "src/example.ts",
2795                    "symbol": "",
2796                    "content": "replacement",
2797                }),
2798                None,
2799                Some("requires a non-empty string 'symbol'"),
2800            ),
2801            (
2802                "two real modes conflict",
2803                serde_json::json!({
2804                    "filePath": "src/example.ts",
2805                    "appendContent": "append",
2806                    "edits": [{ "oldString": "old", "newString": "new" }],
2807                }),
2808                None,
2809                Some("conflicting modes"),
2810            ),
2811            (
2812                "all empty fields have no mode",
2813                serde_json::json!({
2814                    "filePath": "src/example.ts",
2815                    "appendContent": "",
2816                    "edits": [],
2817                    "symbol": "",
2818                    "content": "",
2819                    "oldString": "",
2820                    "newString": "",
2821                    "replaceAll": null,
2822                    "occurrence": null,
2823                }),
2824                None,
2825                Some("exactly one of"),
2826            ),
2827        ];
2828
2829        for (label, arguments, command, expected_error) in cases {
2830            match (command, expected_error) {
2831                (Some(command), None) => {
2832                    let translated = subc_translate_owned("edit", arguments, project)
2833                        .unwrap_or_else(|error| panic!("{label}: {}", error.message));
2834                    assert_eq!(translated.command, command, "{label}");
2835                    match label {
2836                        "edits ignores empty mode sentinels" => {
2837                            assert_eq!(
2838                                translated.args["edits"][0]["match"],
2839                                Value::String("old".to_string())
2840                            );
2841                            assert_eq!(
2842                                translated.args["edits"][0]["replacement"],
2843                                Value::String("new".to_string())
2844                            );
2845                        }
2846                        "append ignores empty edits" => {
2847                            assert_eq!(
2848                                translated.args["append_content"],
2849                                Value::String("append".to_string())
2850                            );
2851                        }
2852                        "symbol deletion keeps empty content" => {
2853                            assert_eq!(translated.args["content"], Value::String(String::new()));
2854                        }
2855                        _ => unreachable!("unexpected successful edit mode case"),
2856                    }
2857                }
2858                (None, Some(expected_error)) => {
2859                    let translation_error = subc_translate_owned("edit", arguments, project)
2860                        .expect_err("meaningful mode case must fail");
2861                    assert!(
2862                        translation_error.message.contains(expected_error),
2863                        "{label}: {}",
2864                        translation_error.message
2865                    );
2866                }
2867                _ => unreachable!("case must expect exactly one outcome"),
2868            }
2869        }
2870    }
2871
2872    #[test]
2873    fn edit_normalization_accepts_aliases_and_rejects_ambiguous_scalars() {
2874        let project = Path::new("/project");
2875        let stringified = subc_translate_owned(
2876            "edit",
2877            serde_json::json!({
2878                "path": "src/main.ts",
2879                "edits": "[{\"oldString\":\"before\",\"newString\":\"after\"}]"
2880            }),
2881            project,
2882        )
2883        .expect("stringified non-empty edits array");
2884        assert_eq!(stringified.command, "batch");
2885
2886        let normalized = subc_translate_owned(
2887            "edit",
2888            serde_json::json!({
2889                "filePath": "src/main.ts",
2890                "edits": [{ "oldText": "before", "newText": "after", "occurrence": " +01 " }]
2891            }),
2892            project,
2893        )
2894        .expect("compatibility aliases");
2895        let item = normalized
2896            .args
2897            .get("edits")
2898            .and_then(Value::as_array)
2899            .and_then(|items| items.first())
2900            .expect("translated edit item");
2901        assert_eq!(item.get("match").and_then(Value::as_str), Some("before"));
2902        assert_eq!(item.get("occurrence").and_then(Value::as_u64), Some(1));
2903
2904        for value in ["0", "00", "+0", "1.0", "1e0", "0x1", "-1"] {
2905            let error = subc_translate_owned(
2906                "edit",
2907                serde_json::json!({
2908                    "path": "src/main.ts",
2909                    "edits": [{ "oldString": "before", "occurrence": value }]
2910                }),
2911                project,
2912            )
2913            .expect_err("invalid occurrence spelling");
2914            assert!(error.message.contains("occurrence"));
2915        }
2916    }
2917
2918    #[test]
2919    fn edit_strips_all_empty_sentinel_edit_items() {
2920        let project = Path::new("/project");
2921
2922        // The exact GPT 5.6 Terra report shape: every optional field carries a
2923        // type-default sentinel and the real payload lives in appendContent.
2924        // The all-empty edits array must not claim the edits mode.
2925        let report = subc_translate_owned(
2926            "edit",
2927            serde_json::json!({
2928                "path": "src/main.ts",
2929                "symbol": "",
2930                "content": "",
2931                "appendContent": "CONTENT IT APPENDS",
2932                "edits": [
2933                    { "oldString": "", "newString": "", "replaceAll": false,
2934                      "occurrence": 1, "startLine": 1, "endLine": 1, "content": "" }
2935                ]
2936            }),
2937            project,
2938        )
2939        .expect("all-empty sentinel edits must not claim the edits mode");
2940        assert_eq!(report.command, "edit_match");
2941        assert_eq!(
2942            report.args.get("append_content").and_then(Value::as_str),
2943            Some("CONTENT IT APPENDS")
2944        );
2945
2946        // A sentinel item alongside one real match item: the real item
2947        // survives and the batch path is taken.
2948        let mixed = subc_translate_owned(
2949            "edit",
2950            serde_json::json!({
2951                "path": "src/main.ts",
2952                "edits": [
2953                    { "oldString": "", "newString": "", "replaceAll": false,
2954                      "occurrence": 1, "startLine": 1, "endLine": 1, "content": "" },
2955                    { "oldString": "old", "newString": "new" }
2956                ]
2957            }),
2958            project,
2959        )
2960        .expect("real item must survive sentinel stripping");
2961        assert_eq!(mixed.command, "batch");
2962        let items = mixed.args.get("edits").and_then(Value::as_array).unwrap();
2963        assert_eq!(items.len(), 1, "only the real item survives");
2964        assert_eq!(items[0].get("match").and_then(Value::as_str), Some("old"));
2965        assert_eq!(
2966            items[0].get("replacement").and_then(Value::as_str),
2967            Some("new")
2968        );
2969
2970        // A pure line-range delete item has no oldString key, so it is never a
2971        // sentinel even with empty content (deleting lines is real intent).
2972        let line_delete = subc_translate_owned(
2973            "edit",
2974            serde_json::json!({
2975                "path": "src/main.ts",
2976                "edits": [{ "startLine": 1, "endLine": 1, "content": "" }]
2977            }),
2978            project,
2979        )
2980        .expect("pure line-range delete must stay an edits claim");
2981        assert_eq!(line_delete.command, "batch");
2982
2983        // {oldString:"", newString:"x"} is NOT a sentinel: it is kept as an
2984        // edits claim so the batch parser reports its specific empty-match
2985        // error instead of us silently discarding a broken but intentional
2986        // edit. Translation succeeds (the batch command is produced); the
2987        // empty-match error surfaces at the batch leaf handler.
2988        let empty_match = subc_translate_owned(
2989            "edit",
2990            serde_json::json!({
2991                "path": "src/main.ts",
2992                "edits": [{ "oldString": "", "newString": "x" }]
2993            }),
2994            project,
2995        )
2996        .expect("empty oldString must stay an edits claim");
2997        assert_eq!(empty_match.command, "batch");
2998        let kept = empty_match
2999            .args
3000            .get("edits")
3001            .and_then(Value::as_array)
3002            .unwrap();
3003        assert_eq!(kept.len(), 1, "the empty-match item must be kept");
3004        assert_eq!(kept[0].get("match").and_then(Value::as_str), Some(""));
3005
3006        // Stringified kitchen-sink edits + appendContent: appendContent wins.
3007        let stringified = subc_translate_owned(
3008            "edit",
3009            serde_json::json!({
3010                "path": "src/main.ts",
3011                "appendContent": "APPEND",
3012                "edits": "[{\"oldString\":\"\",\"newString\":\"\",\"replaceAll\":false,\"occurrence\":1,\"startLine\":1,\"endLine\":1,\"content\":\"\"}]"
3013            }),
3014            project,
3015        )
3016        .expect("stringified all-empty sentinel edits must not claim edits mode");
3017        assert_eq!(stringified.command, "edit_match");
3018        assert_eq!(
3019            stringified
3020                .args
3021                .get("append_content")
3022                .and_then(Value::as_str),
3023            Some("APPEND")
3024        );
3025    }
3026
3027    #[test]
3028    fn meaningful_find_payload_predicate_rejects_empty_match_mutation_control() {
3029        let meaningful = serde_json::json!({ "oldString": "before" });
3030        let empty = serde_json::json!({ "oldString": "" });
3031        let absent = serde_json::json!({});
3032
3033        assert!(has_meaningful_find_payload(
3034            meaningful.as_object().expect("meaningful item object")
3035        ));
3036        assert!(!has_meaningful_find_payload(
3037            empty.as_object().expect("empty item object")
3038        ));
3039        assert!(!has_meaningful_find_payload(
3040            absent.as_object().expect("absent item object")
3041        ));
3042    }
3043
3044    #[test]
3045    fn edit_normalization_applies_symmetric_item_sentinel_precedence() {
3046        let project = Path::new("/project");
3047
3048        // A meaningful match wins when range fields are blank serializer
3049        // defaults, including all three range-shaped fields.
3050        for item in [
3051            serde_json::json!({
3052                "oldString": "before", "newString": "after",
3053                "startLine": 1, "endLine": 1, "content": "",
3054            }),
3055            serde_json::json!({
3056                "oldString": "before", "newString": "after",
3057                "startLine": 1, "endLine": 1,
3058            }),
3059        ] {
3060            let translated = subc_translate_owned(
3061                "edit",
3062                serde_json::json!({ "path": "src/example.ts", "edits": [item] }),
3063                project,
3064            )
3065            .expect("empty or absent range payload must yield to find/replace");
3066            assert_eq!(
3067                translated.args["edits"],
3068                serde_json::json!([{ "match": "before", "replacement": "after" }]),
3069            );
3070        }
3071
3072        // A range delete with no find/replace fields is real intent, not a
3073        // serializer sentinel.
3074        let line_delete = subc_translate_owned(
3075            "edit",
3076            serde_json::json!({
3077                "path": "src/example.ts",
3078                "edits": [{ "startLine": 1, "endLine": 1, "content": "" }],
3079            }),
3080            project,
3081        )
3082        .expect("bare line-range delete must remain a range edit");
3083        assert_eq!(
3084            line_delete.args["edits"],
3085            serde_json::json!([{ "line_start": 1, "line_end": 1, "content": "" }]),
3086        );
3087
3088        // An all-default object carries no edit payload, so it is removed
3089        // before validation and leaves no selected edit mode.
3090        let all_sentinels = subc_translate_owned(
3091            "edit",
3092            serde_json::json!({
3093                "path": "src/example.ts",
3094                "edits": [{
3095                    "oldString": "", "newString": "", "replaceAll": false,
3096                    "occurrence": 1, "startLine": 1, "endLine": 1, "content": "",
3097                }],
3098            }),
3099            project,
3100        )
3101        .expect_err("all-default item must be dropped");
3102        assert!(all_sentinels.message.contains("exactly one of"));
3103
3104        let issue_payload = serde_json::json!({
3105            "path": "src/example.ts",
3106            "edits": [{
3107                "content": "const value = new;",
3108                "startLine": 14,
3109                "endLine": 14,
3110                "oldString": "",
3111                "newString": "",
3112                "replaceAll": false,
3113                "occurrence": 1,
3114            }],
3115        });
3116        let translated = subc_translate_owned("edit", issue_payload, project)
3117            .expect("line-range sentinels must not select find/replace mode");
3118        assert_eq!(translated.command, "batch");
3119        assert_eq!(
3120            translated.args["edits"],
3121            serde_json::json!([{
3122                "content": "const value = new;",
3123                "line_start": 14,
3124                "line_end": 14,
3125            }]),
3126        );
3127
3128        for arguments in [
3129            serde_json::json!({
3130                "path": "src/example.ts",
3131                "edits": [{
3132                    "content": "replacement", "startLine": 14, "endLine": 14,
3133                    "oldString": "meaningful", "newString": "",
3134                }],
3135            }),
3136            serde_json::json!({
3137                "path": "src/example.ts",
3138                "edits": [{
3139                    "content": "replacement", "startLine": 14, "endLine": 14,
3140                    "oldString": "", "newString": "", "replaceAll": true,
3141                }],
3142            }),
3143            serde_json::json!({
3144                "path": "src/example.ts",
3145                "edits": [{
3146                    "content": "replacement", "startLine": 14, "endLine": 14,
3147                    "oldString": "", "newString": "", "occurrence": 2,
3148                }],
3149            }),
3150        ] {
3151            let error = subc_translate_owned("edit", arguments, project)
3152                .expect_err("meaningful find/replace fields must remain mixed-mode errors");
3153            assert_eq!(
3154                error.message,
3155                "edit: edits[0] mixes find/replace and line-range fields"
3156            );
3157        }
3158
3159        let empty_find = subc_translate_owned(
3160            "edit",
3161            serde_json::json!({
3162                "path": "src/example.ts",
3163                "edits": [{ "oldString": "", "newString": "replacement" }],
3164            }),
3165            project,
3166        )
3167        .expect("empty find match without line-range fields must reach batch validation");
3168        assert_eq!(empty_find.command, "batch");
3169        assert_eq!(
3170            empty_find.args["edits"][0]["match"],
3171            Value::String(String::new())
3172        );
3173    }
3174
3175    #[test]
3176    fn edit_null_sentinels_are_absent_at_both_edit_boundaries() {
3177        let project = Path::new("/project");
3178
3179        // Null optional range fields must not turn an otherwise valid
3180        // find/replace item into a mixed-family request.
3181        let issue_payload = serde_json::json!({
3182            "path": "src/example.ts",
3183            "edits": [{
3184                "oldString": "gamma line three",
3185                "newString": "GAMMA line three",
3186                "replaceAll": false,
3187                "occurrence": null,
3188                "startLine": null,
3189                "endLine": null,
3190                "content": null,
3191            }],
3192        });
3193        let translated = subc_translate_owned("edit", issue_payload, project)
3194            .expect("null range sentinels must not create a mode conflict");
3195        assert_eq!(
3196            translated.args["edits"],
3197            serde_json::json!([{
3198                "match": "gamma line three",
3199                "replacement": "GAMMA line three",
3200            }]),
3201        );
3202
3203        // Cover every nullable item field so a change to one stripping branch
3204        // cannot silently restore the false mixed-mode rejection.
3205        for field in [
3206            "newString",
3207            "replaceAll",
3208            "occurrence",
3209            "startLine",
3210            "endLine",
3211            "content",
3212        ] {
3213            let mut item = serde_json::json!({
3214                "oldString": "before",
3215                "newString": "after",
3216            });
3217            item.as_object_mut()
3218                .expect("edit item object")
3219                .insert(field.to_string(), Value::Null);
3220            let translated = subc_translate_owned(
3221                "edit",
3222                serde_json::json!({ "path": "src/example.ts", "edits": [item] }),
3223                project,
3224            )
3225            .unwrap_or_else(|error| panic!("null {field} sentinel: {}", error.message));
3226            let expected = if field == "newString" {
3227                serde_json::json!([{ "match": "before" }])
3228            } else {
3229                serde_json::json!([{ "match": "before", "replacement": "after" }])
3230            };
3231            assert_eq!(
3232                translated.args["edits"], expected,
3233                "null {field} must be absent",
3234            );
3235        }
3236
3237        // A pure line-range delete remains real intent even when the host
3238        // emits nulls for all of the unrelated find/replace fields.
3239        let line_delete = subc_translate_owned(
3240            "edit",
3241            serde_json::json!({
3242                "path": "src/example.ts",
3243                "edits": [{
3244                    "startLine": 1,
3245                    "endLine": 1,
3246                    "content": "",
3247                    "oldString": null,
3248                    "newString": null,
3249                    "replaceAll": null,
3250                    "occurrence": null,
3251                }],
3252            }),
3253            project,
3254        )
3255        .expect("null find fields must not hide a line-range delete");
3256        assert_eq!(
3257            line_delete.args["edits"],
3258            serde_json::json!([{
3259                "content": "",
3260                "line_start": 1,
3261                "line_end": 1,
3262            }]),
3263        );
3264
3265        // An item containing only null sentinels has no edit intent and is
3266        // dropped, while a non-null malformed match still reports its own
3267        // missing required field.
3268        let null_item = serde_json::json!({
3269            "oldString": null,
3270            "newString": null,
3271            "replaceAll": null,
3272            "occurrence": null,
3273            "startLine": null,
3274            "endLine": null,
3275            "content": null,
3276        });
3277        let no_mode = subc_translate_owned(
3278            "edit",
3279            serde_json::json!({ "path": "src/example.ts", "edits": [null_item] }),
3280            project,
3281        )
3282        .expect_err("all-null edit item must be dropped as a sentinel");
3283        assert!(no_mode.message.contains("exactly one of"));
3284
3285        let mixed = subc_translate_owned(
3286            "edit",
3287            serde_json::json!({
3288                "path": "src/example.ts",
3289                "edits": [
3290                    {
3291                        "oldString": null,
3292                        "newString": null,
3293                        "replaceAll": null,
3294                        "occurrence": null,
3295                        "startLine": null,
3296                        "endLine": null,
3297                        "content": null,
3298                    },
3299                    { "oldString": "before", "newString": "after" },
3300                ],
3301            }),
3302            project,
3303        )
3304        .expect("real edit must survive an all-null sentinel");
3305        assert_eq!(
3306            mixed.args["edits"],
3307            serde_json::json!([{ "match": "before", "replacement": "after" }]),
3308        );
3309
3310        let malformed = subc_translate_owned(
3311            "edit",
3312            serde_json::json!({
3313                "path": "src/example.ts",
3314                "edits": [{ "oldString": null, "newString": "replacement" }],
3315            }),
3316            project,
3317        )
3318        .expect_err("null oldString with real replacement must remain invalid");
3319        assert!(malformed.message.contains("requires string 'oldString'"));
3320
3321        // Top-level nulls are absent mode sentinels, but null content in an
3322        // otherwise selected symbol mode still fails the required-content check.
3323        let top_level_base = serde_json::json!({
3324            "path": "src/example.ts",
3325            "edits": [{ "oldString": "before", "newString": "after" }],
3326        });
3327        for field in [
3328            "appendContent",
3329            "symbol",
3330            "content",
3331            "oldString",
3332            "newString",
3333            "replaceAll",
3334            "occurrence",
3335        ] {
3336            let mut arguments = top_level_base.clone();
3337            arguments
3338                .as_object_mut()
3339                .expect("edit arguments object")
3340                .insert(field.to_string(), Value::Null);
3341            subc_translate_owned("edit", arguments, project)
3342                .unwrap_or_else(|error| panic!("top-level null {field}: {}", error.message));
3343        }
3344        let null_edits = subc_translate_owned(
3345            "edit",
3346            serde_json::json!({
3347                "path": "src/example.ts",
3348                "appendContent": "append",
3349                "edits": null,
3350            }),
3351            project,
3352        )
3353        .expect("top-level null edits must be absent");
3354        assert_eq!(null_edits.command, "edit_match");
3355
3356        let symbol_error = subc_translate_owned(
3357            "edit",
3358            serde_json::json!({
3359                "path": "src/example.ts",
3360                "symbol": "greetUser",
3361                "content": null,
3362            }),
3363            project,
3364        )
3365        .expect_err("null symbol content must fail the required-content check");
3366        assert_eq!(
3367            symbol_error.message,
3368            "edit: symbol mode requires both 'symbol' and 'content' string properties"
3369        );
3370
3371        let occurrence_zero = subc_translate_owned(
3372            "edit",
3373            serde_json::json!({
3374                "path": "src/example.ts",
3375                "edits": [{ "oldString": "before", "occurrence": 0 }],
3376            }),
3377            project,
3378        )
3379        .expect_err("occurrence zero must not be treated as a null sentinel");
3380        assert!(occurrence_zero.message.contains("occurrence"));
3381    }
3382
3383    #[test]
3384    fn edit_mode_errors_steer_away_from_empty_sentinels() {
3385        let project = Path::new("/project");
3386        let steering = "Omit unused optional fields entirely; do not send empty strings or empty arrays for them.";
3387
3388        let conflict = subc_translate_owned(
3389            "edit",
3390            serde_json::json!({
3391                "path": "src/main.ts",
3392                "appendContent": "x",
3393                "edits": [{ "oldString": "old", "newString": "new" }]
3394            }),
3395            project,
3396        )
3397        .expect_err("conflicting modes");
3398        assert!(conflict.message.contains("conflicting modes"));
3399        assert!(
3400            conflict.message.contains(steering),
3401            "conflicting-modes error must steer: {}",
3402            conflict.message
3403        );
3404
3405        let no_mode = subc_translate_owned(
3406            "edit",
3407            serde_json::json!({ "path": "src/main.ts" }),
3408            project,
3409        )
3410        .expect_err("no mode");
3411        assert!(no_mode.message.contains("exactly one of"));
3412        assert!(
3413            no_mode.message.contains(steering),
3414            "no-mode error must steer: {}",
3415            no_mode.message
3416        );
3417    }
3418
3419    #[test]
3420    fn search_legacy_hint_is_accepted_and_ignored() {
3421        let translated = subc_translate_owned(
3422            "search",
3423            serde_json::json!({
3424                "query": "outside <touser>",
3425                "topK": 5,
3426                "hint": "literal"
3427            }),
3428            Path::new("/project"),
3429        )
3430        .expect("legacy search hint must not reject the request");
3431
3432        assert_eq!(translated.command, "semantic_search");
3433        assert_eq!(
3434            translated.args.get("query").and_then(Value::as_str),
3435            Some("outside <touser>")
3436        );
3437        assert_eq!(
3438            translated.args.get("top_k").and_then(Value::as_u64),
3439            Some(5)
3440        );
3441        assert!(translated.args.get("hint").is_none());
3442    }
3443
3444    // supports_tool() gates whether run_tool_call translates or passes a name
3445    // through as a native command. If a translate arm is added but the
3446    // allowlist isn't updated, that tool would silently bypass translation and
3447    // dispatch as a raw native command — this proves the two sets agree.
3448    #[test]
3449    fn powershell_translation_selects_the_unified_bash_executor() {
3450        let translated = subc_translate_owned(
3451            "powershell",
3452            serde_json::json!({ "command": "Get-ChildItem", "workdir": "scripts" }),
3453            Path::new("/project"),
3454        )
3455        .expect("PowerShell tool must translate");
3456
3457        assert_eq!(translated.command, "bash");
3458        assert_eq!(
3459            translated.args.get("shell"),
3460            Some(&Value::String("powershell".into()))
3461        );
3462        // Compare as paths, not strings: the product re-joins every component
3463        // with the native separator (Windows renders \project\scripts), so no
3464        // single expected literal is right on both platforms. Path equality is
3465        // component-based and separator-agnostic.
3466        let workdir = translated
3467            .args
3468            .get("workdir")
3469            .and_then(Value::as_str)
3470            .expect("workdir must translate");
3471        assert_eq!(
3472            Path::new(workdir),
3473            Path::new("/project").join("scripts").as_path()
3474        );
3475    }
3476
3477    #[test]
3478    fn supports_tool_covers_every_translated_arm() {
3479        for name in [
3480            "bash",
3481            "powershell",
3482            "status",
3483            "read",
3484            "write",
3485            "edit",
3486            "apply_patch",
3487            "grep",
3488            "glob",
3489            "search",
3490            "outline",
3491            "zoom",
3492            "inspect",
3493            "callgraph",
3494            "conflicts",
3495            "ast_search",
3496            "ast_replace",
3497            "delete",
3498            "move",
3499            "import",
3500            "refactor",
3501            "safety",
3502        ] {
3503            // Every name the allowlist claims support for must actually
3504            // translate (not return unsupported_tool). A no-arg call may fail
3505            // validation, but it must never be unsupported_tool.
3506            let err =
3507                subc_translate_owned(name, Value::Object(Map::new()), Path::new("/project")).err();
3508            assert_ne!(
3509                err.as_ref().map(|e| e.code),
3510                Some("unsupported_tool"),
3511                "{name} is in supports_tool but has no translate arm"
3512            );
3513            assert!(
3514                supports_tool(name),
3515                "{name} translates but is missing from supports_tool"
3516            );
3517        }
3518        // A name that is not a tool must be rejected by both.
3519        assert!(!supports_tool("definitely_not_a_tool"));
3520    }
3521}