Skip to main content

aft/
subc_translate.rs

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