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