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