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