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