Skip to main content

aft/
subc_translate.rs

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