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