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 path_string<'a>(value: Option<&'a Value>, property: &str) -> Result<&'a str, TranslateError> {
36 value
37 .and_then(Value::as_str)
38 .filter(|value| !value.is_empty())
39 .ok_or_else(|| {
40 invalid_request(format!(
41 "'{property}' must be a non-empty well-formed Unicode string"
42 ))
43 })
44}
45
46fn normalize_path_alias_pair(
47 map: &mut Map<String, Value>,
48 canonical: &str,
49 legacy: &str,
50 required: bool,
51) -> Result<(), TranslateError> {
52 let has_canonical = map.contains_key(canonical);
53 let has_legacy = map.contains_key(legacy);
54 if !has_canonical && !has_legacy {
55 if required {
56 return Err(invalid_request(format!("'{canonical}' is required")));
57 }
58 return Ok(());
59 }
60
61 if has_canonical && has_legacy {
62 let canonical_value = path_string(map.get(canonical), canonical).map(str::to_owned);
63 let legacy_value = path_string(map.get(legacy), legacy).map(str::to_owned);
64 let (Ok(canonical_value), Ok(legacy_value)) = (canonical_value, legacy_value) else {
65 return Err(invalid_request(format!(
66 "Invalid request: '{canonical}' and '{legacy}' must both be non-empty well-formed Unicode strings"
67 )));
68 };
69 if canonical_value != legacy_value {
70 return Err(invalid_request(format!(
71 "Invalid request: '{canonical}' and '{legacy}' must contain equal decoded strings"
72 )));
73 }
74 map.remove(legacy);
75 return Ok(());
76 }
77
78 if has_canonical {
79 path_string(map.get(canonical), canonical)?;
80 } else if let Ok(legacy_value) = path_string(map.get(legacy), legacy) {
81 map.insert(
82 canonical.to_string(),
83 Value::String(legacy_value.to_string()),
84 );
85 map.remove(legacy);
86 } else {
87 path_string(map.get(legacy), legacy)?;
88 }
89 Ok(())
90}
91
92fn normalize_zoom_target_aliases(target: &mut Value, index: usize) -> Result<(), TranslateError> {
93 let Some(object) = target.as_object_mut() else {
94 return Err(invalid_request(format!(
95 "'targets[{index}].path' must be a non-empty string"
96 )));
97 };
98 normalize_path_alias_pair(object, "path", "filePath", true)
99}
100
101fn normalize_zoom_aliases(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
102 normalize_path_alias_pair(map, "path", "filePath", false)?;
103 let Some(targets) = map.get_mut("targets") else {
104 return Ok(());
105 };
106 match targets {
107 Value::Array(items) => {
108 for (index, target) in items.iter_mut().enumerate() {
109 normalize_zoom_target_aliases(target, index)?;
110 }
111 }
112 Value::Object(_) => normalize_zoom_target_aliases(targets, 0)?,
113 _ => {}
114 }
115 Ok(())
116}
117
118fn normalize_path_arguments(bare_name: &str, args: Value) -> Result<Value, TranslateError> {
119 let mut map = match args {
120 Value::Object(map) => map,
121 _ => return Err(invalid_request("tool arguments must be an object")),
122 };
123
124 match bare_name {
125 "read" | "write" | "move" | "import" => {
126 normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
127 }
128 "edit" => normalize_edit_arguments(&mut map)?,
129 "refactor" => {
130 normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
131 }
132 "zoom" => normalize_zoom_aliases(&mut map)?,
133 "callgraph" => {
134 normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
135 normalize_path_alias_pair(&mut map, "toPath", "toFile", false)?;
136 }
137 "safety" => normalize_path_alias_pair(&mut map, "path", "filePath", false)?,
138 "grep" | "search" | "conflicts" => {
139 if map.contains_key("path") {
140 path_string(map.get("path"), "path")?;
141 }
142 }
143 _ => {}
144 }
145
146 Ok(Value::Object(map))
147}
148
149fn normalize_edit_arguments(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
150 normalize_edit_path_alias(map)?;
151
152 let supplied_line_fields = ["startLine", "endLine"]
153 .into_iter()
154 .filter(|key| map.contains_key(*key))
155 .collect::<Vec<_>>();
156 if !supplied_line_fields.is_empty() {
157 let fields = supplied_line_fields
158 .iter()
159 .map(|field| format!("'{field}'"))
160 .collect::<Vec<_>>()
161 .join(" and ");
162 return Err(invalid_request(format!(
163 "edit: top-level {fields} are invalid; line-range fields are valid only inside 'edits[]'. Use edits: [{{ startLine, endLine, content }}]."
164 )));
165 }
166
167 let unknown_root_keys = map
168 .keys()
169 .filter(|key| {
170 !matches!(
171 key.as_str(),
172 "path"
173 | "filePath"
174 | "appendContent"
175 | "edits"
176 | "symbol"
177 | "content"
178 | "oldString"
179 | "newString"
180 | "replaceAll"
181 | "occurrence"
182 )
183 })
184 .cloned()
185 .collect::<Vec<_>>();
186 if !unknown_root_keys.is_empty() {
187 return Err(invalid_request(format_unknown_keys(unknown_root_keys)));
188 }
189
190 let modes = edit_modes_present(map);
191 if modes.len() > 1 {
192 return Err(invalid_request(format!(
193 "edit: conflicting modes: {}",
194 modes.join(", ")
195 )));
196 }
197 let Some(mode) = modes.first().copied() else {
198 return Err(invalid_request(
199 "edit: exactly one of `appendContent`, `edits`, or `symbol` plus `content` is required",
200 ));
201 };
202
203 match mode {
204 "appendContent" => {
205 if !matches!(map.get("appendContent"), Some(Value::String(_))) {
206 return Err(invalid_request("edit: 'appendContent' must be a string"));
207 }
208 }
209 "edits" => {
210 let items = parse_edit_array(map.remove("edits"))?;
211 let normalized = items
212 .into_iter()
213 .enumerate()
214 .map(|(index, item)| normalize_edit_item(item, index))
215 .collect::<Result<Vec<_>, _>>()?;
216 map.insert(
217 "edits".to_string(),
218 Value::Array(normalized.into_iter().map(Value::Object).collect()),
219 );
220 }
221 "symbol/content" => {
222 if !matches!(map.get("symbol"), Some(Value::String(_))) {
223 return Err(invalid_request(
224 "edit: 'symbol' must be a string when symbol mode is selected",
225 ));
226 }
227 if !matches!(map.get("content"), Some(Value::String(_))) {
228 return Err(invalid_request(
229 "edit: symbol mode requires both 'symbol' and 'content' string properties",
230 ));
231 }
232 }
233 "oldString/newString" => {
234 let mut item = Map::new();
235 for key in ["oldString", "newString", "replaceAll", "occurrence"] {
236 if let Some(value) = map.get(key) {
237 item.insert(key.to_string(), value.clone());
238 }
239 map.remove(key);
240 }
241 let normalized = normalize_edit_item(Value::Object(item), 0)?;
242 map.insert(
243 "edits".to_string(),
244 Value::Array(vec![Value::Object(normalized)]),
245 );
246 }
247 _ => unreachable!("edit mode list contains an unknown mode"),
248 }
249
250 let path = map
251 .get("path")
252 .ok_or_else(|| invalid_request("'path' is required"))?;
253 path_string(Some(path), "path")?;
254 Ok(())
255}
256
257fn normalize_edit_path_alias(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
258 let has_path = map.contains_key("path");
259 let has_file_path = map.contains_key("filePath");
260 match (has_path, has_file_path) {
261 (true, true) => normalize_path_alias_pair(map, "path", "filePath", false),
262 (false, true) => normalize_path_alias_pair(map, "path", "filePath", false),
263 (false, false) | (true, false) => Ok(()),
264 }
265}
266
267fn edit_modes_present(map: &Map<String, Value>) -> Vec<&'static str> {
268 let mut modes = Vec::new();
269 if map.contains_key("appendContent") {
270 modes.push("appendContent");
271 }
272 if map.contains_key("edits") {
273 modes.push("edits");
274 }
275 if map.contains_key("symbol") || map.contains_key("content") {
276 modes.push("symbol/content");
277 }
278 if ["oldString", "newString", "replaceAll", "occurrence"]
279 .iter()
280 .any(|key| map.contains_key(*key))
281 {
282 modes.push("oldString/newString");
283 }
284 modes
285}
286
287fn format_unknown_keys(mut keys: Vec<String>) -> String {
288 keys.sort();
289 format!(
290 "Unrecognized keys: {}",
291 keys.iter()
292 .map(|key| format!("\"{key}\""))
293 .collect::<Vec<_>>()
294 .join(", ")
295 )
296}
297
298fn parse_edit_array(value: Option<Value>) -> Result<Vec<Value>, TranslateError> {
299 let Some(value) = value else {
300 return Err(invalid_request("edit: 'edits' must be a non-empty array"));
301 };
302 let value = if let Value::String(raw) = value {
303 serde_json::from_str::<Value>(&raw).map_err(|_| {
304 invalid_request("edit: 'edits' must contain valid JSON representing an array")
305 })?
306 } else {
307 value
308 };
309 let Value::Array(items) = value else {
310 return Err(invalid_request(
311 "edit: 'edits' JSON must have an array root",
312 ));
313 };
314 if items.is_empty() {
315 return Err(invalid_request("edit: 'edits' array must not be empty"));
316 }
317 Ok(items)
318}
319
320fn normalize_edit_item(value: Value, index: usize) -> Result<Map<String, Value>, TranslateError> {
321 let Value::Object(mut item) = value else {
322 return Err(invalid_request(format!(
323 "edit: edits[{index}] must be an object"
324 )));
325 };
326
327 normalize_item_alias(&mut item, "oldString", "oldText");
328 normalize_item_alias(&mut item, "newString", "newText");
329
330 let has_find = ["oldString", "newString", "replaceAll", "occurrence"]
331 .iter()
332 .any(|key| item.contains_key(*key));
333 let has_range = ["startLine", "endLine", "content"]
334 .iter()
335 .any(|key| item.contains_key(*key));
336 if has_find && has_range {
337 return Err(invalid_request(format!(
338 "edit: edits[{index}] mixes find/replace and line-range fields"
339 )));
340 }
341
342 if has_find {
343 if !matches!(item.get("oldString"), Some(Value::String(_))) {
344 return Err(invalid_request(format!(
345 "edit: edits[{index}] requires string 'oldString'"
346 )));
347 }
348 if item.contains_key("newString")
349 && !matches!(item.get("newString"), Some(Value::String(_)))
350 {
351 return Err(invalid_request(format!(
352 "edit: edits[{index}].newString must be a string"
353 )));
354 }
355 coerce_edit_scalars(&mut item, index)?;
356 validate_edit_item_keys(&item, index)?;
357 return Ok(item);
358 }
359
360 if has_range {
361 for key in ["startLine", "endLine"] {
362 let valid = item
363 .get(key)
364 .and_then(Value::as_u64)
365 .is_some_and(|value| value >= 1 && value <= MAX_SAFE_INTEGER as u64);
366 if !valid {
367 return Err(invalid_request(format!(
368 "edit: edits[{index}].{key} must be a positive integer"
369 )));
370 }
371 }
372 let start = item.get("startLine").and_then(Value::as_u64).unwrap();
373 let end = item.get("endLine").and_then(Value::as_u64).unwrap();
374 if start > end {
375 return Err(invalid_request(format!(
376 "edit: edits[{index}] requires startLine <= endLine"
377 )));
378 }
379 if !matches!(item.get("content"), Some(Value::String(_))) {
380 return Err(invalid_request(format!(
381 "edit: edits[{index}] requires string 'content'"
382 )));
383 }
384 validate_edit_item_keys(&item, index)?;
385 return Ok(item);
386 }
387
388 Err(invalid_request(format!(
389 "edit: edits[{index}] must be a find/replace or line-range item"
390 )))
391}
392
393fn normalize_item_alias(item: &mut Map<String, Value>, canonical: &str, legacy: &str) {
394 if let Some(legacy_value) = item.remove(legacy) {
395 if !item.contains_key(canonical) {
396 item.insert(canonical.to_string(), legacy_value);
397 }
398 }
399}
400
401fn validate_edit_item_keys(item: &Map<String, Value>, index: usize) -> Result<(), TranslateError> {
402 let unknown = item
403 .keys()
404 .filter(|key| {
405 !matches!(
406 key.as_str(),
407 "oldString"
408 | "newString"
409 | "replaceAll"
410 | "occurrence"
411 | "startLine"
412 | "endLine"
413 | "content"
414 )
415 })
416 .cloned()
417 .collect::<Vec<_>>();
418 if unknown.is_empty() {
419 Ok(())
420 } else {
421 Err(invalid_request(format!(
422 "edit: edits[{index}] contains {}",
423 format_unknown_keys(unknown)
424 )))
425 }
426}
427
428fn coerce_edit_scalars(item: &mut Map<String, Value>, index: usize) -> Result<(), TranslateError> {
429 if item.contains_key("replaceAll") && item.contains_key("occurrence") {
430 return Err(invalid_request(format!(
431 "edit: edits[{index}] cannot contain both 'replaceAll' and 'occurrence'"
432 )));
433 }
434 if let Some(value) = item.get("replaceAll") {
435 let coerced = match value {
436 Value::Bool(value) => Some(*value),
437 Value::Number(number) if number.as_f64() == Some(0.0) => Some(false),
438 Value::Number(number) if number.as_f64() == Some(1.0) => Some(true),
439 Value::String(value) if value == "0" => Some(false),
440 Value::String(value) if value == "1" => Some(true),
441 Value::String(value) if value.eq_ignore_ascii_case("true") => Some(true),
442 Value::String(value) if value.eq_ignore_ascii_case("false") => Some(false),
443 _ => None,
444 };
445 let Some(coerced) = coerced else {
446 return Err(invalid_request(format!(
447 "edit: edits[{index}].replaceAll must be a boolean, true/false string, or 0/1"
448 )));
449 };
450 item.insert("replaceAll".to_string(), Value::Bool(coerced));
451 }
452
453 if item.contains_key("occurrence") {
454 let value = item.get("occurrence").cloned().unwrap();
455 match coerce_edit_occurrence(&value, index)? {
456 Some(value) => {
457 item.insert("occurrence".to_string(), Value::Number(value.into()));
458 }
459 None => {
460 item.remove("occurrence");
461 }
462 }
463 }
464 Ok(())
465}
466
467fn coerce_edit_occurrence(value: &Value, index: usize) -> Result<Option<u64>, TranslateError> {
468 if value.is_null() {
469 return Ok(None);
470 }
471 let parsed = match value {
472 Value::Number(number) => number
473 .as_u64()
474 .filter(|value| *value <= MAX_SAFE_INTEGER as u64)
475 .or_else(|| {
476 number.as_f64().and_then(|value| {
477 (value.is_finite()
478 && value.fract() == 0.0
479 && value >= 1.0
480 && value <= MAX_SAFE_INTEGER as f64)
481 .then_some(value as u64)
482 })
483 }),
484 Value::String(raw) => {
485 let trimmed = raw.trim_matches(|ch: char| ch.is_ascii_whitespace());
486 if trimmed.is_empty() {
487 return Ok(None);
488 }
489 let digits = trimmed.strip_prefix('+').unwrap_or(trimmed);
490 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
491 None
492 } else {
493 digits
494 .parse::<u64>()
495 .ok()
496 .filter(|value| *value <= MAX_SAFE_INTEGER as u64)
497 }
498 }
499 _ => None,
500 };
501 match parsed {
502 Some(value) if value >= 1 => Ok(Some(value)),
503 _ => Err(invalid_request(format!(
504 "edit: edits[{index}].occurrence must be a positive integer"
505 ))),
506 }
507}
508
509fn unsupported_tool(message: impl Into<String>) -> TranslateError {
510 TranslateError {
511 code: "unsupported_tool",
512 message: message.into(),
513 }
514}
515
516fn resolve_home_dir() -> Option<PathBuf> {
517 let raw = std::env::var_os("HOME")
518 .or_else(|| std::env::var_os("USERPROFILE"))
519 .map(PathBuf::from)?;
520 Some(raw)
521}
522
523fn expand_tilde(target: &str) -> Cow<'_, str> {
524 if target == "~" {
525 return resolve_home_dir()
526 .map(|h| Cow::Owned(h.to_string_lossy().into_owned()))
527 .unwrap_or(Cow::Borrowed(target));
528 }
529 if let Some(rest) = target.strip_prefix("~/") {
530 if let Some(home) = resolve_home_dir() {
531 return Cow::Owned(home.join(rest).to_string_lossy().into_owned());
532 }
533 }
534 Cow::Borrowed(target)
536}
537
538fn decode_file_url(target: &str) -> Option<String> {
550 let rest = target.strip_prefix("file:")?;
551 let path_part = if let Some(after) = rest.strip_prefix("//") {
552 let (authority, path) = match after.find('/') {
553 Some(index) => after.split_at(index),
554 None => (after, ""),
555 };
556 match authority {
557 "" | "localhost" => path.to_string(),
558 server if cfg!(windows) => format!("//{server}{path}"),
559 _ => return None,
560 }
561 } else {
562 if !rest.starts_with('/') {
564 return None;
565 }
566 rest.to_string()
567 };
568 let decoded = percent_decode(&path_part);
569 if cfg!(windows) {
572 let bytes = decoded.as_bytes();
573 if bytes.len() >= 3
574 && bytes[0] == b'/'
575 && bytes[1].is_ascii_alphabetic()
576 && bytes[2] == b':'
577 {
578 return Some(decoded[1..].to_string());
579 }
580 }
581 Some(decoded)
582}
583
584fn percent_decode(input: &str) -> String {
585 let bytes = input.as_bytes();
586 let mut out = Vec::with_capacity(bytes.len());
587 let mut index = 0;
588 while index < bytes.len() {
589 if bytes[index] == b'%' && index + 2 < bytes.len() {
590 let hex = &input[index + 1..index + 3];
591 if let Ok(value) = u8::from_str_radix(hex, 16) {
592 out.push(value);
593 index += 3;
594 continue;
595 }
596 }
597 out.push(bytes[index]);
598 index += 1;
599 }
600 String::from_utf8_lossy(&out).into_owned()
601}
602
603pub fn resolve_path_from_project_root(project_root: &Path, target: &str) -> PathBuf {
604 let target = decode_file_url(target)
605 .map(std::borrow::Cow::Owned)
606 .unwrap_or(std::borrow::Cow::Borrowed(target));
607 let expanded = expand_tilde(&target);
608 let path = Path::new(expanded.as_ref());
609 let joined = if path.is_absolute() {
610 path.to_path_buf()
611 } else {
612 project_root.join(path)
613 };
614 normalize_lexically(&joined)
615}
616
617fn normalize_lexically(path: &Path) -> PathBuf {
618 use std::path::Component;
619
620 let mut out = PathBuf::new();
621 for component in path.components() {
622 match component {
623 Component::CurDir => {}
624 Component::ParentDir => {
625 if !out.pop() {
626 out.push(component.as_os_str());
627 }
628 }
629 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
630 out.push(component.as_os_str());
631 }
632 }
633 }
634 if out.as_os_str().is_empty() {
635 PathBuf::from(".")
636 } else {
637 out
638 }
639}
640
641fn is_empty_param(value: &Value) -> bool {
642 match value {
643 Value::Null => true,
644 Value::String(s) => s.is_empty(),
645 Value::Array(a) => a.is_empty(),
646 Value::Object(o) => o.is_empty(),
647 _ => false,
648 }
649}
650
651fn coerce_optional_int_result(
652 value: Option<&Value>,
653 param_name: &str,
654 min: i64,
655 max: i64,
656) -> Result<Option<u64>, TranslateError> {
657 let Some(value) = value else {
658 return Ok(None);
659 };
660 if value.is_null()
661 || matches!(value, Value::String(s) if s.is_empty())
662 || matches!(value, Value::Array(a) if a.is_empty())
663 || matches!(value, Value::Object(o) if o.is_empty())
664 {
665 return Ok(None);
666 }
667 if matches!(value, Value::Number(num) if num.as_i64() == Some(0) && min > 0) {
668 return Ok(None);
669 }
670
671 let int_error = || {
672 invalid_request(format!(
673 "{param_name} must be an integer between {min} and {max}"
674 ))
675 };
676 let n = match value {
677 Value::Number(num) => num.as_i64().ok_or_else(int_error)?,
678 Value::String(s) => {
679 let parsed = s.parse::<f64>().map_err(|_| int_error())?;
680 if !parsed.is_finite() || parsed.fract() != 0.0 {
681 return Err(int_error());
682 }
683 parsed as i64
684 }
685 _ => return Err(int_error()),
686 };
687 if n < min || n > max {
688 return Err(invalid_request(format!(
689 "{param_name} must be between {min} and {max}"
690 )));
691 }
692 Ok(Some(n as u64))
693}
694
695fn agent_args_map(args: Value) -> Map<String, Value> {
696 match args {
697 Value::Object(map) => map,
698 _ => Map::new(),
699 }
700}
701
702pub(crate) fn supports_tool(bare_name: &str) -> bool {
703 matches!(
704 bare_name,
705 "bash"
706 | "status"
707 | "read"
708 | "write"
709 | "edit"
710 | "apply_patch"
711 | "grep"
712 | "glob"
713 | "search"
714 | "outline"
715 | "zoom"
716 | "inspect"
717 | "callgraph"
718 | "conflicts"
719 | "ast_search"
720 | "ast_replace"
721 | "delete"
722 | "move"
723 | "import"
724 | "refactor"
725 | "safety"
726 )
727}
728
729fn insert_resolved_file(map: &mut Map<String, Value>, project_root: &Path, file_path: &str) {
730 let resolved = resolve_path_from_project_root(project_root, file_path);
731 map.insert(
732 "file".to_string(),
733 Value::String(resolved.to_string_lossy().into_owned()),
734 );
735}
736
737pub fn subc_translate(
738 bare_name: &str,
739 agent_args: &Value,
740 project_root: &Path,
741) -> Result<Translated, TranslateError> {
742 subc_translate_owned(bare_name, agent_args.clone(), project_root)
743}
744
745pub fn subc_translate_owned(
746 bare_name: &str,
747 agent_args: Value,
748 project_root: &Path,
749) -> Result<Translated, TranslateError> {
750 subc_translate_owned_with_context(
751 bare_name,
752 agent_args,
753 project_root,
754 TranslateContext::default(),
755 )
756}
757
758pub fn subc_translate_with_context(
759 bare_name: &str,
760 agent_args: &Value,
761 project_root: &Path,
762 ctx: TranslateContext,
763) -> Result<Translated, TranslateError> {
764 subc_translate_owned_with_context(bare_name, agent_args.clone(), project_root, ctx)
765}
766
767pub fn subc_translate_owned_with_context(
768 bare_name: &str,
769 agent_args: Value,
770 project_root: &Path,
771 ctx: TranslateContext,
772) -> Result<Translated, TranslateError> {
773 let agent_args = normalize_path_arguments(bare_name, agent_args)?;
774 match bare_name {
775 "bash" => translate_bash(agent_args, project_root),
776 "status" => Ok(Translated {
777 command: "status".into(),
778 args: Map::new(),
779 }),
780 "read" => translate_read(agent_args, project_root),
781 "write" => translate_write(agent_args, project_root, ctx),
782 "edit" => translate_edit(agent_args, project_root, ctx),
783 "apply_patch" => translate_apply_patch(agent_args),
784 "grep" => translate_grep(agent_args, project_root),
785 "glob" => translate_glob(agent_args),
786 "search" => translate_search(agent_args),
787 "outline" => translate_outline(agent_args, project_root),
788 "zoom" => translate_zoom(agent_args, project_root),
789 "inspect" => translate_inspect(agent_args, project_root),
790 "callgraph" => translate_callgraph(agent_args, project_root),
791 "conflicts" => translate_conflicts(agent_args),
792 "ast_search" => translate_ast_search(agent_args),
793 "ast_replace" => translate_ast_replace(agent_args),
794 "delete" => translate_delete(agent_args, project_root),
795 "move" => translate_move(agent_args, project_root),
796 "import" => translate_import(agent_args),
797 "refactor" => translate_refactor(agent_args),
798 "safety" => translate_safety(agent_args, project_root),
799 other => Err(unsupported_tool(format!(
800 "subc_translate: unsupported tool {other:?}"
801 ))),
802 }
803}
804
805fn coerce_boolean(value: &Value) -> bool {
806 match value {
807 Value::Bool(value) => *value,
808 Value::Number(num) => num.as_i64() == Some(1) || num.as_u64() == Some(1),
809 Value::String(raw) => {
810 let normalized = raw.trim().to_ascii_lowercase();
811 normalized == "true" || normalized == "1"
812 }
813 _ => false,
814 }
815}
816
817fn translate_bash(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
818 let mut map_in = agent_args_map(args);
819 if let Some(Value::Object(params)) = map_in.remove("params") {
820 map_in = params;
821 }
822 let command = map_in
823 .get("command")
824 .and_then(Value::as_str)
825 .ok_or_else(|| invalid_request("'command' is required"))?;
826
827 let mut out = Map::new();
828 out.insert("command".to_string(), Value::String(command.to_string()));
829
830 if let Some(timeout) =
831 coerce_optional_int_result(map_in.get("timeout"), "timeout", 1, MAX_SAFE_INTEGER)?
832 {
833 out.insert("timeout".to_string(), Value::Number(timeout.into()));
834 }
835
836 if let Some(workdir) = map_in
837 .get("workdir")
838 .and_then(Value::as_str)
839 .filter(|value| !value.is_empty())
840 {
841 let resolved = resolve_path_from_project_root(project_root, workdir);
842 out.insert(
843 "workdir".to_string(),
844 Value::String(resolved.to_string_lossy().into_owned()),
845 );
846 }
847
848 if let Some(description) = map_in
849 .get("description")
850 .and_then(Value::as_str)
851 .filter(|value| !value.is_empty())
852 {
853 out.insert(
854 "description".to_string(),
855 Value::String(description.to_string()),
856 );
857 }
858
859 let background = map_in.get("background").is_some_and(coerce_boolean);
860 let pty = map_in.get("pty").is_some_and(coerce_boolean);
861 let wait = map_in.get("wait").is_some_and(coerce_boolean);
862 if wait && pty {
863 return Err(invalid_request(
864 "bash: wait:true cannot be used with pty:true because PTY sessions run in background",
865 ));
866 }
867 if wait && background {
868 return Err(invalid_request(
869 "bash: wait:true cannot be used with background:true",
870 ));
871 }
872 out.insert("background".to_string(), Value::Bool(background));
873 out.insert("pty".to_string(), Value::Bool(pty));
874 out.insert("wait".to_string(), Value::Bool(wait));
875 out.insert(
876 "notify_on_completion".to_string(),
877 Value::Bool(background || pty),
878 );
879
880 if let Some(rows) = coerce_optional_int_result(
881 map_in.get("ptyRows").or_else(|| map_in.get("pty_rows")),
882 "ptyRows",
883 1,
884 60,
885 )? {
886 out.insert("pty_rows".to_string(), Value::Number(rows.into()));
887 }
888 if let Some(cols) = coerce_optional_int_result(
889 map_in.get("ptyCols").or_else(|| map_in.get("pty_cols")),
890 "ptyCols",
891 1,
892 140,
893 )? {
894 out.insert("pty_cols".to_string(), Value::Number(cols.into()));
895 }
896
897 if let Some(compressed) = map_in.get("compressed") {
898 out.insert(
899 "compressed".to_string(),
900 Value::Bool(coerce_boolean(compressed)),
901 );
902 }
903
904 let foreground_orchestrate = map_in
905 .get("foreground_orchestrate")
906 .map(coerce_boolean)
907 .unwrap_or(true);
908 let block_to_completion = map_in
909 .get("block_to_completion")
910 .map(coerce_boolean)
911 .unwrap_or(false);
912 out.insert(
913 "foreground_orchestrate".to_string(),
914 Value::Bool(foreground_orchestrate),
915 );
916 out.insert(
917 "block_to_completion".to_string(),
918 Value::Bool(block_to_completion),
919 );
920
921 if let Some(permissions_granted) = map_in.get("permissions_granted") {
922 out.insert(
923 "permissions_granted".to_string(),
924 permissions_granted.clone(),
925 );
926 }
927 if let Some(permissions_requested) = map_in.get("permissions_requested") {
928 out.insert(
929 "permissions_requested".to_string(),
930 Value::Bool(coerce_boolean(permissions_requested)),
931 );
932 }
933 if let Some(env) = map_in.get("env") {
934 out.insert("env".to_string(), env.clone());
935 }
936 if let Some(sandbox) = map_in.get("sandbox") {
937 if sandbox.as_str() != Some("host") {
938 return Err(invalid_request("bash: 'sandbox' must be 'host'"));
939 }
940 out.insert("sandbox".to_string(), sandbox.clone());
941 }
942
943 Ok(Translated {
944 command: "bash".into(),
945 args: out,
946 })
947}
948
949fn translate_callgraph(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
950 let map_in = agent_args_map(args);
951 let op = map_in
952 .get("op")
953 .and_then(Value::as_str)
954 .filter(|s| !s.is_empty())
955 .ok_or_else(|| invalid_request("'op' is required"))?;
956 if !matches!(
957 op,
958 "call_tree" | "callers" | "trace_to" | "trace_to_symbol" | "impact" | "trace_data"
959 ) {
960 return Err(invalid_request(format!("callgraph: invalid op '{op}'")));
961 }
962
963 let file_path = map_in
964 .get("path")
965 .and_then(Value::as_str)
966 .filter(|s| !s.is_empty())
967 .ok_or_else(|| invalid_request("'path' is required"))?;
968 let symbol = map_in
969 .get("symbol")
970 .and_then(Value::as_str)
971 .filter(|s| !s.is_empty())
972 .ok_or_else(|| invalid_request("'symbol' is required"))?;
973
974 if op == "trace_data" && map_in.get("expression").is_none_or(is_empty_param) {
975 return Err(invalid_request(
976 "'expression' is required for 'trace_data' op",
977 ));
978 }
979 if op == "trace_to_symbol" && map_in.get("toSymbol").is_none_or(is_empty_param) {
980 return Err(invalid_request(
981 "'toSymbol' is required for 'trace_to_symbol' op",
982 ));
983 }
984
985 let mut out = Map::new();
986 insert_resolved_file(&mut out, project_root, file_path);
987 out.insert("symbol".to_string(), Value::String(symbol.to_string()));
988
989 if let Some(depth) =
990 coerce_optional_int_result(map_in.get("depth"), "depth", 1, 9_007_199_254_740_991)?
991 {
992 out.insert("depth".to_string(), Value::Number(depth.into()));
993 }
994 if let Some(expression) = map_in.get("expression") {
995 if !is_empty_param(expression) {
996 out.insert("expression".to_string(), expression.clone());
997 }
998 }
999 if let Some(to_symbol) = map_in.get("toSymbol") {
1000 if !is_empty_param(to_symbol) {
1001 out.insert("toSymbol".to_string(), to_symbol.clone());
1002 }
1003 }
1004 if let Some(to_file) = map_in.get("toPath") {
1005 if !is_empty_param(to_file) {
1006 let to_file = to_file
1007 .as_str()
1008 .ok_or_else(|| invalid_request("'toPath' must be a string"))?;
1009 let resolved = resolve_path_from_project_root(project_root, to_file);
1010 out.insert(
1011 "toFile".to_string(),
1012 Value::String(resolved.to_string_lossy().into_owned()),
1013 );
1014 }
1015 }
1016 if let Some(include_tests) = map_in.get("includeTests") {
1017 if !is_empty_param(include_tests) {
1018 out.insert(
1019 "include_tests".to_string(),
1020 Value::Bool(coerce_boolean(include_tests)),
1021 );
1022 }
1023 }
1024
1025 Ok(Translated {
1026 command: op.to_string(),
1027 args: out,
1028 })
1029}
1030
1031fn insert_common_mutation_flags(out: &mut Map<String, Value>, ctx: TranslateContext) {
1032 out.insert(
1033 "diagnostics".to_string(),
1034 Value::Bool(ctx.diagnostics_on_edit),
1035 );
1036 out.insert("include_diff_content".to_string(), Value::Bool(true));
1037 out.insert("preview".to_string(), Value::Bool(ctx.preview));
1038}
1039
1040fn translate_read(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1041 let map_in = agent_args_map(args);
1042 let file_path = map_in
1043 .get("path")
1044 .and_then(Value::as_str)
1045 .filter(|s| !s.is_empty())
1046 .ok_or_else(|| invalid_request("'path' is required"))?;
1047
1048 let mut out = Map::new();
1049 insert_resolved_file(&mut out, project_root, file_path);
1050
1051 let mut start_line = map_in.get("startLine").and_then(Value::as_u64);
1052 let mut end_line = map_in.get("endLine").and_then(Value::as_u64);
1053
1054 if start_line.is_none() {
1055 if let Some(offset) = map_in.get("offset").and_then(Value::as_u64) {
1056 start_line = Some(offset);
1057 if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
1058 end_line = Some(offset.saturating_add(limit).saturating_sub(1));
1059 }
1060 }
1061 }
1062
1063 if let Some(sl) = start_line {
1064 out.insert("start_line".to_string(), Value::Number(sl.into()));
1065 }
1066 if let Some(el) = end_line {
1067 out.insert("end_line".to_string(), Value::Number(el.into()));
1068 }
1069 if map_in.get("offset").is_none() {
1070 if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
1071 out.insert("limit".to_string(), Value::Number(limit.into()));
1072 }
1073 }
1074
1075 Ok(Translated {
1076 command: "read".into(),
1077 args: out,
1078 })
1079}
1080
1081fn translate_write(
1082 args: Value,
1083 project_root: &Path,
1084 ctx: TranslateContext,
1085) -> Result<Translated, TranslateError> {
1086 let mut map_in = agent_args_map(args);
1087 let file_path = match map_in.remove("path") {
1088 Some(Value::String(path)) if !path.is_empty() => path,
1089 _ => return Err(invalid_request("'path' is required")),
1090 };
1091 let content = match map_in.remove("content") {
1092 Some(Value::String(content)) => content,
1093 _ => return Err(invalid_request("write: missing required param 'content'")),
1094 };
1095
1096 let mut out = Map::new();
1097 insert_resolved_file(&mut out, project_root, &file_path);
1098 out.insert("content".to_string(), Value::String(content));
1099 out.insert("create_dirs".to_string(), Value::Bool(true));
1100 insert_common_mutation_flags(&mut out, ctx);
1101
1102 Ok(Translated {
1103 command: "write".into(),
1104 args: out,
1105 })
1106}
1107
1108fn translate_edit(
1109 args: Value,
1110 project_root: &Path,
1111 ctx: TranslateContext,
1112) -> Result<Translated, TranslateError> {
1113 let map_in = agent_args_map(args);
1114
1115 if map_in.get("startLine").is_some() || map_in.get("endLine").is_some() {
1116 return Err(invalid_request(
1117 "edit: 'startLine'/'endLine' are not top-level parameters. \
1118 For line-range edits, nest them inside the `edits` array. \
1119 For find/replace, use 'oldString'/'newString'.",
1120 ));
1121 }
1122
1123 let file_path = map_in
1124 .get("path")
1125 .and_then(Value::as_str)
1126 .filter(|s| !s.is_empty())
1127 .ok_or_else(|| invalid_request("'path' is required"))?;
1128
1129 let file_str = resolve_path_from_project_root(project_root, file_path)
1130 .to_string_lossy()
1131 .into_owned();
1132
1133 if let Some(append) = map_in.get("appendContent").and_then(Value::as_str) {
1134 let mut out = Map::new();
1135 out.insert("file".to_string(), Value::String(file_str));
1136 out.insert("op".to_string(), Value::String("append".into()));
1137 out.insert(
1138 "append_content".to_string(),
1139 Value::String(append.to_string()),
1140 );
1141 out.insert("create_dirs".to_string(), Value::Bool(true));
1142 insert_common_mutation_flags(&mut out, ctx);
1143 return Ok(Translated {
1144 command: "edit_match".into(),
1145 args: out,
1146 });
1147 }
1148
1149 if let Some(edits) = map_in.get("edits").and_then(Value::as_array) {
1150 if path_is_glob_pattern(file_path) {
1155 if let [single] = edits.as_slice() {
1156 if let Some(obj) = single.as_object() {
1157 let is_find_replace = obj.contains_key("oldString")
1158 && !obj.contains_key("startLine")
1159 && !obj.contains_key("endLine");
1160 if is_find_replace {
1161 return translate_single_edit_match(obj, file_str, ctx);
1162 }
1163 }
1164 }
1165 return Err(invalid_request(
1166 "edit: glob targets support exactly one find/replace edit \
1167 (oldString/newString); line-range and multi-item batches \
1168 need a concrete file path",
1169 ));
1170 }
1171 let mut out = Map::new();
1172 out.insert("file".to_string(), Value::String(file_str));
1173 let translated_edits: Vec<Value> = edits
1174 .iter()
1175 .filter_map(|edit| {
1176 let obj = edit.as_object()?;
1177 let mut t = Map::new();
1178 for (key, value) in obj {
1179 let native_key = match key.as_str() {
1180 "oldString" => "match",
1181 "newString" => "replacement",
1182 "startLine" => "line_start",
1183 "endLine" => "line_end",
1184 other => other,
1185 };
1186 t.insert(native_key.to_string(), value.clone());
1187 }
1188 Some(Value::Object(t))
1189 })
1190 .collect();
1191 out.insert("edits".to_string(), Value::Array(translated_edits));
1192 insert_common_mutation_flags(&mut out, ctx);
1193 return Ok(Translated {
1194 command: "batch".into(),
1195 args: out,
1196 });
1197 }
1198
1199 let symbol_is_string = map_in.get("symbol").and_then(Value::as_str).is_some();
1200 let old_string_is_string = map_in.get("oldString").and_then(Value::as_str).is_some();
1201 let has_content = map_in.get("content").is_some();
1202
1203 if symbol_is_string && !old_string_is_string && has_content {
1204 let mut out = Map::new();
1205 out.insert("file".to_string(), Value::String(file_str));
1206 out.insert(
1207 "symbol".to_string(),
1208 map_in.get("symbol").cloned().unwrap_or(Value::Null),
1209 );
1210 out.insert("operation".to_string(), Value::String("replace".into()));
1211 out.insert(
1212 "content".to_string(),
1213 map_in.get("content").cloned().unwrap_or(Value::Null),
1214 );
1215 insert_common_mutation_flags(&mut out, ctx);
1216 return Ok(Translated {
1217 command: "edit_symbol".into(),
1218 args: out,
1219 });
1220 }
1221
1222 if old_string_is_string {
1223 return translate_single_edit_match(&map_in, file_str, ctx);
1224 }
1225
1226 Err(invalid_request(
1227 "edit: no edit mode resolved from arguments.",
1228 ))
1229}
1230
1231fn path_is_glob_pattern(path: &str) -> bool {
1233 path.contains('*') || path.contains('?') || path.contains('{') || path.contains('[')
1234}
1235
1236fn translate_single_edit_match(
1239 fields: &Map<String, Value>,
1240 file_str: String,
1241 ctx: TranslateContext,
1242) -> Result<Translated, TranslateError> {
1243 let mut out = Map::new();
1244 out.insert("file".to_string(), Value::String(file_str));
1245 out.insert(
1246 "match".to_string(),
1247 Value::String(
1248 fields
1249 .get("oldString")
1250 .and_then(Value::as_str)
1251 .unwrap_or("")
1252 .to_string(),
1253 ),
1254 );
1255 let replacement = fields
1256 .get("newString")
1257 .and_then(Value::as_str)
1258 .unwrap_or("");
1259 out.insert(
1260 "replacement".to_string(),
1261 Value::String(replacement.to_string()),
1262 );
1263 if let Some(v) = fields.get("replaceAll") {
1264 out.insert("replace_all".to_string(), v.clone());
1265 }
1266 if let Some(v) = fields.get("occurrence") {
1267 out.insert("occurrence".to_string(), v.clone());
1268 }
1269 insert_common_mutation_flags(&mut out, ctx);
1270 Ok(Translated {
1271 command: "edit_match".into(),
1272 args: out,
1273 })
1274}
1275
1276fn translate_apply_patch(args: Value) -> Result<Translated, TranslateError> {
1277 let map_in = agent_args_map(args);
1278 let patch_text = map_in
1279 .get("patchText")
1280 .and_then(Value::as_str)
1281 .filter(|s| !s.is_empty())
1282 .ok_or_else(|| invalid_request("apply_patch: missing required param 'patchText'"))?;
1283
1284 let mut out = Map::new();
1285 out.insert(
1286 "patch_text".to_string(),
1287 Value::String(patch_text.to_string()),
1288 );
1289 Ok(Translated {
1290 command: "apply_patch".into(),
1291 args: out,
1292 })
1293}
1294
1295fn translate_grep(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1296 let map_in = agent_args_map(args);
1297 let pattern = map_in
1298 .get("pattern")
1299 .and_then(Value::as_str)
1300 .filter(|s| !s.is_empty())
1301 .ok_or_else(|| invalid_request("grep: missing required param 'pattern'"))?;
1302
1303 let mut out = Map::new();
1304 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1305 out.insert("case_sensitive".to_string(), Value::Bool(true));
1306 if let Some(include) = map_in.get("include") {
1307 if !is_empty_param(include) {
1308 let include_arg = include.as_str().ok_or_else(|| {
1309 invalid_request("grep: 'include' must be a comma-separated string")
1310 })?;
1311 let includes = split_include_arg(include_arg)
1312 .into_iter()
1313 .map(|pattern| Value::String(normalize_glob(&pattern)))
1314 .collect::<Vec<_>>();
1315 if !includes.is_empty() {
1316 out.insert("include".to_string(), Value::Array(includes));
1317 }
1318 }
1319 }
1320 if let Some(path_val) = map_in.get("path") {
1321 if !is_empty_param(path_val) {
1322 if let Some(path_str) = path_val.as_str() {
1323 out.insert(
1324 "path".to_string(),
1325 Value::String(resolve_grep_path_arg(project_root, path_str)),
1326 );
1327 }
1328 }
1329 }
1330 out.insert("max_results".to_string(), Value::Number(100u64.into()));
1331
1332 Ok(Translated {
1333 command: "grep".into(),
1334 args: out,
1335 })
1336}
1337
1338fn translate_ast_search(args: Value) -> Result<Translated, TranslateError> {
1339 let map_in = agent_args_map(args);
1340 let pattern = map_in
1341 .get("pattern")
1342 .and_then(Value::as_str)
1343 .filter(|s| !s.is_empty())
1344 .ok_or_else(|| invalid_request("ast_search: missing required param 'pattern'"))?;
1345 let lang = map_in
1346 .get("lang")
1347 .and_then(Value::as_str)
1348 .filter(|s| !s.is_empty())
1349 .ok_or_else(|| invalid_request("ast_search: missing required param 'lang'"))?;
1350
1351 let mut out = Map::new();
1352 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1353 out.insert("lang".to_string(), Value::String(lang.to_string()));
1354 insert_non_empty_array(&mut out, &map_in, "paths");
1355 insert_non_empty_array(&mut out, &map_in, "globs");
1356 if let Some(context) = coerce_optional_int_result(
1357 map_in.get("contextLines"),
1358 "contextLines",
1359 1,
1360 9_007_199_254_740_991,
1361 )? {
1362 out.insert("context".to_string(), Value::Number(context.into()));
1363 }
1364
1365 Ok(Translated {
1366 command: "ast_search".into(),
1367 args: out,
1368 })
1369}
1370
1371fn translate_ast_replace(args: Value) -> Result<Translated, TranslateError> {
1372 let map_in = agent_args_map(args);
1373 let pattern = map_in
1374 .get("pattern")
1375 .and_then(Value::as_str)
1376 .filter(|s| !s.is_empty())
1377 .ok_or_else(|| invalid_request("ast_replace: missing required param 'pattern'"))?;
1378 let rewrite = map_in
1379 .get("rewrite")
1380 .and_then(Value::as_str)
1381 .ok_or_else(|| invalid_request("ast_replace: missing required param 'rewrite'"))?;
1382 let lang = map_in
1383 .get("lang")
1384 .and_then(Value::as_str)
1385 .filter(|s| !s.is_empty())
1386 .ok_or_else(|| invalid_request("ast_replace: missing required param 'lang'"))?;
1387
1388 let mut out = Map::new();
1389 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1390 out.insert("rewrite".to_string(), Value::String(rewrite.to_string()));
1391 out.insert("lang".to_string(), Value::String(lang.to_string()));
1392 insert_non_empty_array(&mut out, &map_in, "paths");
1393 insert_non_empty_array(&mut out, &map_in, "globs");
1394 let dry_run = map_in
1395 .get("dryRun")
1396 .or_else(|| map_in.get("dry_run"))
1397 .is_some_and(coerce_boolean);
1398 out.insert("dry_run".to_string(), Value::Bool(dry_run));
1399
1400 Ok(Translated {
1401 command: "ast_replace".into(),
1402 args: out,
1403 })
1404}
1405
1406fn insert_present_renamed(
1407 out: &mut Map<String, Value>,
1408 map_in: &Map<String, Value>,
1409 from: &str,
1410 to: &str,
1411) {
1412 if let Some(value) = map_in.get(from) {
1413 out.insert(to.to_string(), value.clone());
1414 }
1415}
1416
1417fn translate_delete(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1418 let map_in = agent_args_map(args);
1419 let files = map_in
1420 .get("files")
1421 .and_then(Value::as_array)
1422 .filter(|items| !items.is_empty())
1423 .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
1424
1425 let mut resolved_files = Vec::with_capacity(files.len());
1426 for file in files {
1427 let file = file
1428 .as_str()
1429 .filter(|path| !path.is_empty())
1430 .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
1431 let resolved = resolve_path_from_project_root(project_root, file);
1432 resolved_files.push(Value::String(resolved.to_string_lossy().into_owned()));
1433 }
1434
1435 let mut out = Map::new();
1436 out.insert("files".to_string(), Value::Array(resolved_files));
1437 out.insert(
1438 "recursive".to_string(),
1439 Value::Bool(map_in.get("recursive").is_some_and(coerce_boolean)),
1440 );
1441
1442 Ok(Translated {
1443 command: "delete_file".into(),
1444 args: out,
1445 })
1446}
1447
1448fn translate_move(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1449 let map_in = agent_args_map(args);
1450 let file_path = map_in
1451 .get("path")
1452 .and_then(Value::as_str)
1453 .filter(|s| !s.is_empty())
1454 .ok_or_else(|| invalid_request("aft_move: missing required param 'path'"))?;
1455 let destination = map_in
1456 .get("destination")
1457 .and_then(Value::as_str)
1458 .filter(|s| !s.is_empty())
1459 .ok_or_else(|| invalid_request("aft_move: missing required param 'destination'"))?;
1460
1461 let file_path = resolve_path_from_project_root(project_root, file_path);
1462 let destination = resolve_path_from_project_root(project_root, destination);
1463
1464 let mut out = Map::new();
1465 out.insert(
1466 "file".to_string(),
1467 Value::String(file_path.to_string_lossy().into_owned()),
1468 );
1469 out.insert(
1470 "destination".to_string(),
1471 Value::String(destination.to_string_lossy().into_owned()),
1472 );
1473
1474 Ok(Translated {
1475 command: "move_file".into(),
1476 args: out,
1477 })
1478}
1479
1480fn translate_import(args: Value) -> Result<Translated, TranslateError> {
1481 let map_in = agent_args_map(args);
1482 let op = map_in
1483 .get("op")
1484 .and_then(Value::as_str)
1485 .ok_or_else(|| invalid_request("aft_import: missing required param 'op'"))?;
1486 let command = match op {
1487 "add" => "add_import",
1488 "remove" => "remove_import",
1489 "organize" => "organize_imports",
1490 other => {
1491 return Err(invalid_request(format!(
1492 "aft_import: invalid op {other:?}; expected 'add', 'remove', or 'organize'"
1493 )));
1494 }
1495 };
1496
1497 let file_path = map_in
1498 .get("path")
1499 .and_then(Value::as_str)
1500 .filter(|s| !s.is_empty())
1501 .ok_or_else(|| invalid_request("aft_import: missing required param 'filePath'"))?;
1502
1503 if matches!(op, "add" | "remove") && map_in.get("module").map_or(true, is_empty_param) {
1504 return Err(invalid_request(format!(
1505 "'module' is required for '{op}' op"
1506 )));
1507 }
1508
1509 let mut out = Map::new();
1510 out.insert("file".to_string(), Value::String(file_path.to_string()));
1511 insert_present_renamed(&mut out, &map_in, "module", "module");
1512 insert_present_renamed(&mut out, &map_in, "names", "names");
1513 insert_present_renamed(&mut out, &map_in, "defaultImport", "default_import");
1514 insert_present_renamed(&mut out, &map_in, "namespace", "namespace");
1515 insert_present_renamed(&mut out, &map_in, "alias", "alias");
1516 insert_present_renamed(&mut out, &map_in, "modifiers", "modifiers");
1517 insert_present_renamed(&mut out, &map_in, "importKind", "import_kind");
1518 insert_present_renamed(&mut out, &map_in, "typeOnly", "type_only");
1519 insert_present_renamed(&mut out, &map_in, "removeName", "name");
1520 insert_present_renamed(&mut out, &map_in, "validate", "validate");
1521
1522 Ok(Translated {
1523 command: command.into(),
1524 args: out,
1525 })
1526}
1527
1528fn translate_refactor(args: Value) -> Result<Translated, TranslateError> {
1529 let map_in = agent_args_map(args);
1530 let op = map_in
1531 .get("op")
1532 .and_then(Value::as_str)
1533 .ok_or_else(|| invalid_request("aft_refactor: missing required param 'op'"))?;
1534 let command = match op {
1535 "move" => "move_symbol",
1536 "extract" => "extract_function",
1537 "inline" => "inline_symbol",
1538 other => {
1539 return Err(invalid_request(format!(
1540 "aft_refactor: invalid op {other:?}; expected 'move', 'extract', or 'inline'"
1541 )));
1542 }
1543 };
1544
1545 let file_path = map_in
1546 .get("path")
1547 .and_then(Value::as_str)
1548 .filter(|s| !s.is_empty())
1549 .ok_or_else(|| invalid_request("aft_refactor: missing required param 'filePath'"))?;
1550
1551 if matches!(op, "move" | "inline") && map_in.get("symbol").is_none_or(is_empty_param) {
1552 return Err(invalid_request(format!(
1553 "'symbol' is required for '{op}' op"
1554 )));
1555 }
1556 if op == "move" && map_in.get("destination").is_none_or(is_empty_param) {
1557 return Err(invalid_request("'destination' is required for 'move' op"));
1558 }
1559
1560 let mut out = Map::new();
1561 out.insert("file".to_string(), Value::String(file_path.to_string()));
1562
1563 match op {
1564 "move" => {
1565 insert_present_renamed(&mut out, &map_in, "symbol", "symbol");
1566 insert_present_renamed(&mut out, &map_in, "destination", "destination");
1567 insert_present_renamed(&mut out, &map_in, "scope", "scope");
1568 }
1569 "extract" => {
1570 if map_in.get("name").is_none_or(is_empty_param) {
1571 return Err(invalid_request("'name' is required for 'extract' op"));
1572 }
1573 let start_line = coerce_optional_int_result(
1574 map_in.get("startLine"),
1575 "startLine",
1576 1,
1577 MAX_SAFE_INTEGER,
1578 )?
1579 .ok_or_else(|| invalid_request("'startLine' is required for 'extract' op"))?;
1580 let end_line =
1581 coerce_optional_int_result(map_in.get("endLine"), "endLine", 1, MAX_SAFE_INTEGER)?
1582 .ok_or_else(|| invalid_request("'endLine' is required for 'extract' op"))?;
1583
1584 insert_present_renamed(&mut out, &map_in, "name", "name");
1585 out.insert("start_line".to_string(), Value::Number(start_line.into()));
1586 out.insert("end_line".to_string(), Value::Number((end_line + 1).into()));
1587 }
1588 "inline" => {
1589 let call_site_line = coerce_optional_int_result(
1590 map_in.get("callSiteLine"),
1591 "callSiteLine",
1592 1,
1593 MAX_SAFE_INTEGER,
1594 )?
1595 .ok_or_else(|| invalid_request("'callSiteLine' is required for 'inline' op"))?;
1596
1597 insert_present_renamed(&mut out, &map_in, "symbol", "symbol");
1598 out.insert(
1599 "call_site_line".to_string(),
1600 Value::Number(call_site_line.into()),
1601 );
1602 }
1603 _ => unreachable!("validated refactor op"),
1604 }
1605
1606 insert_present_renamed(&mut out, &map_in, "lsp_hints", "lsp_hints");
1607
1608 Ok(Translated {
1609 command: command.into(),
1610 args: out,
1611 })
1612}
1613
1614fn translate_safety(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1615 let map_in = agent_args_map(args);
1616 let op = map_in
1617 .get("op")
1618 .and_then(Value::as_str)
1619 .ok_or_else(|| invalid_request("aft_safety: missing required param 'op'"))?;
1620 let command = match op {
1621 "undo" => "undo",
1622 "history" => "edit_history",
1623 "checkpoint" => "checkpoint",
1624 "restore" => "restore_checkpoint",
1625 "list" => "list_checkpoints",
1626 other => {
1627 return Err(invalid_request(format!(
1628 "aft_safety: invalid op {other:?}; expected 'undo', 'history', 'checkpoint', 'restore', or 'list'"
1629 )));
1630 }
1631 };
1632
1633 if op == "history" && map_in.get("path").and_then(Value::as_str).is_none() {
1634 return Err(invalid_request("'path' is required for 'history' op"));
1635 }
1636 if matches!(op, "checkpoint" | "restore")
1637 && map_in.get("name").and_then(Value::as_str).is_none()
1638 {
1639 return Err(invalid_request(format!("'name' is required for '{op}' op")));
1640 }
1641
1642 let resolve_path = |value: &Value| -> Result<Value, TranslateError> {
1643 let path = value
1644 .as_str()
1645 .filter(|path| !path.is_empty())
1646 .ok_or_else(|| invalid_request("aft_safety: paths must be non-empty strings"))?;
1647 Ok(Value::String(
1648 resolve_path_from_project_root(project_root, path)
1649 .to_string_lossy()
1650 .into_owned(),
1651 ))
1652 };
1653
1654 let mut out = Map::new();
1655 insert_present_renamed(&mut out, &map_in, "name", "name");
1656 let files = map_in
1657 .get("files")
1658 .and_then(Value::as_array)
1659 .filter(|items| !items.is_empty())
1660 .map(|items| {
1661 items
1662 .iter()
1663 .map(resolve_path)
1664 .collect::<Result<Vec<_>, _>>()
1665 })
1666 .transpose()?;
1667
1668 if op == "checkpoint" {
1669 if let Some(files) = files {
1670 out.insert("files".to_string(), Value::Array(files));
1671 } else if let Some(file_path) = map_in.get("path") {
1672 out.insert(
1673 "files".to_string(),
1674 Value::Array(vec![resolve_path(file_path)?]),
1675 );
1676 }
1677 } else {
1678 if let Some(file_path) = map_in.get("path") {
1679 out.insert("file".to_string(), resolve_path(file_path)?);
1680 }
1681 if let Some(files) = files {
1682 out.insert("files".to_string(), Value::Array(files));
1683 }
1684 }
1685
1686 Ok(Translated {
1687 command: command.into(),
1688 args: out,
1689 })
1690}
1691
1692fn insert_non_empty_array(out: &mut Map<String, Value>, map_in: &Map<String, Value>, key: &str) {
1693 if let Some(value) = map_in.get(key) {
1694 if let Some(items) = value.as_array() {
1695 if !items.is_empty() {
1696 out.insert(key.to_string(), Value::Array(items.clone()));
1697 }
1698 }
1699 }
1700}
1701
1702fn translate_glob(args: Value) -> Result<Translated, TranslateError> {
1703 let map_in = agent_args_map(args);
1704 let pattern = map_in
1705 .get("pattern")
1706 .and_then(Value::as_str)
1707 .filter(|s| !s.is_empty())
1708 .ok_or_else(|| invalid_request("glob: missing required param 'pattern'"))?;
1709
1710 let mut out = Map::new();
1711 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1712 if let Some(path_val) = map_in.get("path") {
1713 if !is_empty_param(path_val) {
1714 if let Some(path_str) = path_val.as_str() {
1715 out.insert("path".to_string(), Value::String(path_str.to_string()));
1716 }
1717 }
1718 }
1719
1720 Ok(Translated {
1721 command: "glob".into(),
1722 args: out,
1723 })
1724}
1725
1726fn normalize_glob(pattern: &str) -> String {
1727 if !pattern.contains('/') && !pattern.starts_with("**/") {
1728 format!("**/{pattern}")
1729 } else {
1730 pattern.to_string()
1731 }
1732}
1733
1734fn split_include_arg(raw: &str) -> Vec<String> {
1735 let mut out = Vec::new();
1736 let mut depth = 0usize;
1737 let mut buf = String::new();
1738 for ch in raw.chars() {
1739 match ch {
1740 '{' => {
1741 depth += 1;
1742 buf.push(ch);
1743 }
1744 '}' => {
1745 depth = depth.saturating_sub(1);
1746 buf.push(ch);
1747 }
1748 ',' if depth == 0 => {
1749 let trimmed = buf.trim();
1750 if !trimmed.is_empty() {
1751 out.push(trimmed.to_string());
1752 }
1753 buf.clear();
1754 }
1755 _ => buf.push(ch),
1756 }
1757 }
1758 let trimmed = buf.trim();
1759 if !trimmed.is_empty() {
1760 out.push(trimmed.to_string());
1761 }
1762 out
1763}
1764
1765fn search_path_exists(project_root: &Path, raw: &str) -> bool {
1766 resolve_path_from_project_root(project_root, raw).exists()
1767}
1768
1769fn split_search_path_arg(project_root: &Path, raw: &str) -> Vec<String> {
1770 if search_path_exists(project_root, raw) || !raw.chars().any(char::is_whitespace) {
1771 return vec![raw.to_string()];
1772 }
1773
1774 let fragments = raw
1775 .split_whitespace()
1776 .filter(|fragment| !fragment.is_empty())
1777 .collect::<Vec<_>>();
1778 if fragments.len() < 2 {
1779 return vec![raw.to_string()];
1780 }
1781
1782 let existing = fragments
1783 .iter()
1784 .filter(|fragment| search_path_exists(project_root, fragment))
1785 .map(|fragment| (*fragment).to_string())
1786 .collect::<Vec<_>>();
1787 if existing.is_empty() {
1788 vec![raw.to_string()]
1789 } else {
1790 existing
1791 }
1792}
1793
1794fn resolve_grep_path_arg(project_root: &Path, raw: &str) -> String {
1795 split_search_path_arg(project_root, raw)
1796 .iter()
1797 .map(|target| {
1798 resolve_path_from_project_root(project_root, target)
1799 .to_string_lossy()
1800 .into_owned()
1801 })
1802 .collect::<Vec<_>>()
1803 .join(" ")
1804}
1805
1806fn translate_search(args: Value) -> Result<Translated, TranslateError> {
1807 let map_in = agent_args_map(args);
1808 let query = map_in
1809 .get("query")
1810 .and_then(Value::as_str)
1811 .filter(|s| !s.trim().is_empty())
1812 .ok_or_else(|| {
1813 invalid_request("semantic_search: invalid params: `query` must be a non-empty string")
1814 })?;
1815
1816 let mut out = Map::new();
1817 out.insert("query".to_string(), Value::String(query.to_string()));
1818 let top_k = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)?.unwrap_or(10);
1819 out.insert("top_k".to_string(), Value::Number(top_k.into()));
1820 if let Some(include_tests) = map_in.get("includeTests").and_then(Value::as_bool) {
1821 out.insert("include_tests".to_string(), Value::Bool(include_tests));
1822 }
1823 if let Some(path) = map_in
1824 .get("path")
1825 .and_then(Value::as_str)
1826 .map(str::trim)
1827 .filter(|path| !path.is_empty())
1828 {
1829 out.insert("path".to_string(), Value::String(path.to_string()));
1830 }
1831
1832 Ok(Translated {
1833 command: "semantic_search".into(),
1834 args: out,
1835 })
1836}
1837
1838fn translate_outline(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1839 let map_in = agent_args_map(args);
1840 let files_flag = map_in
1841 .get("files")
1842 .and_then(Value::as_bool)
1843 .unwrap_or(false);
1844
1845 let target = map_in
1846 .get("target")
1847 .ok_or_else(|| invalid_request("outline: missing required param 'target'"))?;
1848
1849 if is_empty_param(target) {
1850 return Err(invalid_request(
1851 "'target' must be a non-empty string or array of strings",
1852 ));
1853 }
1854
1855 let mut out = Map::new();
1856 if let Some(include_tests) = map_in
1857 .get("includeTests")
1858 .or_else(|| map_in.get("include_tests"))
1859 .and_then(Value::as_bool)
1860 {
1861 out.insert("includeTests".to_string(), Value::Bool(include_tests));
1862 }
1863
1864 if let Some(arr) = target.as_array() {
1865 if arr.is_empty() {
1866 return Err(invalid_request(
1867 "'target' must be a non-empty string or array of strings",
1868 ));
1869 }
1870 if files_flag {
1871 let resolved: Vec<Value> = arr
1872 .iter()
1873 .filter_map(|v| v.as_str())
1874 .map(|entry| {
1875 let p = resolve_path_from_project_root(project_root, entry);
1876 Value::String(p.to_string_lossy().into_owned())
1877 })
1878 .collect();
1879 out.insert("target".to_string(), Value::Array(resolved));
1880 out.insert("files".to_string(), Value::Bool(true));
1881 return Ok(Translated {
1882 command: "outline".into(),
1883 args: out,
1884 });
1885 }
1886 let resolved: Vec<Value> = arr
1887 .iter()
1888 .filter_map(|v| v.as_str())
1889 .map(|entry| {
1890 let p = resolve_path_from_project_root(project_root, entry);
1891 Value::String(p.to_string_lossy().into_owned())
1892 })
1893 .collect();
1894 out.insert("files".to_string(), Value::Array(resolved));
1895 return Ok(Translated {
1896 command: "outline".into(),
1897 args: out,
1898 });
1899 }
1900
1901 if let Some(url) = target.as_str() {
1902 if !files_flag && (url.starts_with("http://") || url.starts_with("https://")) {
1903 out.insert("file".to_string(), Value::String(url.to_string()));
1904 return Ok(Translated {
1905 command: "outline".into(),
1906 args: out,
1907 });
1908 }
1909 }
1910
1911 let target_str = target.as_str().ok_or_else(|| {
1912 invalid_request("'target' must be a non-empty string or array of strings")
1913 })?;
1914
1915 let resolved = resolve_path_from_project_root(project_root, target_str);
1916 let is_dir = std::fs::metadata(&resolved)
1917 .map(|m| m.is_dir())
1918 .unwrap_or(false);
1919
1920 if files_flag {
1921 if is_dir {
1922 out.insert(
1923 "directory".to_string(),
1924 Value::String(resolved.to_string_lossy().into_owned()),
1925 );
1926 } else {
1927 out.insert(
1928 "file".to_string(),
1929 Value::String(resolved.to_string_lossy().into_owned()),
1930 );
1931 }
1932 out.insert("files".to_string(), Value::Bool(true));
1933 } else if is_dir {
1934 out.insert(
1935 "directory".to_string(),
1936 Value::String(resolved.to_string_lossy().into_owned()),
1937 );
1938 } else {
1939 out.insert(
1940 "file".to_string(),
1941 Value::String(resolved.to_string_lossy().into_owned()),
1942 );
1943 }
1944
1945 Ok(Translated {
1946 command: "outline".into(),
1947 args: out,
1948 })
1949}
1950
1951fn zoom_target_entry_is_empty(entry: &Value) -> bool {
1952 let Some(obj) = entry.as_object() else {
1953 return true;
1954 };
1955 let file_path_empty = obj
1956 .get("path")
1957 .and_then(Value::as_str)
1958 .is_none_or(str::is_empty);
1959 let symbol_empty = obj
1960 .get("symbol")
1961 .and_then(Value::as_str)
1962 .is_none_or(str::is_empty);
1963 file_path_empty && symbol_empty
1964}
1965
1966fn zoom_targets_provided(value: Option<&Value>) -> bool {
1967 let Some(value) = value else {
1968 return false;
1969 };
1970 if is_empty_param(value) {
1971 return false;
1972 }
1973 match value {
1974 Value::Array(items) => !items.iter().all(zoom_target_entry_is_empty),
1975 Value::Object(_) => !zoom_target_entry_is_empty(value),
1976 _ => false,
1977 }
1978}
1979
1980fn translate_zoom_targets(
1981 targets_value: &Value,
1982 project_root: &Path,
1983) -> Result<Vec<Value>, TranslateError> {
1984 let target_values: Vec<&Value> = match targets_value {
1985 Value::Array(items) => items.iter().collect(),
1986 Value::Object(_) => vec![targets_value],
1987 _ => {
1988 return Err(invalid_request(
1989 "'targets' must be a non-empty object or array",
1990 ))
1991 }
1992 };
1993
1994 if target_values.is_empty() {
1995 return Err(invalid_request(
1996 "'targets' must be a non-empty object or array",
1997 ));
1998 }
1999
2000 let mut out = Vec::with_capacity(target_values.len());
2001 for (index, target) in target_values.into_iter().enumerate() {
2002 let obj = target.as_object();
2003 let file_path = obj
2004 .and_then(|obj| obj.get("path"))
2005 .and_then(Value::as_str)
2006 .filter(|file_path| !file_path.is_empty())
2007 .ok_or_else(|| {
2008 invalid_request(format!(
2009 "targets[{index}].filePath must be a non-empty string"
2010 ))
2011 })?;
2012 let symbol = obj
2013 .and_then(|obj| obj.get("symbol"))
2014 .and_then(Value::as_str)
2015 .filter(|symbol| !symbol.is_empty())
2016 .ok_or_else(|| {
2017 invalid_request(format!(
2018 "targets[{index}].symbol must be a non-empty string"
2019 ))
2020 })?;
2021 let resolved = resolve_path_from_project_root(project_root, file_path);
2022 let mut target_out = Map::new();
2023 target_out.insert(
2024 "file".to_string(),
2025 Value::String(resolved.to_string_lossy().into_owned()),
2026 );
2027 target_out.insert("symbol".to_string(), Value::String(symbol.to_string()));
2028 target_out.insert(
2029 "target_label".to_string(),
2030 Value::String(file_path.to_string()),
2031 );
2032 out.push(Value::Object(target_out));
2033 }
2034 Ok(out)
2035}
2036
2037fn translate_zoom(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2038 let map_in = agent_args_map(args);
2039
2040 let has_targets = zoom_targets_provided(map_in.get("targets"));
2041 let has_file_path = map_in
2042 .get("path")
2043 .is_some_and(|value| !is_empty_param(value));
2044 let has_url = map_in
2045 .get("url")
2046 .is_some_and(|value| !is_empty_param(value));
2047 let has_symbols = map_in
2048 .get("symbols")
2049 .is_some_and(|value| !is_empty_param(value));
2050
2051 let mut out = Map::new();
2052
2053 if has_targets {
2054 if has_file_path || has_url || has_symbols {
2055 return Err(invalid_request(
2056 "'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'",
2057 ));
2058 }
2059 let targets_value = map_in
2060 .get("targets")
2061 .expect("has_targets implies a targets value exists");
2062 out.insert(
2063 "targets".to_string(),
2064 Value::Array(translate_zoom_targets(targets_value, project_root)?),
2065 );
2066
2067 if let Some(context_lines) = coerce_optional_int_result(
2068 map_in.get("contextLines"),
2069 "contextLines",
2070 1,
2071 9_007_199_254_740_991,
2072 )? {
2073 out.insert(
2074 "context_lines".to_string(),
2075 Value::Number(context_lines.into()),
2076 );
2077 }
2078
2079 if map_in.get("callgraph").is_some_and(coerce_boolean) {
2080 out.insert("callgraph".to_string(), Value::Bool(true));
2081 }
2082
2083 return Ok(Translated {
2084 command: "zoom".into(),
2085 args: out,
2086 });
2087 }
2088
2089 let file_path = map_in
2090 .get("path")
2091 .and_then(Value::as_str)
2092 .filter(|s| !s.is_empty());
2093 let url = map_in
2094 .get("url")
2095 .and_then(Value::as_str)
2096 .filter(|s| !s.is_empty());
2097
2098 match (file_path, url) {
2099 (None, None) => {
2100 return Err(invalid_request(
2101 "Provide exactly one of 'filePath', 'url', or 'targets'",
2102 ));
2103 }
2104 (Some(_), Some(_)) => {
2105 return Err(invalid_request(
2106 "Provide exactly ONE of 'filePath' or 'url' — not both",
2107 ));
2108 }
2109 _ => {}
2110 }
2111
2112 if let Some(url) = url {
2113 out.insert("file".to_string(), Value::String(url.to_string()));
2114 } else if let Some(file_path) = file_path {
2115 insert_resolved_file(&mut out, project_root, file_path);
2116 }
2117
2118 if let Some(symbols) = map_in.get("symbols") {
2119 if !is_empty_param(symbols) {
2120 match symbols {
2121 Value::String(symbol) => {
2122 out.insert("symbol".to_string(), Value::String(symbol.to_string()));
2123 }
2124 Value::Array(items) => {
2125 let names: Vec<Value> = items
2131 .iter()
2132 .filter_map(Value::as_str)
2133 .filter(|name| !name.is_empty())
2134 .map(|name| Value::String(name.to_string()))
2135 .collect();
2136 if !names.is_empty() {
2137 out.insert("symbols".to_string(), Value::Array(names));
2138 }
2139 }
2140 _ => {
2141 return Err(invalid_request(
2142 "'symbols' must be a string or array of strings",
2143 ))
2144 }
2145 }
2146 }
2147 }
2148
2149 if let Some(context_lines) = coerce_optional_int_result(
2150 map_in.get("contextLines"),
2151 "contextLines",
2152 1,
2153 9_007_199_254_740_991,
2154 )? {
2155 out.insert(
2156 "context_lines".to_string(),
2157 Value::Number(context_lines.into()),
2158 );
2159 }
2160
2161 if map_in.get("callgraph").is_some_and(coerce_boolean) {
2162 out.insert("callgraph".to_string(), Value::Bool(true));
2163 }
2164
2165 Ok(Translated {
2166 command: "zoom".into(),
2167 args: out,
2168 })
2169}
2170
2171fn translate_conflicts(args: Value) -> Result<Translated, TranslateError> {
2172 let map_in = agent_args_map(args);
2173 let mut out = Map::new();
2174 if let Some(path_val) = map_in.get("path") {
2175 if !is_empty_param(path_val) {
2176 if let Some(path_str) = path_val.as_str() {
2177 out.insert("path".to_string(), Value::String(path_str.to_string()));
2178 }
2179 }
2180 }
2181
2182 Ok(Translated {
2183 command: "git_conflicts".into(),
2184 args: out,
2185 })
2186}
2187
2188fn translate_inspect(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2189 let map_in = agent_args_map(args);
2190 let mut out = Map::new();
2191
2192 if let Some(sections) = map_in.get("sections") {
2193 if !is_empty_param(sections) {
2194 out.insert("sections".to_string(), sections.clone());
2195 }
2196 }
2197
2198 if let Some(scope) = map_in.get("scope") {
2199 if !is_empty_param(scope) {
2200 match scope {
2201 Value::String(s) if !s.is_empty() => {
2202 let resolved = resolve_path_from_project_root(project_root, s);
2203 out.insert(
2204 "scope".to_string(),
2205 Value::String(resolved.to_string_lossy().into_owned()),
2206 );
2207 }
2208 Value::Array(arr) => {
2209 let resolved: Vec<Value> = arr
2210 .iter()
2211 .filter_map(|v| v.as_str())
2212 .map(|entry| {
2213 let p = resolve_path_from_project_root(project_root, entry);
2214 Value::String(p.to_string_lossy().into_owned())
2215 })
2216 .collect();
2217 out.insert("scope".to_string(), Value::Array(resolved));
2218 }
2219 other => {
2220 out.insert("scope".to_string(), other.clone());
2221 }
2222 }
2223 }
2224 }
2225
2226 if let Some(top_k) = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)? {
2227 out.insert("topK".to_string(), Value::Number(top_k.into()));
2228 }
2229
2230 Ok(Translated {
2231 command: "inspect".into(),
2232 args: out,
2233 })
2234}
2235
2236#[cfg(test)]
2237mod tests {
2238 use super::*;
2239
2240 #[test]
2241 fn path_aliases_normalize_equal_and_reject_conflicts() {
2242 let project = Path::new("/project");
2243 let legacy = serde_json::json!({"filePath": "src/main.ts", "content": "x"});
2244 let canonical = serde_json::json!({"path": "src/main.ts", "content": "x"});
2245 assert_eq!(
2246 subc_translate_owned("write", legacy, project).expect("legacy path"),
2247 subc_translate_owned("write", canonical, project).expect("canonical path")
2248 );
2249
2250 let conflict = serde_json::json!({"path": "src/a.ts", "filePath": "src/b.ts"});
2251 let error = subc_translate_owned("read", conflict, project).expect_err("conflict");
2252 assert_eq!(error.code, "invalid_request");
2253 assert!(error.message.contains("path"));
2254 assert!(error.message.contains("filePath"));
2255 }
2256
2257 #[test]
2258 fn path_aliases_keep_unicode_scalar_equality_strict() {
2259 let project = Path::new("/project");
2260 let equal = serde_json::json!({"path": "src/😀.ts", "filePath": "src/😀.ts"});
2261 assert!(subc_translate_owned("read", equal, project).is_ok());
2262
2263 let canonically_different = serde_json::json!({
2264 "path": "src/é.ts",
2265 "filePath": "src/e\u{301}.ts"
2266 });
2267 let error = subc_translate_owned("read", canonically_different, project)
2268 .expect_err("different Unicode normalization");
2269 assert_eq!(error.code, "invalid_request");
2270 }
2271
2272 #[test]
2273 fn owned_write_translation_moves_content_buffer() {
2274 let content = "x".repeat(256 * 1024);
2275 let content_ptr = content.as_ptr();
2276 let content_len = content.len();
2277 let mut arguments = Map::new();
2278 arguments.insert(
2279 "filePath".to_string(),
2280 Value::String("src/generated.ts".to_string()),
2281 );
2282 arguments.insert("content".to_string(), Value::String(content));
2283
2284 let translated =
2285 subc_translate_owned("write", Value::Object(arguments), Path::new("/project"))
2286 .expect("write translation succeeds");
2287 let translated_content = translated
2288 .args
2289 .get("content")
2290 .and_then(Value::as_str)
2291 .expect("translated write keeps content");
2292
2293 assert_eq!(translated_content.len(), content_len);
2294 assert_eq!(translated_content.as_ptr(), content_ptr);
2295 }
2296
2297 #[test]
2298 fn edit_normalization_orders_contract_checks_before_path_resolution() {
2299 let project = Path::new("/project");
2300 let conflict = subc_translate_owned(
2301 "edit",
2302 serde_json::json!({
2303 "path": "src/main.ts",
2304 "appendContent": "x",
2305 "edits": "not-json"
2306 }),
2307 project,
2308 )
2309 .expect_err("mode conflict");
2310 assert_eq!(conflict.code, "invalid_request");
2311 assert!(conflict.message.contains("conflicting modes"));
2312
2313 let line_error = subc_translate_owned(
2314 "edit",
2315 serde_json::json!({ "path": 42, "startLine": 1 }),
2316 project,
2317 )
2318 .expect_err("top-level line range");
2319 assert!(line_error.message.contains("startLine"));
2320
2321 let no_mode = subc_translate_owned("edit", serde_json::json!({ "path": "x" }), project)
2322 .expect_err("missing mode");
2323 assert!(no_mode.message.contains("exactly one of"));
2324
2325 let retired_fields = subc_translate_owned(
2326 "edit",
2327 serde_json::json!({ "mode": "write", "file": "src/main.ts" }),
2328 project,
2329 )
2330 .expect_err("retired fields are ordinary unknown keys outside OpenCode aft_edit");
2331 assert_eq!(
2332 retired_fields.message,
2333 "Unrecognized keys: \"file\", \"mode\""
2334 );
2335 }
2336
2337 #[test]
2338 fn edit_normalization_accepts_aliases_and_rejects_ambiguous_scalars() {
2339 let project = Path::new("/project");
2340 let normalized = subc_translate_owned(
2341 "edit",
2342 serde_json::json!({
2343 "filePath": "src/main.ts",
2344 "edits": [{ "oldText": "before", "newText": "after", "occurrence": " +01 " }]
2345 }),
2346 project,
2347 )
2348 .expect("compatibility aliases");
2349 let item = normalized
2350 .args
2351 .get("edits")
2352 .and_then(Value::as_array)
2353 .and_then(|items| items.first())
2354 .expect("translated edit item");
2355 assert_eq!(item.get("match").and_then(Value::as_str), Some("before"));
2356 assert_eq!(item.get("occurrence").and_then(Value::as_u64), Some(1));
2357
2358 for value in ["0", "00", "+0", "1.0", "1e0", "0x1", "-1"] {
2359 let error = subc_translate_owned(
2360 "edit",
2361 serde_json::json!({
2362 "path": "src/main.ts",
2363 "edits": [{ "oldString": "before", "occurrence": value }]
2364 }),
2365 project,
2366 )
2367 .expect_err("invalid occurrence spelling");
2368 assert!(error.message.contains("occurrence"));
2369 }
2370 }
2371
2372 #[test]
2373 fn search_legacy_hint_is_accepted_and_ignored() {
2374 let translated = subc_translate_owned(
2375 "search",
2376 serde_json::json!({
2377 "query": "outside <touser>",
2378 "topK": 5,
2379 "hint": "literal"
2380 }),
2381 Path::new("/project"),
2382 )
2383 .expect("legacy search hint must not reject the request");
2384
2385 assert_eq!(translated.command, "semantic_search");
2386 assert_eq!(
2387 translated.args.get("query").and_then(Value::as_str),
2388 Some("outside <touser>")
2389 );
2390 assert_eq!(
2391 translated.args.get("top_k").and_then(Value::as_u64),
2392 Some(5)
2393 );
2394 assert!(translated.args.get("hint").is_none());
2395 }
2396
2397 #[test]
2402 fn supports_tool_covers_every_translated_arm() {
2403 for name in [
2404 "bash",
2405 "status",
2406 "read",
2407 "write",
2408 "edit",
2409 "apply_patch",
2410 "grep",
2411 "glob",
2412 "search",
2413 "outline",
2414 "zoom",
2415 "inspect",
2416 "callgraph",
2417 "conflicts",
2418 "ast_search",
2419 "ast_replace",
2420 "delete",
2421 "move",
2422 "import",
2423 "refactor",
2424 "safety",
2425 ] {
2426 let err =
2430 subc_translate_owned(name, Value::Object(Map::new()), Path::new("/project")).err();
2431 assert_ne!(
2432 err.as_ref().map(|e| e.code),
2433 Some("unsupported_tool"),
2434 "{name} is in supports_tool but has no translate arm"
2435 );
2436 assert!(
2437 supports_tool(name),
2438 "{name} translates but is missing from supports_tool"
2439 );
2440 }
2441 assert!(!supports_tool("definitely_not_a_tool"));
2443 }
2444}