Skip to main content

aft/
subc_translate.rs

1//! Agent-facing tool → native command translation (subc edge only).
2
3use std::borrow::Cow;
4use std::path::{Path, PathBuf};
5
6use serde_json::{Map, Value};
7
8const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct Translated {
12    pub command: String,
13    pub args: Map<String, Value>,
14}
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct TranslateContext {
18    pub diagnostics_on_edit: bool,
19    pub preview: bool,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TranslateError {
24    pub code: &'static str,
25    pub message: String,
26}
27
28fn invalid_request(message: impl Into<String>) -> TranslateError {
29    TranslateError {
30        code: "invalid_request",
31        message: message.into(),
32    }
33}
34
35fn unsupported_tool(message: impl Into<String>) -> TranslateError {
36    TranslateError {
37        code: "unsupported_tool",
38        message: message.into(),
39    }
40}
41
42fn resolve_home_dir() -> Option<PathBuf> {
43    let raw = std::env::var_os("HOME")
44        .or_else(|| std::env::var_os("USERPROFILE"))
45        .map(PathBuf::from)?;
46    Some(raw)
47}
48
49fn expand_tilde(target: &str) -> Cow<'_, str> {
50    if target == "~" {
51        return resolve_home_dir()
52            .map(|h| Cow::Owned(h.to_string_lossy().into_owned()))
53            .unwrap_or(Cow::Borrowed(target));
54    }
55    if let Some(rest) = target.strip_prefix("~/") {
56        if let Some(home) = resolve_home_dir() {
57            return Cow::Owned(home.join(rest).to_string_lossy().into_owned());
58        }
59    }
60    // Ordinary paths (the overwhelmingly common case) borrow — no allocation.
61    Cow::Borrowed(target)
62}
63
64/// Decode an RFC 8089 `file:` URL to a local filesystem path.
65///
66/// Agents (and users pasting editor links) routinely spell local paths as
67/// `file:///path`, `file:/path`, or `file://localhost/path`; rejecting those
68/// only produces failed tool calls. Accepts empty/`localhost` authorities and
69/// percent-decodes the path. A `file://server/share` form (non-local
70/// authority) becomes a UNC path on Windows and is left undecoded elsewhere.
71/// Grants no extra access: the decoded path flows through the same
72/// resolution and permission checks as any literal path — the plugins apply
73/// the SAME decoding before their permission gates so both layers judge the
74/// identical target.
75fn decode_file_url(target: &str) -> Option<String> {
76    let rest = target.strip_prefix("file:")?;
77    let path_part = if let Some(after) = rest.strip_prefix("//") {
78        let (authority, path) = match after.find('/') {
79            Some(index) => after.split_at(index),
80            None => (after, ""),
81        };
82        match authority {
83            "" | "localhost" => path.to_string(),
84            server if cfg!(windows) => format!("//{server}{path}"),
85            _ => return None,
86        }
87    } else {
88        // RFC 8089 minimal form: `file:/path` (exactly one slash).
89        if !rest.starts_with('/') {
90            return None;
91        }
92        rest.to_string()
93    };
94    let decoded = percent_decode(&path_part);
95    // `file:///C:/path` decodes to `/C:/path`; strip the leading slash so the
96    // drive-letter form is a valid Windows absolute path.
97    if cfg!(windows) {
98        let bytes = decoded.as_bytes();
99        if bytes.len() >= 3
100            && bytes[0] == b'/'
101            && bytes[1].is_ascii_alphabetic()
102            && bytes[2] == b':'
103        {
104            return Some(decoded[1..].to_string());
105        }
106    }
107    Some(decoded)
108}
109
110fn percent_decode(input: &str) -> String {
111    let bytes = input.as_bytes();
112    let mut out = Vec::with_capacity(bytes.len());
113    let mut index = 0;
114    while index < bytes.len() {
115        if bytes[index] == b'%' && index + 2 < bytes.len() {
116            let hex = &input[index + 1..index + 3];
117            if let Ok(value) = u8::from_str_radix(hex, 16) {
118                out.push(value);
119                index += 3;
120                continue;
121            }
122        }
123        out.push(bytes[index]);
124        index += 1;
125    }
126    String::from_utf8_lossy(&out).into_owned()
127}
128
129pub fn resolve_path_from_project_root(project_root: &Path, target: &str) -> PathBuf {
130    let target = decode_file_url(target)
131        .map(std::borrow::Cow::Owned)
132        .unwrap_or(std::borrow::Cow::Borrowed(target));
133    let expanded = expand_tilde(&target);
134    let path = Path::new(expanded.as_ref());
135    let joined = if path.is_absolute() {
136        path.to_path_buf()
137    } else {
138        project_root.join(path)
139    };
140    normalize_lexically(&joined)
141}
142
143fn normalize_lexically(path: &Path) -> PathBuf {
144    use std::path::Component;
145
146    let mut out = PathBuf::new();
147    for component in path.components() {
148        match component {
149            Component::CurDir => {}
150            Component::ParentDir => {
151                if !out.pop() {
152                    out.push(component.as_os_str());
153                }
154            }
155            Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
156                out.push(component.as_os_str());
157            }
158        }
159    }
160    if out.as_os_str().is_empty() {
161        PathBuf::from(".")
162    } else {
163        out
164    }
165}
166
167fn is_empty_param(value: &Value) -> bool {
168    match value {
169        Value::Null => true,
170        Value::String(s) => s.is_empty(),
171        Value::Array(a) => a.is_empty(),
172        Value::Object(o) => o.is_empty(),
173        _ => false,
174    }
175}
176
177fn coerce_optional_int_result(
178    value: Option<&Value>,
179    param_name: &str,
180    min: i64,
181    max: i64,
182) -> Result<Option<u64>, TranslateError> {
183    let Some(value) = value else {
184        return Ok(None);
185    };
186    if value.is_null()
187        || matches!(value, Value::String(s) if s.is_empty())
188        || matches!(value, Value::Array(a) if a.is_empty())
189        || matches!(value, Value::Object(o) if o.is_empty())
190    {
191        return Ok(None);
192    }
193    if matches!(value, Value::Number(num) if num.as_i64() == Some(0) && min > 0) {
194        return Ok(None);
195    }
196
197    let int_error = || {
198        invalid_request(format!(
199            "{param_name} must be an integer between {min} and {max}"
200        ))
201    };
202    let n = match value {
203        Value::Number(num) => num.as_i64().ok_or_else(int_error)?,
204        Value::String(s) => {
205            let parsed = s.parse::<f64>().map_err(|_| int_error())?;
206            if !parsed.is_finite() || parsed.fract() != 0.0 {
207                return Err(int_error());
208            }
209            parsed as i64
210        }
211        _ => return Err(int_error()),
212    };
213    if n < min || n > max {
214        return Err(invalid_request(format!(
215            "{param_name} must be between {min} and {max}"
216        )));
217    }
218    Ok(Some(n as u64))
219}
220
221fn agent_args_map(args: Value) -> Map<String, Value> {
222    match args {
223        Value::Object(map) => map,
224        _ => Map::new(),
225    }
226}
227
228pub(crate) fn supports_tool(bare_name: &str) -> bool {
229    matches!(
230        bare_name,
231        "bash"
232            | "status"
233            | "read"
234            | "write"
235            | "edit"
236            | "apply_patch"
237            | "grep"
238            | "glob"
239            | "search"
240            | "outline"
241            | "zoom"
242            | "inspect"
243            | "callgraph"
244            | "conflicts"
245            | "ast_search"
246            | "ast_replace"
247            | "delete"
248            | "move"
249            | "import"
250            | "refactor"
251            | "safety"
252    )
253}
254
255fn insert_resolved_file(map: &mut Map<String, Value>, project_root: &Path, file_path: &str) {
256    let resolved = resolve_path_from_project_root(project_root, file_path);
257    map.insert(
258        "file".to_string(),
259        Value::String(resolved.to_string_lossy().into_owned()),
260    );
261}
262
263pub fn subc_translate(
264    bare_name: &str,
265    agent_args: &Value,
266    project_root: &Path,
267) -> Result<Translated, TranslateError> {
268    subc_translate_owned(bare_name, agent_args.clone(), project_root)
269}
270
271pub fn subc_translate_owned(
272    bare_name: &str,
273    agent_args: Value,
274    project_root: &Path,
275) -> Result<Translated, TranslateError> {
276    subc_translate_owned_with_context(
277        bare_name,
278        agent_args,
279        project_root,
280        TranslateContext::default(),
281    )
282}
283
284pub fn subc_translate_with_context(
285    bare_name: &str,
286    agent_args: &Value,
287    project_root: &Path,
288    ctx: TranslateContext,
289) -> Result<Translated, TranslateError> {
290    subc_translate_owned_with_context(bare_name, agent_args.clone(), project_root, ctx)
291}
292
293pub fn subc_translate_owned_with_context(
294    bare_name: &str,
295    agent_args: Value,
296    project_root: &Path,
297    ctx: TranslateContext,
298) -> Result<Translated, TranslateError> {
299    match bare_name {
300        "bash" => translate_bash(agent_args, project_root),
301        "status" => Ok(Translated {
302            command: "status".into(),
303            args: Map::new(),
304        }),
305        "read" => translate_read(agent_args, project_root),
306        "write" => translate_write(agent_args, project_root, ctx),
307        "edit" => translate_edit(agent_args, project_root, ctx),
308        "apply_patch" => translate_apply_patch(agent_args),
309        "grep" => translate_grep(agent_args, project_root),
310        "glob" => translate_glob(agent_args),
311        "search" => translate_search(agent_args),
312        "outline" => translate_outline(agent_args, project_root),
313        "zoom" => translate_zoom(agent_args, project_root),
314        "inspect" => translate_inspect(agent_args, project_root),
315        "callgraph" => translate_callgraph(agent_args, project_root),
316        "conflicts" => translate_conflicts(agent_args),
317        "ast_search" => translate_ast_search(agent_args),
318        "ast_replace" => translate_ast_replace(agent_args),
319        "delete" => translate_delete(agent_args, project_root),
320        "move" => translate_move(agent_args, project_root),
321        "import" => translate_import(agent_args),
322        "refactor" => translate_refactor(agent_args),
323        "safety" => translate_safety(agent_args, project_root),
324        other => Err(unsupported_tool(format!(
325            "subc_translate: unsupported tool {other:?}"
326        ))),
327    }
328}
329
330fn coerce_boolean(value: &Value) -> bool {
331    match value {
332        Value::Bool(value) => *value,
333        Value::Number(num) => num.as_i64() == Some(1) || num.as_u64() == Some(1),
334        Value::String(raw) => {
335            let normalized = raw.trim().to_ascii_lowercase();
336            normalized == "true" || normalized == "1"
337        }
338        _ => false,
339    }
340}
341
342fn translate_bash(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
343    let mut map_in = agent_args_map(args);
344    if let Some(Value::Object(params)) = map_in.remove("params") {
345        map_in = params;
346    }
347    let command = map_in
348        .get("command")
349        .and_then(Value::as_str)
350        .ok_or_else(|| invalid_request("'command' is required"))?;
351
352    let mut out = Map::new();
353    out.insert("command".to_string(), Value::String(command.to_string()));
354
355    if let Some(timeout) =
356        coerce_optional_int_result(map_in.get("timeout"), "timeout", 1, MAX_SAFE_INTEGER)?
357    {
358        out.insert("timeout".to_string(), Value::Number(timeout.into()));
359    }
360
361    if let Some(workdir) = map_in
362        .get("workdir")
363        .and_then(Value::as_str)
364        .filter(|value| !value.is_empty())
365    {
366        let resolved = resolve_path_from_project_root(project_root, workdir);
367        out.insert(
368            "workdir".to_string(),
369            Value::String(resolved.to_string_lossy().into_owned()),
370        );
371    }
372
373    if let Some(description) = map_in
374        .get("description")
375        .and_then(Value::as_str)
376        .filter(|value| !value.is_empty())
377    {
378        out.insert(
379            "description".to_string(),
380            Value::String(description.to_string()),
381        );
382    }
383
384    let background = map_in.get("background").is_some_and(coerce_boolean);
385    let pty = map_in.get("pty").is_some_and(coerce_boolean);
386    let wait = map_in.get("wait").is_some_and(coerce_boolean);
387    if wait && pty {
388        return Err(invalid_request(
389            "bash: wait:true cannot be used with pty:true because PTY sessions run in background",
390        ));
391    }
392    if wait && background {
393        return Err(invalid_request(
394            "bash: wait:true cannot be used with background:true",
395        ));
396    }
397    out.insert("background".to_string(), Value::Bool(background));
398    out.insert("pty".to_string(), Value::Bool(pty));
399    out.insert("wait".to_string(), Value::Bool(wait));
400    out.insert(
401        "notify_on_completion".to_string(),
402        Value::Bool(background || pty),
403    );
404
405    if let Some(rows) = coerce_optional_int_result(
406        map_in.get("ptyRows").or_else(|| map_in.get("pty_rows")),
407        "ptyRows",
408        1,
409        60,
410    )? {
411        out.insert("pty_rows".to_string(), Value::Number(rows.into()));
412    }
413    if let Some(cols) = coerce_optional_int_result(
414        map_in.get("ptyCols").or_else(|| map_in.get("pty_cols")),
415        "ptyCols",
416        1,
417        140,
418    )? {
419        out.insert("pty_cols".to_string(), Value::Number(cols.into()));
420    }
421
422    if let Some(compressed) = map_in.get("compressed") {
423        out.insert(
424            "compressed".to_string(),
425            Value::Bool(coerce_boolean(compressed)),
426        );
427    }
428
429    let foreground_orchestrate = map_in
430        .get("foreground_orchestrate")
431        .map(coerce_boolean)
432        .unwrap_or(true);
433    let block_to_completion = map_in
434        .get("block_to_completion")
435        .map(coerce_boolean)
436        .unwrap_or(false);
437    out.insert(
438        "foreground_orchestrate".to_string(),
439        Value::Bool(foreground_orchestrate),
440    );
441    out.insert(
442        "block_to_completion".to_string(),
443        Value::Bool(block_to_completion),
444    );
445
446    if let Some(permissions_granted) = map_in.get("permissions_granted") {
447        out.insert(
448            "permissions_granted".to_string(),
449            permissions_granted.clone(),
450        );
451    }
452    if let Some(permissions_requested) = map_in.get("permissions_requested") {
453        out.insert(
454            "permissions_requested".to_string(),
455            Value::Bool(coerce_boolean(permissions_requested)),
456        );
457    }
458    if let Some(env) = map_in.get("env") {
459        out.insert("env".to_string(), env.clone());
460    }
461    if let Some(sandbox) = map_in.get("sandbox") {
462        if sandbox.as_str() != Some("host") {
463            return Err(invalid_request("bash: 'sandbox' must be 'host'"));
464        }
465        out.insert("sandbox".to_string(), sandbox.clone());
466    }
467
468    Ok(Translated {
469        command: "bash".into(),
470        args: out,
471    })
472}
473
474fn translate_callgraph(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
475    let map_in = agent_args_map(args);
476    let op = map_in
477        .get("op")
478        .and_then(Value::as_str)
479        .filter(|s| !s.is_empty())
480        .ok_or_else(|| invalid_request("'op' is required"))?;
481    if !matches!(
482        op,
483        "call_tree" | "callers" | "trace_to" | "trace_to_symbol" | "impact" | "trace_data"
484    ) {
485        return Err(invalid_request(format!("callgraph: invalid op '{op}'")));
486    }
487
488    let file_path = map_in
489        .get("filePath")
490        .and_then(Value::as_str)
491        .filter(|s| !s.is_empty())
492        .ok_or_else(|| invalid_request("'filePath' is required"))?;
493    let symbol = map_in
494        .get("symbol")
495        .and_then(Value::as_str)
496        .filter(|s| !s.is_empty())
497        .ok_or_else(|| invalid_request("'symbol' is required"))?;
498
499    if op == "trace_data" && map_in.get("expression").is_none_or(is_empty_param) {
500        return Err(invalid_request(
501            "'expression' is required for 'trace_data' op",
502        ));
503    }
504    if op == "trace_to_symbol" && map_in.get("toSymbol").is_none_or(is_empty_param) {
505        return Err(invalid_request(
506            "'toSymbol' is required for 'trace_to_symbol' op",
507        ));
508    }
509
510    let mut out = Map::new();
511    insert_resolved_file(&mut out, project_root, file_path);
512    out.insert("symbol".to_string(), Value::String(symbol.to_string()));
513
514    if let Some(depth) =
515        coerce_optional_int_result(map_in.get("depth"), "depth", 1, 9_007_199_254_740_991)?
516    {
517        out.insert("depth".to_string(), Value::Number(depth.into()));
518    }
519    if let Some(expression) = map_in.get("expression") {
520        if !is_empty_param(expression) {
521            out.insert("expression".to_string(), expression.clone());
522        }
523    }
524    if let Some(to_symbol) = map_in.get("toSymbol") {
525        if !is_empty_param(to_symbol) {
526            out.insert("toSymbol".to_string(), to_symbol.clone());
527        }
528    }
529    if let Some(to_file) = map_in.get("toFile") {
530        if !is_empty_param(to_file) {
531            let to_file = to_file
532                .as_str()
533                .ok_or_else(|| invalid_request("'toFile' must be a string"))?;
534            let resolved = resolve_path_from_project_root(project_root, to_file);
535            out.insert(
536                "toFile".to_string(),
537                Value::String(resolved.to_string_lossy().into_owned()),
538            );
539        }
540    }
541    if let Some(include_tests) = map_in.get("includeTests") {
542        if !is_empty_param(include_tests) {
543            out.insert(
544                "include_tests".to_string(),
545                Value::Bool(coerce_boolean(include_tests)),
546            );
547        }
548    }
549
550    Ok(Translated {
551        command: op.to_string(),
552        args: out,
553    })
554}
555
556fn insert_common_mutation_flags(out: &mut Map<String, Value>, ctx: TranslateContext) {
557    out.insert(
558        "diagnostics".to_string(),
559        Value::Bool(ctx.diagnostics_on_edit),
560    );
561    out.insert("include_diff_content".to_string(), Value::Bool(true));
562    out.insert("preview".to_string(), Value::Bool(ctx.preview));
563}
564
565fn translate_read(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
566    let map_in = agent_args_map(args);
567    let file_path = map_in
568        .get("filePath")
569        .and_then(Value::as_str)
570        .filter(|s| !s.is_empty())
571        .ok_or_else(|| invalid_request("'filePath' is required"))?;
572
573    let mut out = Map::new();
574    insert_resolved_file(&mut out, project_root, file_path);
575
576    let mut start_line = map_in.get("startLine").and_then(Value::as_u64);
577    let mut end_line = map_in.get("endLine").and_then(Value::as_u64);
578
579    if start_line.is_none() {
580        if let Some(offset) = map_in.get("offset").and_then(Value::as_u64) {
581            start_line = Some(offset);
582            if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
583                end_line = Some(offset.saturating_add(limit).saturating_sub(1));
584            }
585        }
586    }
587
588    if let Some(sl) = start_line {
589        out.insert("start_line".to_string(), Value::Number(sl.into()));
590    }
591    if let Some(el) = end_line {
592        out.insert("end_line".to_string(), Value::Number(el.into()));
593    }
594    if map_in.get("offset").is_none() {
595        if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
596            out.insert("limit".to_string(), Value::Number(limit.into()));
597        }
598    }
599
600    Ok(Translated {
601        command: "read".into(),
602        args: out,
603    })
604}
605
606fn translate_write(
607    args: Value,
608    project_root: &Path,
609    ctx: TranslateContext,
610) -> Result<Translated, TranslateError> {
611    let mut map_in = agent_args_map(args);
612    let file_path = match map_in.remove("filePath") {
613        Some(Value::String(path)) if !path.is_empty() => path,
614        _ => return Err(invalid_request("'filePath' is required")),
615    };
616    let content = match map_in.remove("content") {
617        Some(Value::String(content)) => content,
618        _ => return Err(invalid_request("write: missing required param 'content'")),
619    };
620
621    let mut out = Map::new();
622    insert_resolved_file(&mut out, project_root, &file_path);
623    out.insert("content".to_string(), Value::String(content));
624    out.insert("create_dirs".to_string(), Value::Bool(true));
625    insert_common_mutation_flags(&mut out, ctx);
626
627    Ok(Translated {
628        command: "write".into(),
629        args: out,
630    })
631}
632
633fn translate_edit(
634    args: Value,
635    project_root: &Path,
636    ctx: TranslateContext,
637) -> Result<Translated, TranslateError> {
638    let map_in = agent_args_map(args);
639
640    if map_in.get("startLine").is_some() || map_in.get("endLine").is_some() {
641        return Err(invalid_request(
642            "edit: 'startLine'/'endLine' are not top-level parameters. \
643             For line-range edits, nest them inside the `edits` array. \
644             For find/replace, use 'oldString'/'newString'.",
645        ));
646    }
647
648    let file_path = map_in
649        .get("filePath")
650        .and_then(Value::as_str)
651        .filter(|s| !s.is_empty())
652        .ok_or_else(|| invalid_request("'filePath' is required"))?;
653
654    let file_str = resolve_path_from_project_root(project_root, file_path)
655        .to_string_lossy()
656        .into_owned();
657
658    if let Some(append) = map_in.get("appendContent").and_then(Value::as_str) {
659        let mut out = Map::new();
660        out.insert("file".to_string(), Value::String(file_str));
661        out.insert("op".to_string(), Value::String("append".into()));
662        out.insert(
663            "append_content".to_string(),
664            Value::String(append.to_string()),
665        );
666        out.insert("create_dirs".to_string(), Value::Bool(true));
667        insert_common_mutation_flags(&mut out, ctx);
668        return Ok(Translated {
669            command: "edit_match".into(),
670            args: out,
671        });
672    }
673
674    if let Some(edits) = map_in.get("edits").and_then(Value::as_array) {
675        let mut out = Map::new();
676        out.insert("file".to_string(), Value::String(file_str));
677        let translated_edits: Vec<Value> = edits
678            .iter()
679            .filter_map(|edit| {
680                let obj = edit.as_object()?;
681                let mut t = Map::new();
682                for (key, value) in obj {
683                    let native_key = match key.as_str() {
684                        "oldString" => "match",
685                        "newString" => "replacement",
686                        "startLine" => "line_start",
687                        "endLine" => "line_end",
688                        other => other,
689                    };
690                    t.insert(native_key.to_string(), value.clone());
691                }
692                Some(Value::Object(t))
693            })
694            .collect();
695        out.insert("edits".to_string(), Value::Array(translated_edits));
696        insert_common_mutation_flags(&mut out, ctx);
697        return Ok(Translated {
698            command: "batch".into(),
699            args: out,
700        });
701    }
702
703    let symbol_is_string = map_in.get("symbol").and_then(Value::as_str).is_some();
704    let old_string_is_string = map_in.get("oldString").and_then(Value::as_str).is_some();
705    let has_content = map_in.get("content").is_some();
706
707    if symbol_is_string && !old_string_is_string && has_content {
708        let mut out = Map::new();
709        out.insert("file".to_string(), Value::String(file_str));
710        out.insert(
711            "symbol".to_string(),
712            map_in.get("symbol").cloned().unwrap_or(Value::Null),
713        );
714        out.insert("operation".to_string(), Value::String("replace".into()));
715        out.insert(
716            "content".to_string(),
717            map_in.get("content").cloned().unwrap_or(Value::Null),
718        );
719        insert_common_mutation_flags(&mut out, ctx);
720        return Ok(Translated {
721            command: "edit_symbol".into(),
722            args: out,
723        });
724    }
725
726    if old_string_is_string {
727        let mut out = Map::new();
728        out.insert("file".to_string(), Value::String(file_str));
729        out.insert(
730            "match".to_string(),
731            Value::String(
732                map_in
733                    .get("oldString")
734                    .and_then(Value::as_str)
735                    .unwrap_or("")
736                    .to_string(),
737            ),
738        );
739        let replacement = map_in
740            .get("newString")
741            .and_then(Value::as_str)
742            .unwrap_or("");
743        out.insert(
744            "replacement".to_string(),
745            Value::String(replacement.to_string()),
746        );
747        if let Some(v) = map_in.get("replaceAll") {
748            out.insert("replace_all".to_string(), v.clone());
749        }
750        if map_in.contains_key("occurrence") {
751            if let Some(v) = map_in.get("occurrence") {
752                out.insert("occurrence".to_string(), v.clone());
753            }
754        }
755        insert_common_mutation_flags(&mut out, ctx);
756        return Ok(Translated {
757            command: "edit_match".into(),
758            args: out,
759        });
760    }
761
762    Err(invalid_request(
763        "edit: no edit mode resolved from arguments.",
764    ))
765}
766
767fn translate_apply_patch(args: Value) -> Result<Translated, TranslateError> {
768    let map_in = agent_args_map(args);
769    let patch_text = map_in
770        .get("patchText")
771        .and_then(Value::as_str)
772        .filter(|s| !s.is_empty())
773        .ok_or_else(|| invalid_request("apply_patch: missing required param 'patchText'"))?;
774
775    let mut out = Map::new();
776    out.insert(
777        "patch_text".to_string(),
778        Value::String(patch_text.to_string()),
779    );
780    Ok(Translated {
781        command: "apply_patch".into(),
782        args: out,
783    })
784}
785
786fn translate_grep(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
787    let map_in = agent_args_map(args);
788    let pattern = map_in
789        .get("pattern")
790        .and_then(Value::as_str)
791        .filter(|s| !s.is_empty())
792        .ok_or_else(|| invalid_request("grep: missing required param 'pattern'"))?;
793
794    let mut out = Map::new();
795    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
796    out.insert("case_sensitive".to_string(), Value::Bool(true));
797    if let Some(include) = map_in.get("include") {
798        if !is_empty_param(include) {
799            let include_arg = include.as_str().ok_or_else(|| {
800                invalid_request("grep: 'include' must be a comma-separated string")
801            })?;
802            let includes = split_include_arg(include_arg)
803                .into_iter()
804                .map(|pattern| Value::String(normalize_glob(&pattern)))
805                .collect::<Vec<_>>();
806            if !includes.is_empty() {
807                out.insert("include".to_string(), Value::Array(includes));
808            }
809        }
810    }
811    if let Some(path_val) = map_in.get("path") {
812        if !is_empty_param(path_val) {
813            if let Some(path_str) = path_val.as_str() {
814                out.insert(
815                    "path".to_string(),
816                    Value::String(resolve_grep_path_arg(project_root, path_str)),
817                );
818            }
819        }
820    }
821    out.insert("max_results".to_string(), Value::Number(100u64.into()));
822
823    Ok(Translated {
824        command: "grep".into(),
825        args: out,
826    })
827}
828
829fn translate_ast_search(args: Value) -> Result<Translated, TranslateError> {
830    let map_in = agent_args_map(args);
831    let pattern = map_in
832        .get("pattern")
833        .and_then(Value::as_str)
834        .filter(|s| !s.is_empty())
835        .ok_or_else(|| invalid_request("ast_search: missing required param 'pattern'"))?;
836    let lang = map_in
837        .get("lang")
838        .and_then(Value::as_str)
839        .filter(|s| !s.is_empty())
840        .ok_or_else(|| invalid_request("ast_search: missing required param 'lang'"))?;
841
842    let mut out = Map::new();
843    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
844    out.insert("lang".to_string(), Value::String(lang.to_string()));
845    insert_non_empty_array(&mut out, &map_in, "paths");
846    insert_non_empty_array(&mut out, &map_in, "globs");
847    if let Some(context) = coerce_optional_int_result(
848        map_in.get("contextLines"),
849        "contextLines",
850        1,
851        9_007_199_254_740_991,
852    )? {
853        out.insert("context".to_string(), Value::Number(context.into()));
854    }
855
856    Ok(Translated {
857        command: "ast_search".into(),
858        args: out,
859    })
860}
861
862fn translate_ast_replace(args: Value) -> Result<Translated, TranslateError> {
863    let map_in = agent_args_map(args);
864    let pattern = map_in
865        .get("pattern")
866        .and_then(Value::as_str)
867        .filter(|s| !s.is_empty())
868        .ok_or_else(|| invalid_request("ast_replace: missing required param 'pattern'"))?;
869    let rewrite = map_in
870        .get("rewrite")
871        .and_then(Value::as_str)
872        .ok_or_else(|| invalid_request("ast_replace: missing required param 'rewrite'"))?;
873    let lang = map_in
874        .get("lang")
875        .and_then(Value::as_str)
876        .filter(|s| !s.is_empty())
877        .ok_or_else(|| invalid_request("ast_replace: missing required param 'lang'"))?;
878
879    let mut out = Map::new();
880    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
881    out.insert("rewrite".to_string(), Value::String(rewrite.to_string()));
882    out.insert("lang".to_string(), Value::String(lang.to_string()));
883    insert_non_empty_array(&mut out, &map_in, "paths");
884    insert_non_empty_array(&mut out, &map_in, "globs");
885    let dry_run = map_in
886        .get("dryRun")
887        .or_else(|| map_in.get("dry_run"))
888        .is_some_and(coerce_boolean);
889    out.insert("dry_run".to_string(), Value::Bool(dry_run));
890
891    Ok(Translated {
892        command: "ast_replace".into(),
893        args: out,
894    })
895}
896
897fn insert_present_renamed(
898    out: &mut Map<String, Value>,
899    map_in: &Map<String, Value>,
900    from: &str,
901    to: &str,
902) {
903    if let Some(value) = map_in.get(from) {
904        out.insert(to.to_string(), value.clone());
905    }
906}
907
908fn translate_delete(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
909    let map_in = agent_args_map(args);
910    let files = map_in
911        .get("files")
912        .and_then(Value::as_array)
913        .filter(|items| !items.is_empty())
914        .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
915
916    let mut resolved_files = Vec::with_capacity(files.len());
917    for file in files {
918        let file = file
919            .as_str()
920            .filter(|path| !path.is_empty())
921            .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
922        let resolved = resolve_path_from_project_root(project_root, file);
923        resolved_files.push(Value::String(resolved.to_string_lossy().into_owned()));
924    }
925
926    let mut out = Map::new();
927    out.insert("files".to_string(), Value::Array(resolved_files));
928    out.insert(
929        "recursive".to_string(),
930        Value::Bool(map_in.get("recursive").is_some_and(coerce_boolean)),
931    );
932
933    Ok(Translated {
934        command: "delete_file".into(),
935        args: out,
936    })
937}
938
939fn translate_move(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
940    let map_in = agent_args_map(args);
941    let file_path = map_in
942        .get("filePath")
943        .and_then(Value::as_str)
944        .filter(|s| !s.is_empty())
945        .ok_or_else(|| invalid_request("aft_move: missing required param 'filePath'"))?;
946    let destination = map_in
947        .get("destination")
948        .and_then(Value::as_str)
949        .filter(|s| !s.is_empty())
950        .ok_or_else(|| invalid_request("aft_move: missing required param 'destination'"))?;
951
952    let file_path = resolve_path_from_project_root(project_root, file_path);
953    let destination = resolve_path_from_project_root(project_root, destination);
954
955    let mut out = Map::new();
956    out.insert(
957        "file".to_string(),
958        Value::String(file_path.to_string_lossy().into_owned()),
959    );
960    out.insert(
961        "destination".to_string(),
962        Value::String(destination.to_string_lossy().into_owned()),
963    );
964
965    Ok(Translated {
966        command: "move_file".into(),
967        args: out,
968    })
969}
970
971fn translate_import(args: Value) -> Result<Translated, TranslateError> {
972    let map_in = agent_args_map(args);
973    let op = map_in
974        .get("op")
975        .and_then(Value::as_str)
976        .ok_or_else(|| invalid_request("aft_import: missing required param 'op'"))?;
977    let command = match op {
978        "add" => "add_import",
979        "remove" => "remove_import",
980        "organize" => "organize_imports",
981        other => {
982            return Err(invalid_request(format!(
983                "aft_import: invalid op {other:?}; expected 'add', 'remove', or 'organize'"
984            )));
985        }
986    };
987
988    let file_path = map_in
989        .get("filePath")
990        .and_then(Value::as_str)
991        .filter(|s| !s.is_empty())
992        .ok_or_else(|| invalid_request("aft_import: missing required param 'filePath'"))?;
993
994    if matches!(op, "add" | "remove") && map_in.get("module").map_or(true, is_empty_param) {
995        return Err(invalid_request(format!(
996            "'module' is required for '{op}' op"
997        )));
998    }
999
1000    let mut out = Map::new();
1001    out.insert("file".to_string(), Value::String(file_path.to_string()));
1002    insert_present_renamed(&mut out, &map_in, "module", "module");
1003    insert_present_renamed(&mut out, &map_in, "names", "names");
1004    insert_present_renamed(&mut out, &map_in, "defaultImport", "default_import");
1005    insert_present_renamed(&mut out, &map_in, "namespace", "namespace");
1006    insert_present_renamed(&mut out, &map_in, "alias", "alias");
1007    insert_present_renamed(&mut out, &map_in, "modifiers", "modifiers");
1008    insert_present_renamed(&mut out, &map_in, "importKind", "import_kind");
1009    insert_present_renamed(&mut out, &map_in, "typeOnly", "type_only");
1010    insert_present_renamed(&mut out, &map_in, "removeName", "name");
1011    insert_present_renamed(&mut out, &map_in, "validate", "validate");
1012
1013    Ok(Translated {
1014        command: command.into(),
1015        args: out,
1016    })
1017}
1018
1019fn translate_refactor(args: Value) -> Result<Translated, TranslateError> {
1020    let map_in = agent_args_map(args);
1021    let op = map_in
1022        .get("op")
1023        .and_then(Value::as_str)
1024        .ok_or_else(|| invalid_request("aft_refactor: missing required param 'op'"))?;
1025    let command = match op {
1026        "move" => "move_symbol",
1027        "extract" => "extract_function",
1028        "inline" => "inline_symbol",
1029        other => {
1030            return Err(invalid_request(format!(
1031                "aft_refactor: invalid op {other:?}; expected 'move', 'extract', or 'inline'"
1032            )));
1033        }
1034    };
1035
1036    let file_path = map_in
1037        .get("filePath")
1038        .and_then(Value::as_str)
1039        .filter(|s| !s.is_empty())
1040        .ok_or_else(|| invalid_request("aft_refactor: missing required param 'filePath'"))?;
1041
1042    if matches!(op, "move" | "inline") && map_in.get("symbol").is_none_or(is_empty_param) {
1043        return Err(invalid_request(format!(
1044            "'symbol' is required for '{op}' op"
1045        )));
1046    }
1047    if op == "move" && map_in.get("destination").is_none_or(is_empty_param) {
1048        return Err(invalid_request("'destination' is required for 'move' op"));
1049    }
1050
1051    let mut out = Map::new();
1052    out.insert("file".to_string(), Value::String(file_path.to_string()));
1053
1054    match op {
1055        "move" => {
1056            insert_present_renamed(&mut out, &map_in, "symbol", "symbol");
1057            insert_present_renamed(&mut out, &map_in, "destination", "destination");
1058            insert_present_renamed(&mut out, &map_in, "scope", "scope");
1059        }
1060        "extract" => {
1061            if map_in.get("name").is_none_or(is_empty_param) {
1062                return Err(invalid_request("'name' is required for 'extract' op"));
1063            }
1064            let start_line = coerce_optional_int_result(
1065                map_in.get("startLine"),
1066                "startLine",
1067                1,
1068                MAX_SAFE_INTEGER,
1069            )?
1070            .ok_or_else(|| invalid_request("'startLine' is required for 'extract' op"))?;
1071            let end_line =
1072                coerce_optional_int_result(map_in.get("endLine"), "endLine", 1, MAX_SAFE_INTEGER)?
1073                    .ok_or_else(|| invalid_request("'endLine' is required for 'extract' op"))?;
1074
1075            insert_present_renamed(&mut out, &map_in, "name", "name");
1076            out.insert("start_line".to_string(), Value::Number(start_line.into()));
1077            out.insert("end_line".to_string(), Value::Number((end_line + 1).into()));
1078        }
1079        "inline" => {
1080            let call_site_line = coerce_optional_int_result(
1081                map_in.get("callSiteLine"),
1082                "callSiteLine",
1083                1,
1084                MAX_SAFE_INTEGER,
1085            )?
1086            .ok_or_else(|| invalid_request("'callSiteLine' is required for 'inline' op"))?;
1087
1088            insert_present_renamed(&mut out, &map_in, "symbol", "symbol");
1089            out.insert(
1090                "call_site_line".to_string(),
1091                Value::Number(call_site_line.into()),
1092            );
1093        }
1094        _ => unreachable!("validated refactor op"),
1095    }
1096
1097    insert_present_renamed(&mut out, &map_in, "lsp_hints", "lsp_hints");
1098
1099    Ok(Translated {
1100        command: command.into(),
1101        args: out,
1102    })
1103}
1104
1105fn translate_safety(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1106    let map_in = agent_args_map(args);
1107    let op = map_in
1108        .get("op")
1109        .and_then(Value::as_str)
1110        .ok_or_else(|| invalid_request("aft_safety: missing required param 'op'"))?;
1111    let command = match op {
1112        "undo" => "undo",
1113        "history" => "edit_history",
1114        "checkpoint" => "checkpoint",
1115        "restore" => "restore_checkpoint",
1116        "list" => "list_checkpoints",
1117        other => {
1118            return Err(invalid_request(format!(
1119                "aft_safety: invalid op {other:?}; expected 'undo', 'history', 'checkpoint', 'restore', or 'list'"
1120            )));
1121        }
1122    };
1123
1124    if op == "history" && map_in.get("filePath").and_then(Value::as_str).is_none() {
1125        return Err(invalid_request("'filePath' is required for 'history' op"));
1126    }
1127    if matches!(op, "checkpoint" | "restore")
1128        && map_in.get("name").and_then(Value::as_str).is_none()
1129    {
1130        return Err(invalid_request(format!("'name' is required for '{op}' op")));
1131    }
1132
1133    let resolve_path = |value: &Value| -> Result<Value, TranslateError> {
1134        let path = value
1135            .as_str()
1136            .filter(|path| !path.is_empty())
1137            .ok_or_else(|| invalid_request("aft_safety: paths must be non-empty strings"))?;
1138        Ok(Value::String(
1139            resolve_path_from_project_root(project_root, path)
1140                .to_string_lossy()
1141                .into_owned(),
1142        ))
1143    };
1144
1145    let mut out = Map::new();
1146    insert_present_renamed(&mut out, &map_in, "name", "name");
1147    let files = map_in
1148        .get("files")
1149        .and_then(Value::as_array)
1150        .filter(|items| !items.is_empty())
1151        .map(|items| {
1152            items
1153                .iter()
1154                .map(resolve_path)
1155                .collect::<Result<Vec<_>, _>>()
1156        })
1157        .transpose()?;
1158
1159    if op == "checkpoint" {
1160        if let Some(files) = files {
1161            out.insert("files".to_string(), Value::Array(files));
1162        } else if let Some(file_path) = map_in.get("filePath") {
1163            out.insert(
1164                "files".to_string(),
1165                Value::Array(vec![resolve_path(file_path)?]),
1166            );
1167        }
1168    } else {
1169        if let Some(file_path) = map_in.get("filePath") {
1170            out.insert("file".to_string(), resolve_path(file_path)?);
1171        }
1172        if let Some(files) = files {
1173            out.insert("files".to_string(), Value::Array(files));
1174        }
1175    }
1176
1177    Ok(Translated {
1178        command: command.into(),
1179        args: out,
1180    })
1181}
1182
1183fn insert_non_empty_array(out: &mut Map<String, Value>, map_in: &Map<String, Value>, key: &str) {
1184    if let Some(value) = map_in.get(key) {
1185        if let Some(items) = value.as_array() {
1186            if !items.is_empty() {
1187                out.insert(key.to_string(), Value::Array(items.clone()));
1188            }
1189        }
1190    }
1191}
1192
1193fn translate_glob(args: Value) -> Result<Translated, TranslateError> {
1194    let map_in = agent_args_map(args);
1195    let pattern = map_in
1196        .get("pattern")
1197        .and_then(Value::as_str)
1198        .filter(|s| !s.is_empty())
1199        .ok_or_else(|| invalid_request("glob: missing required param 'pattern'"))?;
1200
1201    let mut out = Map::new();
1202    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1203    if let Some(path_val) = map_in.get("path") {
1204        if !is_empty_param(path_val) {
1205            if let Some(path_str) = path_val.as_str() {
1206                out.insert("path".to_string(), Value::String(path_str.to_string()));
1207            }
1208        }
1209    }
1210
1211    Ok(Translated {
1212        command: "glob".into(),
1213        args: out,
1214    })
1215}
1216
1217fn normalize_glob(pattern: &str) -> String {
1218    if !pattern.contains('/') && !pattern.starts_with("**/") {
1219        format!("**/{pattern}")
1220    } else {
1221        pattern.to_string()
1222    }
1223}
1224
1225fn split_include_arg(raw: &str) -> Vec<String> {
1226    let mut out = Vec::new();
1227    let mut depth = 0usize;
1228    let mut buf = String::new();
1229    for ch in raw.chars() {
1230        match ch {
1231            '{' => {
1232                depth += 1;
1233                buf.push(ch);
1234            }
1235            '}' => {
1236                depth = depth.saturating_sub(1);
1237                buf.push(ch);
1238            }
1239            ',' if depth == 0 => {
1240                let trimmed = buf.trim();
1241                if !trimmed.is_empty() {
1242                    out.push(trimmed.to_string());
1243                }
1244                buf.clear();
1245            }
1246            _ => buf.push(ch),
1247        }
1248    }
1249    let trimmed = buf.trim();
1250    if !trimmed.is_empty() {
1251        out.push(trimmed.to_string());
1252    }
1253    out
1254}
1255
1256fn search_path_exists(project_root: &Path, raw: &str) -> bool {
1257    resolve_path_from_project_root(project_root, raw).exists()
1258}
1259
1260fn split_search_path_arg(project_root: &Path, raw: &str) -> Vec<String> {
1261    if search_path_exists(project_root, raw) || !raw.chars().any(char::is_whitespace) {
1262        return vec![raw.to_string()];
1263    }
1264
1265    let fragments = raw
1266        .split_whitespace()
1267        .filter(|fragment| !fragment.is_empty())
1268        .collect::<Vec<_>>();
1269    if fragments.len() < 2 {
1270        return vec![raw.to_string()];
1271    }
1272
1273    let existing = fragments
1274        .iter()
1275        .filter(|fragment| search_path_exists(project_root, fragment))
1276        .map(|fragment| (*fragment).to_string())
1277        .collect::<Vec<_>>();
1278    if existing.is_empty() {
1279        vec![raw.to_string()]
1280    } else {
1281        existing
1282    }
1283}
1284
1285fn resolve_grep_path_arg(project_root: &Path, raw: &str) -> String {
1286    split_search_path_arg(project_root, raw)
1287        .iter()
1288        .map(|target| {
1289            resolve_path_from_project_root(project_root, target)
1290                .to_string_lossy()
1291                .into_owned()
1292        })
1293        .collect::<Vec<_>>()
1294        .join(" ")
1295}
1296
1297fn translate_search(args: Value) -> Result<Translated, TranslateError> {
1298    let map_in = agent_args_map(args);
1299    let query = map_in
1300        .get("query")
1301        .and_then(Value::as_str)
1302        .filter(|s| !s.trim().is_empty())
1303        .ok_or_else(|| {
1304            invalid_request("semantic_search: invalid params: `query` must be a non-empty string")
1305        })?;
1306
1307    let mut out = Map::new();
1308    out.insert("query".to_string(), Value::String(query.to_string()));
1309    let top_k = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)?.unwrap_or(10);
1310    out.insert("top_k".to_string(), Value::Number(top_k.into()));
1311    if let Some(hint) = map_in.get("hint") {
1312        if !is_empty_param(hint) {
1313            out.insert("hint".to_string(), hint.clone());
1314        }
1315    }
1316    if let Some(include_tests) = map_in.get("includeTests").and_then(Value::as_bool) {
1317        out.insert("include_tests".to_string(), Value::Bool(include_tests));
1318    }
1319    if let Some(path) = map_in
1320        .get("path")
1321        .and_then(Value::as_str)
1322        .map(str::trim)
1323        .filter(|path| !path.is_empty())
1324    {
1325        out.insert("path".to_string(), Value::String(path.to_string()));
1326    }
1327
1328    Ok(Translated {
1329        command: "semantic_search".into(),
1330        args: out,
1331    })
1332}
1333
1334fn translate_outline(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1335    let map_in = agent_args_map(args);
1336    let files_flag = map_in
1337        .get("files")
1338        .and_then(Value::as_bool)
1339        .unwrap_or(false);
1340
1341    let target = map_in
1342        .get("target")
1343        .ok_or_else(|| invalid_request("outline: missing required param 'target'"))?;
1344
1345    if is_empty_param(target) {
1346        return Err(invalid_request(
1347            "'target' must be a non-empty string or array of strings",
1348        ));
1349    }
1350
1351    let mut out = Map::new();
1352    if let Some(include_tests) = map_in
1353        .get("includeTests")
1354        .or_else(|| map_in.get("include_tests"))
1355        .and_then(Value::as_bool)
1356    {
1357        out.insert("includeTests".to_string(), Value::Bool(include_tests));
1358    }
1359
1360    if let Some(arr) = target.as_array() {
1361        if arr.is_empty() {
1362            return Err(invalid_request(
1363                "'target' must be a non-empty string or array of strings",
1364            ));
1365        }
1366        if files_flag {
1367            let resolved: Vec<Value> = arr
1368                .iter()
1369                .filter_map(|v| v.as_str())
1370                .map(|entry| {
1371                    let p = resolve_path_from_project_root(project_root, entry);
1372                    Value::String(p.to_string_lossy().into_owned())
1373                })
1374                .collect();
1375            out.insert("target".to_string(), Value::Array(resolved));
1376            out.insert("files".to_string(), Value::Bool(true));
1377            return Ok(Translated {
1378                command: "outline".into(),
1379                args: out,
1380            });
1381        }
1382        let resolved: Vec<Value> = arr
1383            .iter()
1384            .filter_map(|v| v.as_str())
1385            .map(|entry| {
1386                let p = resolve_path_from_project_root(project_root, entry);
1387                Value::String(p.to_string_lossy().into_owned())
1388            })
1389            .collect();
1390        out.insert("files".to_string(), Value::Array(resolved));
1391        return Ok(Translated {
1392            command: "outline".into(),
1393            args: out,
1394        });
1395    }
1396
1397    if let Some(url) = target.as_str() {
1398        if !files_flag && (url.starts_with("http://") || url.starts_with("https://")) {
1399            out.insert("file".to_string(), Value::String(url.to_string()));
1400            return Ok(Translated {
1401                command: "outline".into(),
1402                args: out,
1403            });
1404        }
1405    }
1406
1407    let target_str = target.as_str().ok_or_else(|| {
1408        invalid_request("'target' must be a non-empty string or array of strings")
1409    })?;
1410
1411    let resolved = resolve_path_from_project_root(project_root, target_str);
1412    let is_dir = std::fs::metadata(&resolved)
1413        .map(|m| m.is_dir())
1414        .unwrap_or(false);
1415
1416    if files_flag {
1417        if is_dir {
1418            out.insert(
1419                "directory".to_string(),
1420                Value::String(resolved.to_string_lossy().into_owned()),
1421            );
1422        } else {
1423            out.insert(
1424                "file".to_string(),
1425                Value::String(resolved.to_string_lossy().into_owned()),
1426            );
1427        }
1428        out.insert("files".to_string(), Value::Bool(true));
1429    } else if is_dir {
1430        out.insert(
1431            "directory".to_string(),
1432            Value::String(resolved.to_string_lossy().into_owned()),
1433        );
1434    } else {
1435        out.insert(
1436            "file".to_string(),
1437            Value::String(resolved.to_string_lossy().into_owned()),
1438        );
1439    }
1440
1441    Ok(Translated {
1442        command: "outline".into(),
1443        args: out,
1444    })
1445}
1446
1447fn zoom_target_entry_is_empty(entry: &Value) -> bool {
1448    let Some(obj) = entry.as_object() else {
1449        return true;
1450    };
1451    let file_path_empty = obj
1452        .get("filePath")
1453        .and_then(Value::as_str)
1454        .is_none_or(str::is_empty);
1455    let symbol_empty = obj
1456        .get("symbol")
1457        .and_then(Value::as_str)
1458        .is_none_or(str::is_empty);
1459    file_path_empty && symbol_empty
1460}
1461
1462fn zoom_targets_provided(value: Option<&Value>) -> bool {
1463    let Some(value) = value else {
1464        return false;
1465    };
1466    if is_empty_param(value) {
1467        return false;
1468    }
1469    match value {
1470        Value::Array(items) => !items.iter().all(zoom_target_entry_is_empty),
1471        Value::Object(_) => !zoom_target_entry_is_empty(value),
1472        _ => false,
1473    }
1474}
1475
1476fn translate_zoom_targets(
1477    targets_value: &Value,
1478    project_root: &Path,
1479) -> Result<Vec<Value>, TranslateError> {
1480    let target_values: Vec<&Value> = match targets_value {
1481        Value::Array(items) => items.iter().collect(),
1482        Value::Object(_) => vec![targets_value],
1483        _ => {
1484            return Err(invalid_request(
1485                "'targets' must be a non-empty object or array",
1486            ))
1487        }
1488    };
1489
1490    if target_values.is_empty() {
1491        return Err(invalid_request(
1492            "'targets' must be a non-empty object or array",
1493        ));
1494    }
1495
1496    let mut out = Vec::with_capacity(target_values.len());
1497    for (index, target) in target_values.into_iter().enumerate() {
1498        let obj = target.as_object();
1499        let file_path = obj
1500            .and_then(|obj| obj.get("filePath"))
1501            .and_then(Value::as_str)
1502            .filter(|file_path| !file_path.is_empty())
1503            .ok_or_else(|| {
1504                invalid_request(format!(
1505                    "targets[{index}].filePath must be a non-empty string"
1506                ))
1507            })?;
1508        let symbol = obj
1509            .and_then(|obj| obj.get("symbol"))
1510            .and_then(Value::as_str)
1511            .filter(|symbol| !symbol.is_empty())
1512            .ok_or_else(|| {
1513                invalid_request(format!(
1514                    "targets[{index}].symbol must be a non-empty string"
1515                ))
1516            })?;
1517        let resolved = resolve_path_from_project_root(project_root, file_path);
1518        let mut target_out = Map::new();
1519        target_out.insert(
1520            "file".to_string(),
1521            Value::String(resolved.to_string_lossy().into_owned()),
1522        );
1523        target_out.insert("symbol".to_string(), Value::String(symbol.to_string()));
1524        target_out.insert(
1525            "target_label".to_string(),
1526            Value::String(file_path.to_string()),
1527        );
1528        out.push(Value::Object(target_out));
1529    }
1530    Ok(out)
1531}
1532
1533fn translate_zoom(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1534    let map_in = agent_args_map(args);
1535
1536    let has_targets = zoom_targets_provided(map_in.get("targets"));
1537    let has_file_path = map_in
1538        .get("filePath")
1539        .is_some_and(|value| !is_empty_param(value));
1540    let has_url = map_in
1541        .get("url")
1542        .is_some_and(|value| !is_empty_param(value));
1543    let has_symbols = map_in
1544        .get("symbols")
1545        .is_some_and(|value| !is_empty_param(value));
1546
1547    let mut out = Map::new();
1548
1549    if has_targets {
1550        if has_file_path || has_url || has_symbols {
1551            return Err(invalid_request(
1552                "'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'",
1553            ));
1554        }
1555        let targets_value = map_in
1556            .get("targets")
1557            .expect("has_targets implies a targets value exists");
1558        out.insert(
1559            "targets".to_string(),
1560            Value::Array(translate_zoom_targets(targets_value, project_root)?),
1561        );
1562
1563        if let Some(context_lines) = coerce_optional_int_result(
1564            map_in.get("contextLines"),
1565            "contextLines",
1566            1,
1567            9_007_199_254_740_991,
1568        )? {
1569            out.insert(
1570                "context_lines".to_string(),
1571                Value::Number(context_lines.into()),
1572            );
1573        }
1574
1575        if map_in.get("callgraph").is_some_and(coerce_boolean) {
1576            out.insert("callgraph".to_string(), Value::Bool(true));
1577        }
1578
1579        return Ok(Translated {
1580            command: "zoom".into(),
1581            args: out,
1582        });
1583    }
1584
1585    let file_path = map_in
1586        .get("filePath")
1587        .and_then(Value::as_str)
1588        .filter(|s| !s.is_empty());
1589    let url = map_in
1590        .get("url")
1591        .and_then(Value::as_str)
1592        .filter(|s| !s.is_empty());
1593
1594    match (file_path, url) {
1595        (None, None) => {
1596            return Err(invalid_request(
1597                "Provide exactly one of 'filePath', 'url', or 'targets'",
1598            ));
1599        }
1600        (Some(_), Some(_)) => {
1601            return Err(invalid_request(
1602                "Provide exactly ONE of 'filePath' or 'url' — not both",
1603            ));
1604        }
1605        _ => {}
1606    }
1607
1608    if let Some(url) = url {
1609        out.insert("file".to_string(), Value::String(url.to_string()));
1610    } else if let Some(file_path) = file_path {
1611        insert_resolved_file(&mut out, project_root, file_path);
1612    }
1613
1614    if let Some(symbols) = map_in.get("symbols") {
1615        if !is_empty_param(symbols) {
1616            match symbols {
1617                Value::String(symbol) => {
1618                    out.insert("symbol".to_string(), Value::String(symbol.to_string()));
1619                }
1620                Value::Array(items) => {
1621                    // Pass the array THROUGH to the leaf (handle_zoom's
1622                    // parse_zoom_symbol_names handles a `symbols` array natively,
1623                    // one lookup per element). Joining into one space-separated
1624                    // string would break multi-heading markdown/HTML zoom, whose
1625                    // heading names legitimately contain spaces.
1626                    let names: Vec<Value> = items
1627                        .iter()
1628                        .filter_map(Value::as_str)
1629                        .filter(|name| !name.is_empty())
1630                        .map(|name| Value::String(name.to_string()))
1631                        .collect();
1632                    if !names.is_empty() {
1633                        out.insert("symbols".to_string(), Value::Array(names));
1634                    }
1635                }
1636                _ => {
1637                    return Err(invalid_request(
1638                        "'symbols' must be a string or array of strings",
1639                    ))
1640                }
1641            }
1642        }
1643    }
1644
1645    if let Some(context_lines) = coerce_optional_int_result(
1646        map_in.get("contextLines"),
1647        "contextLines",
1648        1,
1649        9_007_199_254_740_991,
1650    )? {
1651        out.insert(
1652            "context_lines".to_string(),
1653            Value::Number(context_lines.into()),
1654        );
1655    }
1656
1657    if map_in.get("callgraph").is_some_and(coerce_boolean) {
1658        out.insert("callgraph".to_string(), Value::Bool(true));
1659    }
1660
1661    Ok(Translated {
1662        command: "zoom".into(),
1663        args: out,
1664    })
1665}
1666
1667fn translate_conflicts(args: Value) -> Result<Translated, TranslateError> {
1668    let map_in = agent_args_map(args);
1669    let mut out = Map::new();
1670    if let Some(path_val) = map_in.get("path") {
1671        if !is_empty_param(path_val) {
1672            if let Some(path_str) = path_val.as_str() {
1673                out.insert("path".to_string(), Value::String(path_str.to_string()));
1674            }
1675        }
1676    }
1677
1678    Ok(Translated {
1679        command: "git_conflicts".into(),
1680        args: out,
1681    })
1682}
1683
1684fn translate_inspect(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1685    let map_in = agent_args_map(args);
1686    let mut out = Map::new();
1687
1688    if let Some(sections) = map_in.get("sections") {
1689        if !is_empty_param(sections) {
1690            out.insert("sections".to_string(), sections.clone());
1691        }
1692    }
1693
1694    if let Some(scope) = map_in.get("scope") {
1695        if !is_empty_param(scope) {
1696            match scope {
1697                Value::String(s) if !s.is_empty() => {
1698                    let resolved = resolve_path_from_project_root(project_root, s);
1699                    out.insert(
1700                        "scope".to_string(),
1701                        Value::String(resolved.to_string_lossy().into_owned()),
1702                    );
1703                }
1704                Value::Array(arr) => {
1705                    let resolved: Vec<Value> = arr
1706                        .iter()
1707                        .filter_map(|v| v.as_str())
1708                        .map(|entry| {
1709                            let p = resolve_path_from_project_root(project_root, entry);
1710                            Value::String(p.to_string_lossy().into_owned())
1711                        })
1712                        .collect();
1713                    out.insert("scope".to_string(), Value::Array(resolved));
1714                }
1715                other => {
1716                    out.insert("scope".to_string(), other.clone());
1717                }
1718            }
1719        }
1720    }
1721
1722    if let Some(top_k) = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)? {
1723        out.insert("topK".to_string(), Value::Number(top_k.into()));
1724    }
1725
1726    Ok(Translated {
1727        command: "inspect".into(),
1728        args: out,
1729    })
1730}
1731
1732#[cfg(test)]
1733mod tests {
1734    use super::*;
1735
1736    #[test]
1737    fn owned_write_translation_moves_content_buffer() {
1738        let content = "x".repeat(256 * 1024);
1739        let content_ptr = content.as_ptr();
1740        let content_len = content.len();
1741        let mut arguments = Map::new();
1742        arguments.insert(
1743            "filePath".to_string(),
1744            Value::String("src/generated.ts".to_string()),
1745        );
1746        arguments.insert("content".to_string(), Value::String(content));
1747
1748        let translated =
1749            subc_translate_owned("write", Value::Object(arguments), Path::new("/project"))
1750                .expect("write translation succeeds");
1751        let translated_content = translated
1752            .args
1753            .get("content")
1754            .and_then(Value::as_str)
1755            .expect("translated write keeps content");
1756
1757        assert_eq!(translated_content.len(), content_len);
1758        assert_eq!(translated_content.as_ptr(), content_ptr);
1759    }
1760
1761    // supports_tool() gates whether run_tool_call translates or passes a name
1762    // through as a native command. If a translate arm is added but the
1763    // allowlist isn't updated, that tool would silently bypass translation and
1764    // dispatch as a raw native command — this proves the two sets agree.
1765    #[test]
1766    fn supports_tool_covers_every_translated_arm() {
1767        for name in [
1768            "bash",
1769            "status",
1770            "read",
1771            "write",
1772            "edit",
1773            "apply_patch",
1774            "grep",
1775            "glob",
1776            "search",
1777            "outline",
1778            "zoom",
1779            "inspect",
1780            "callgraph",
1781            "conflicts",
1782            "ast_search",
1783            "ast_replace",
1784            "delete",
1785            "move",
1786            "import",
1787            "refactor",
1788            "safety",
1789        ] {
1790            // Every name the allowlist claims support for must actually
1791            // translate (not return unsupported_tool). A no-arg call may fail
1792            // validation, but it must never be unsupported_tool.
1793            let err =
1794                subc_translate_owned(name, Value::Object(Map::new()), Path::new("/project")).err();
1795            assert_ne!(
1796                err.as_ref().map(|e| e.code),
1797                Some("unsupported_tool"),
1798                "{name} is in supports_tool but has no translate arm"
1799            );
1800            assert!(
1801                supports_tool(name),
1802                "{name} translates but is missing from supports_tool"
1803            );
1804        }
1805        // A name that is not a tool must be rejected by both.
1806        assert!(!supports_tool("definitely_not_a_tool"));
1807    }
1808}