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