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