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