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