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