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