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, project_root),
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, project_root: &Path) -> 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 resolve_path = |value: &Value| -> Result<Value, TranslateError> {
1014        let path = value
1015            .as_str()
1016            .filter(|path| !path.is_empty())
1017            .ok_or_else(|| invalid_request("aft_safety: paths must be non-empty strings"))?;
1018        Ok(Value::String(
1019            resolve_path_from_project_root(project_root, path)
1020                .to_string_lossy()
1021                .into_owned(),
1022        ))
1023    };
1024
1025    let mut out = Map::new();
1026    insert_present_renamed(&mut out, &map_in, "name", "name");
1027    let files = map_in
1028        .get("files")
1029        .and_then(Value::as_array)
1030        .filter(|items| !items.is_empty())
1031        .map(|items| {
1032            items
1033                .iter()
1034                .map(resolve_path)
1035                .collect::<Result<Vec<_>, _>>()
1036        })
1037        .transpose()?;
1038
1039    if op == "checkpoint" {
1040        if let Some(files) = files {
1041            out.insert("files".to_string(), Value::Array(files));
1042        } else if let Some(file_path) = map_in.get("filePath") {
1043            out.insert(
1044                "files".to_string(),
1045                Value::Array(vec![resolve_path(file_path)?]),
1046            );
1047        }
1048    } else {
1049        if let Some(file_path) = map_in.get("filePath") {
1050            out.insert("file".to_string(), resolve_path(file_path)?);
1051        }
1052        if let Some(files) = files {
1053            out.insert("files".to_string(), Value::Array(files));
1054        }
1055    }
1056
1057    Ok(Translated {
1058        command: command.into(),
1059        args: out,
1060    })
1061}
1062
1063fn insert_non_empty_array(out: &mut Map<String, Value>, map_in: &Map<String, Value>, key: &str) {
1064    if let Some(value) = map_in.get(key) {
1065        if let Some(items) = value.as_array() {
1066            if !items.is_empty() {
1067                out.insert(key.to_string(), Value::Array(items.clone()));
1068            }
1069        }
1070    }
1071}
1072
1073fn translate_glob(args: &Value) -> Result<Translated, TranslateError> {
1074    let map_in = agent_args_map(args);
1075    let pattern = map_in
1076        .get("pattern")
1077        .and_then(Value::as_str)
1078        .filter(|s| !s.is_empty())
1079        .ok_or_else(|| invalid_request("glob: missing required param 'pattern'"))?;
1080
1081    let mut out = Map::new();
1082    out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1083    if let Some(path_val) = map_in.get("path") {
1084        if !is_empty_param(path_val) {
1085            if let Some(path_str) = path_val.as_str() {
1086                out.insert("path".to_string(), Value::String(path_str.to_string()));
1087            }
1088        }
1089    }
1090
1091    Ok(Translated {
1092        command: "glob".into(),
1093        args: out,
1094    })
1095}
1096
1097fn normalize_glob(pattern: &str) -> String {
1098    if !pattern.contains('/') && !pattern.starts_with("**/") {
1099        format!("**/{pattern}")
1100    } else {
1101        pattern.to_string()
1102    }
1103}
1104
1105fn split_include_arg(raw: &str) -> Vec<String> {
1106    let mut out = Vec::new();
1107    let mut depth = 0usize;
1108    let mut buf = String::new();
1109    for ch in raw.chars() {
1110        match ch {
1111            '{' => {
1112                depth += 1;
1113                buf.push(ch);
1114            }
1115            '}' => {
1116                depth = depth.saturating_sub(1);
1117                buf.push(ch);
1118            }
1119            ',' if depth == 0 => {
1120                let trimmed = buf.trim();
1121                if !trimmed.is_empty() {
1122                    out.push(trimmed.to_string());
1123                }
1124                buf.clear();
1125            }
1126            _ => buf.push(ch),
1127        }
1128    }
1129    let trimmed = buf.trim();
1130    if !trimmed.is_empty() {
1131        out.push(trimmed.to_string());
1132    }
1133    out
1134}
1135
1136fn search_path_exists(project_root: &Path, raw: &str) -> bool {
1137    resolve_path_from_project_root(project_root, raw).exists()
1138}
1139
1140fn split_search_path_arg(project_root: &Path, raw: &str) -> Vec<String> {
1141    if search_path_exists(project_root, raw) || !raw.chars().any(char::is_whitespace) {
1142        return vec![raw.to_string()];
1143    }
1144
1145    let fragments = raw
1146        .split_whitespace()
1147        .filter(|fragment| !fragment.is_empty())
1148        .collect::<Vec<_>>();
1149    if fragments.len() < 2 {
1150        return vec![raw.to_string()];
1151    }
1152
1153    let existing = fragments
1154        .iter()
1155        .filter(|fragment| search_path_exists(project_root, fragment))
1156        .map(|fragment| (*fragment).to_string())
1157        .collect::<Vec<_>>();
1158    if existing.is_empty() {
1159        vec![raw.to_string()]
1160    } else {
1161        existing
1162    }
1163}
1164
1165fn resolve_grep_path_arg(project_root: &Path, raw: &str) -> String {
1166    split_search_path_arg(project_root, raw)
1167        .iter()
1168        .map(|target| {
1169            resolve_path_from_project_root(project_root, target)
1170                .to_string_lossy()
1171                .into_owned()
1172        })
1173        .collect::<Vec<_>>()
1174        .join(" ")
1175}
1176
1177fn translate_search(args: &Value) -> Result<Translated, TranslateError> {
1178    let map_in = agent_args_map(args);
1179    let query = map_in
1180        .get("query")
1181        .and_then(Value::as_str)
1182        .filter(|s| !s.trim().is_empty())
1183        .ok_or_else(|| {
1184            invalid_request("semantic_search: invalid params: `query` must be a non-empty string")
1185        })?;
1186
1187    let mut out = Map::new();
1188    out.insert("query".to_string(), Value::String(query.to_string()));
1189    let top_k = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)?.unwrap_or(10);
1190    out.insert("top_k".to_string(), Value::Number(top_k.into()));
1191    if let Some(hint) = map_in.get("hint") {
1192        if !is_empty_param(hint) {
1193            out.insert("hint".to_string(), hint.clone());
1194        }
1195    }
1196    if let Some(include_tests) = map_in.get("includeTests").and_then(Value::as_bool) {
1197        out.insert("include_tests".to_string(), Value::Bool(include_tests));
1198    }
1199    if let Some(path) = map_in
1200        .get("path")
1201        .and_then(Value::as_str)
1202        .map(str::trim)
1203        .filter(|path| !path.is_empty())
1204    {
1205        out.insert("path".to_string(), Value::String(path.to_string()));
1206    }
1207
1208    Ok(Translated {
1209        command: "semantic_search".into(),
1210        args: out,
1211    })
1212}
1213
1214fn translate_outline(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
1215    let map_in = agent_args_map(args);
1216    let files_flag = map_in
1217        .get("files")
1218        .and_then(Value::as_bool)
1219        .unwrap_or(false);
1220
1221    let target = map_in
1222        .get("target")
1223        .ok_or_else(|| invalid_request("outline: missing required param 'target'"))?;
1224
1225    if is_empty_param(target) {
1226        return Err(invalid_request(
1227            "'target' must be a non-empty string or array of strings",
1228        ));
1229    }
1230
1231    let mut out = Map::new();
1232    if let Some(include_tests) = map_in
1233        .get("includeTests")
1234        .or_else(|| map_in.get("include_tests"))
1235        .and_then(Value::as_bool)
1236    {
1237        out.insert("includeTests".to_string(), Value::Bool(include_tests));
1238    }
1239
1240    if let Some(arr) = target.as_array() {
1241        if arr.is_empty() {
1242            return Err(invalid_request(
1243                "'target' must be a non-empty string or array of strings",
1244            ));
1245        }
1246        if files_flag {
1247            let resolved: Vec<Value> = arr
1248                .iter()
1249                .filter_map(|v| v.as_str())
1250                .map(|entry| {
1251                    let p = resolve_path_from_project_root(project_root, entry);
1252                    Value::String(p.to_string_lossy().into_owned())
1253                })
1254                .collect();
1255            out.insert("target".to_string(), Value::Array(resolved));
1256            out.insert("files".to_string(), Value::Bool(true));
1257            return Ok(Translated {
1258                command: "outline".into(),
1259                args: out,
1260            });
1261        }
1262        let resolved: Vec<Value> = arr
1263            .iter()
1264            .filter_map(|v| v.as_str())
1265            .map(|entry| {
1266                let p = resolve_path_from_project_root(project_root, entry);
1267                Value::String(p.to_string_lossy().into_owned())
1268            })
1269            .collect();
1270        out.insert("files".to_string(), Value::Array(resolved));
1271        return Ok(Translated {
1272            command: "outline".into(),
1273            args: out,
1274        });
1275    }
1276
1277    if let Some(url) = target.as_str() {
1278        if !files_flag && (url.starts_with("http://") || url.starts_with("https://")) {
1279            out.insert("file".to_string(), Value::String(url.to_string()));
1280            return Ok(Translated {
1281                command: "outline".into(),
1282                args: out,
1283            });
1284        }
1285    }
1286
1287    let target_str = target.as_str().ok_or_else(|| {
1288        invalid_request("'target' must be a non-empty string or array of strings")
1289    })?;
1290
1291    let resolved = resolve_path_from_project_root(project_root, target_str);
1292    let is_dir = std::fs::metadata(&resolved)
1293        .map(|m| m.is_dir())
1294        .unwrap_or(false);
1295
1296    if files_flag {
1297        if is_dir {
1298            out.insert(
1299                "directory".to_string(),
1300                Value::String(resolved.to_string_lossy().into_owned()),
1301            );
1302        } else {
1303            out.insert(
1304                "file".to_string(),
1305                Value::String(resolved.to_string_lossy().into_owned()),
1306            );
1307        }
1308        out.insert("files".to_string(), Value::Bool(true));
1309    } else if is_dir {
1310        out.insert(
1311            "directory".to_string(),
1312            Value::String(resolved.to_string_lossy().into_owned()),
1313        );
1314    } else {
1315        out.insert(
1316            "file".to_string(),
1317            Value::String(resolved.to_string_lossy().into_owned()),
1318        );
1319    }
1320
1321    Ok(Translated {
1322        command: "outline".into(),
1323        args: out,
1324    })
1325}
1326
1327fn zoom_target_entry_is_empty(entry: &Value) -> bool {
1328    let Some(obj) = entry.as_object() else {
1329        return true;
1330    };
1331    let file_path_empty = obj
1332        .get("filePath")
1333        .and_then(Value::as_str)
1334        .is_none_or(str::is_empty);
1335    let symbol_empty = obj
1336        .get("symbol")
1337        .and_then(Value::as_str)
1338        .is_none_or(str::is_empty);
1339    file_path_empty && symbol_empty
1340}
1341
1342fn zoom_targets_provided(value: Option<&Value>) -> bool {
1343    let Some(value) = value else {
1344        return false;
1345    };
1346    if is_empty_param(value) {
1347        return false;
1348    }
1349    match value {
1350        Value::Array(items) => !items.iter().all(zoom_target_entry_is_empty),
1351        Value::Object(_) => !zoom_target_entry_is_empty(value),
1352        _ => false,
1353    }
1354}
1355
1356fn translate_zoom_targets(
1357    targets_value: &Value,
1358    project_root: &Path,
1359) -> Result<Vec<Value>, TranslateError> {
1360    let target_values: Vec<&Value> = match targets_value {
1361        Value::Array(items) => items.iter().collect(),
1362        Value::Object(_) => vec![targets_value],
1363        _ => {
1364            return Err(invalid_request(
1365                "'targets' must be a non-empty object or array",
1366            ))
1367        }
1368    };
1369
1370    if target_values.is_empty() {
1371        return Err(invalid_request(
1372            "'targets' must be a non-empty object or array",
1373        ));
1374    }
1375
1376    let mut out = Vec::with_capacity(target_values.len());
1377    for (index, target) in target_values.into_iter().enumerate() {
1378        let obj = target.as_object();
1379        let file_path = obj
1380            .and_then(|obj| obj.get("filePath"))
1381            .and_then(Value::as_str)
1382            .filter(|file_path| !file_path.is_empty())
1383            .ok_or_else(|| {
1384                invalid_request(format!(
1385                    "targets[{index}].filePath must be a non-empty string"
1386                ))
1387            })?;
1388        let symbol = obj
1389            .and_then(|obj| obj.get("symbol"))
1390            .and_then(Value::as_str)
1391            .filter(|symbol| !symbol.is_empty())
1392            .ok_or_else(|| {
1393                invalid_request(format!(
1394                    "targets[{index}].symbol must be a non-empty string"
1395                ))
1396            })?;
1397        let resolved = resolve_path_from_project_root(project_root, file_path);
1398        let mut target_out = Map::new();
1399        target_out.insert(
1400            "file".to_string(),
1401            Value::String(resolved.to_string_lossy().into_owned()),
1402        );
1403        target_out.insert("symbol".to_string(), Value::String(symbol.to_string()));
1404        target_out.insert(
1405            "target_label".to_string(),
1406            Value::String(file_path.to_string()),
1407        );
1408        out.push(Value::Object(target_out));
1409    }
1410    Ok(out)
1411}
1412
1413fn translate_zoom(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
1414    let map_in = agent_args_map(args);
1415
1416    let has_targets = zoom_targets_provided(map_in.get("targets"));
1417    let has_file_path = map_in
1418        .get("filePath")
1419        .is_some_and(|value| !is_empty_param(value));
1420    let has_url = map_in
1421        .get("url")
1422        .is_some_and(|value| !is_empty_param(value));
1423    let has_symbols = map_in
1424        .get("symbols")
1425        .is_some_and(|value| !is_empty_param(value));
1426
1427    let mut out = Map::new();
1428
1429    if has_targets {
1430        if has_file_path || has_url || has_symbols {
1431            return Err(invalid_request(
1432                "'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'",
1433            ));
1434        }
1435        let targets_value = map_in
1436            .get("targets")
1437            .expect("has_targets implies a targets value exists");
1438        out.insert(
1439            "targets".to_string(),
1440            Value::Array(translate_zoom_targets(targets_value, project_root)?),
1441        );
1442
1443        if let Some(context_lines) = coerce_optional_int_result(
1444            map_in.get("contextLines"),
1445            "contextLines",
1446            1,
1447            9_007_199_254_740_991,
1448        )? {
1449            out.insert(
1450                "context_lines".to_string(),
1451                Value::Number(context_lines.into()),
1452            );
1453        }
1454
1455        if map_in.get("callgraph").is_some_and(coerce_boolean) {
1456            out.insert("callgraph".to_string(), Value::Bool(true));
1457        }
1458
1459        return Ok(Translated {
1460            command: "zoom".into(),
1461            args: out,
1462        });
1463    }
1464
1465    let file_path = map_in
1466        .get("filePath")
1467        .and_then(Value::as_str)
1468        .filter(|s| !s.is_empty());
1469    let url = map_in
1470        .get("url")
1471        .and_then(Value::as_str)
1472        .filter(|s| !s.is_empty());
1473
1474    match (file_path, url) {
1475        (None, None) => {
1476            return Err(invalid_request(
1477                "Provide exactly one of 'filePath', 'url', or 'targets'",
1478            ));
1479        }
1480        (Some(_), Some(_)) => {
1481            return Err(invalid_request(
1482                "Provide exactly ONE of 'filePath' or 'url' — not both",
1483            ));
1484        }
1485        _ => {}
1486    }
1487
1488    if let Some(url) = url {
1489        out.insert("file".to_string(), Value::String(url.to_string()));
1490    } else if let Some(file_path) = file_path {
1491        insert_resolved_file(&mut out, project_root, file_path);
1492    }
1493
1494    if let Some(symbols) = map_in.get("symbols") {
1495        if !is_empty_param(symbols) {
1496            match symbols {
1497                Value::String(symbol) => {
1498                    out.insert("symbol".to_string(), Value::String(symbol.to_string()));
1499                }
1500                Value::Array(items) => {
1501                    // Pass the array THROUGH to the leaf (handle_zoom's
1502                    // parse_zoom_symbol_names handles a `symbols` array natively,
1503                    // one lookup per element). Joining into one space-separated
1504                    // string would break multi-heading markdown/HTML zoom, whose
1505                    // heading names legitimately contain spaces.
1506                    let names: Vec<Value> = items
1507                        .iter()
1508                        .filter_map(Value::as_str)
1509                        .filter(|name| !name.is_empty())
1510                        .map(|name| Value::String(name.to_string()))
1511                        .collect();
1512                    if !names.is_empty() {
1513                        out.insert("symbols".to_string(), Value::Array(names));
1514                    }
1515                }
1516                _ => {
1517                    return Err(invalid_request(
1518                        "'symbols' must be a string or array of strings",
1519                    ))
1520                }
1521            }
1522        }
1523    }
1524
1525    if let Some(context_lines) = coerce_optional_int_result(
1526        map_in.get("contextLines"),
1527        "contextLines",
1528        1,
1529        9_007_199_254_740_991,
1530    )? {
1531        out.insert(
1532            "context_lines".to_string(),
1533            Value::Number(context_lines.into()),
1534        );
1535    }
1536
1537    if map_in.get("callgraph").is_some_and(coerce_boolean) {
1538        out.insert("callgraph".to_string(), Value::Bool(true));
1539    }
1540
1541    Ok(Translated {
1542        command: "zoom".into(),
1543        args: out,
1544    })
1545}
1546
1547fn translate_conflicts(args: &Value) -> Result<Translated, TranslateError> {
1548    let map_in = agent_args_map(args);
1549    let mut out = Map::new();
1550    if let Some(path_val) = map_in.get("path") {
1551        if !is_empty_param(path_val) {
1552            if let Some(path_str) = path_val.as_str() {
1553                out.insert("path".to_string(), Value::String(path_str.to_string()));
1554            }
1555        }
1556    }
1557
1558    Ok(Translated {
1559        command: "git_conflicts".into(),
1560        args: out,
1561    })
1562}
1563
1564fn translate_inspect(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
1565    let map_in = agent_args_map(args);
1566    let mut out = Map::new();
1567
1568    if let Some(sections) = map_in.get("sections") {
1569        if !is_empty_param(sections) {
1570            out.insert("sections".to_string(), sections.clone());
1571        }
1572    }
1573
1574    if let Some(scope) = map_in.get("scope") {
1575        if !is_empty_param(scope) {
1576            match scope {
1577                Value::String(s) if !s.is_empty() => {
1578                    let resolved = resolve_path_from_project_root(project_root, s);
1579                    out.insert(
1580                        "scope".to_string(),
1581                        Value::String(resolved.to_string_lossy().into_owned()),
1582                    );
1583                }
1584                Value::Array(arr) => {
1585                    let resolved: Vec<Value> = arr
1586                        .iter()
1587                        .filter_map(|v| v.as_str())
1588                        .map(|entry| {
1589                            let p = resolve_path_from_project_root(project_root, entry);
1590                            Value::String(p.to_string_lossy().into_owned())
1591                        })
1592                        .collect();
1593                    out.insert("scope".to_string(), Value::Array(resolved));
1594                }
1595                other => {
1596                    out.insert("scope".to_string(), other.clone());
1597                }
1598            }
1599        }
1600    }
1601
1602    if let Some(top_k) = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)? {
1603        out.insert("topK".to_string(), Value::Number(top_k.into()));
1604    }
1605
1606    Ok(Translated {
1607        command: "inspect".into(),
1608        args: out,
1609    })
1610}