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