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