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