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