Skip to main content

aft/
subc_translate.rs

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