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