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 if is_null_or_empty_string(map.get(canonical)) {
59 map.remove(canonical);
60 }
61 if is_null_or_empty_string(map.get(legacy)) {
62 map.remove(legacy);
63 }
64
65 let has_canonical = map.contains_key(canonical);
66 let has_legacy = map.contains_key(legacy);
67 if !has_canonical && !has_legacy {
68 if required {
69 return Err(invalid_request(format!("'{canonical}' is required")));
70 }
71 return Ok(());
72 }
73
74 if has_canonical && has_legacy {
75 let canonical_value = path_string(map.get(canonical), canonical).map(str::to_owned);
76 let legacy_value = path_string(map.get(legacy), legacy).map(str::to_owned);
77 let (Ok(canonical_value), Ok(legacy_value)) = (canonical_value, legacy_value) else {
78 return Err(invalid_request(format!(
79 "Invalid request: '{canonical}' and '{legacy}' must both be non-empty well-formed Unicode strings"
80 )));
81 };
82 if canonical_value != legacy_value {
83 return Err(invalid_request(format!(
84 "Invalid request: '{canonical}' and '{legacy}' must contain equal decoded strings"
85 )));
86 }
87 map.remove(legacy);
88 return Ok(());
89 }
90
91 if has_canonical {
92 path_string(map.get(canonical), canonical)?;
93 } else if let Ok(legacy_value) = path_string(map.get(legacy), legacy) {
94 map.insert(
95 canonical.to_string(),
96 Value::String(legacy_value.to_string()),
97 );
98 map.remove(legacy);
99 } else {
100 path_string(map.get(legacy), legacy)?;
101 }
102 Ok(())
103}
104
105fn normalize_zoom_target_aliases(target: &mut Value, index: usize) -> Result<(), TranslateError> {
106 let Some(object) = target.as_object_mut() else {
107 return Err(invalid_request(format!(
108 "'targets[{index}].path' must be a non-empty string"
109 )));
110 };
111 normalize_path_alias_pair(object, "path", "filePath", true)
112}
113
114fn normalize_zoom_aliases(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
115 normalize_path_alias_pair(map, "path", "filePath", false)?;
116 let Some(targets) = map.get_mut("targets") else {
117 return Ok(());
118 };
119 match targets {
120 Value::Array(items) => {
121 for (index, target) in items.iter_mut().enumerate() {
122 normalize_zoom_target_aliases(target, index)?;
123 }
124 }
125 Value::Object(_) => normalize_zoom_target_aliases(targets, 0)?,
126 _ => {}
127 }
128 Ok(())
129}
130
131fn normalize_path_arguments(bare_name: &str, args: Value) -> Result<Value, TranslateError> {
132 let mut map = match args {
133 Value::Object(map) => map,
134 _ => return Err(invalid_request("tool arguments must be an object")),
135 };
136
137 match bare_name {
138 "read" | "write" | "move" | "import" => {
139 normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
140 }
141 "edit" => normalize_edit_arguments(&mut map)?,
142 "zoom" => normalize_zoom_aliases(&mut map)?,
143 "callgraph" => {
144 normalize_path_alias_pair(&mut map, "path", "filePath", false)?;
145 normalize_path_alias_pair(&mut map, "toPath", "toFile", false)?;
146 }
147 "safety" => normalize_path_alias_pair(&mut map, "path", "filePath", false)?,
148 "grep" | "search" | "conflicts" => {
149 if is_null_or_empty_string(map.get("path")) {
153 map.remove("path");
154 } else if map.contains_key("path") {
155 path_string(map.get("path"), "path")?;
156 }
157 }
158 _ => {}
159 }
160
161 Ok(Value::Object(map))
162}
163
164fn normalize_edit_arguments(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
165 normalize_edit_path_alias(map)?;
166
167 let supplied_line_fields = ["startLine", "endLine"]
168 .into_iter()
169 .filter(|key| map.contains_key(*key))
170 .collect::<Vec<_>>();
171 if !supplied_line_fields.is_empty() {
172 let fields = supplied_line_fields
173 .iter()
174 .map(|field| format!("'{field}'"))
175 .collect::<Vec<_>>()
176 .join(" and ");
177 return Err(invalid_request(format!(
178 "edit: top-level {fields} are invalid; line-range fields are valid only inside 'edits[]'. Use edits: [{{ startLine, endLine, content }}]."
179 )));
180 }
181
182 let unknown_root_keys = map
183 .keys()
184 .filter(|key| {
185 !matches!(
186 key.as_str(),
187 "path"
188 | "filePath"
189 | "appendContent"
190 | "edits"
191 | "symbol"
192 | "content"
193 | "oldString"
194 | "newString"
195 | "replaceAll"
196 | "occurrence"
197 )
198 })
199 .cloned()
200 .collect::<Vec<_>>();
201 if !unknown_root_keys.is_empty() {
202 return Err(invalid_request(format_unknown_keys(unknown_root_keys)));
203 }
204
205 let modes = edit_modes_present(map);
206 if has_orphaned_symbol_content(map) {
207 return Err(invalid_request(
208 "edit: 'content' requires a non-empty string 'symbol' when symbol mode is selected",
209 ));
210 }
211 if modes.len() > 1 {
212 return Err(invalid_request(format!(
213 "edit: conflicting modes: {}. Omit unused optional fields entirely; do not send empty strings or empty arrays for them.",
214 modes.join(", ")
215 )));
216 }
217 let Some(mode) = modes.first().copied() else {
218 return Err(invalid_request(
219 "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.",
220 ));
221 };
222
223 match mode {
224 "appendContent" => {
225 if !matches!(map.get("appendContent"), Some(Value::String(_))) {
226 return Err(invalid_request("edit: 'appendContent' must be a string"));
227 }
228 }
229 "edits" => {
230 let items = parse_edit_array(map.remove("edits"))?;
231 let normalized = items
232 .into_iter()
233 .enumerate()
234 .map(|(index, item)| normalize_edit_item(item, index))
235 .collect::<Result<Vec<_>, _>>()?;
236 map.insert(
237 "edits".to_string(),
238 Value::Array(normalized.into_iter().map(Value::Object).collect()),
239 );
240 }
241 "symbol/content" => {
242 if !matches!(map.get("symbol"), Some(Value::String(_))) {
243 return Err(invalid_request(
244 "edit: 'symbol' must be a string when symbol mode is selected",
245 ));
246 }
247 if !matches!(map.get("content"), Some(Value::String(_))) {
248 return Err(invalid_request(
249 "edit: symbol mode requires both 'symbol' and 'content' string properties",
250 ));
251 }
252 }
253 "oldString/newString" => {
254 let mut item = Map::new();
255 for key in ["oldString", "newString", "replaceAll", "occurrence"] {
256 if let Some(value) = map.get(key) {
257 item.insert(key.to_string(), value.clone());
258 }
259 map.remove(key);
260 }
261 let normalized = normalize_edit_item(Value::Object(item), 0)?;
262 map.insert(
263 "edits".to_string(),
264 Value::Array(vec![Value::Object(normalized)]),
265 );
266 }
267 _ => unreachable!("edit mode list contains an unknown mode"),
268 }
269
270 let path = map
271 .get("path")
272 .ok_or_else(|| invalid_request("'path' is required"))?;
273 path_string(Some(path), "path")?;
274 Ok(())
275}
276
277fn normalize_edit_path_alias(map: &mut Map<String, Value>) -> Result<(), TranslateError> {
278 if is_null_or_empty_string(map.get("path")) {
283 map.remove("path");
284 }
285 if is_null_or_empty_string(map.get("filePath")) {
286 map.remove("filePath");
287 }
288 let has_path = map.contains_key("path");
289 let has_file_path = map.contains_key("filePath");
290 match (has_path, has_file_path) {
291 (true, true) => normalize_path_alias_pair(map, "path", "filePath", false),
292 (false, true) => normalize_path_alias_pair(map, "path", "filePath", false),
293 (false, false) | (true, false) => Ok(()),
294 }
295}
296
297fn edit_modes_present(map: &mut Map<String, Value>) -> Vec<&'static str> {
298 let has_append_content = is_non_empty_string(map.get("appendContent"));
301 if !has_append_content {
302 map.remove("appendContent");
303 }
304
305 let has_edits = normalize_edit_array_sentinels(map);
306 if !has_edits {
307 map.remove("edits");
308 }
309
310 let has_symbol = is_non_empty_string(map.get("symbol"));
311 if !has_symbol {
312 map.remove("symbol");
313 if is_null_or_empty_string(map.get("content")) {
314 map.remove("content");
315 }
316 } else if matches!(map.get("content"), Some(Value::Null)) {
317 map.remove("content");
318 }
319
320 let has_single_edit = is_non_empty_string(map.get("oldString"));
321 if !has_single_edit {
322 for key in ["oldString", "newString", "replaceAll", "occurrence"] {
323 map.remove(key);
324 }
325 } else {
326 for key in ["newString", "replaceAll", "occurrence"] {
327 if matches!(map.get(key), Some(Value::Null)) {
328 map.remove(key);
329 }
330 }
331 }
332
333 let mut modes = Vec::new();
334 if has_append_content {
335 modes.push("appendContent");
336 }
337 if has_edits {
338 modes.push("edits");
339 }
340 if has_symbol {
341 modes.push("symbol/content");
342 }
343 if has_single_edit {
344 modes.push("oldString/newString");
345 }
346 modes
347}
348
349fn is_non_empty_string(value: Option<&Value>) -> bool {
350 matches!(value, Some(Value::String(value)) if !value.is_empty())
351}
352
353fn is_null_or_empty_string(value: Option<&Value>) -> bool {
354 match value {
355 None | Some(Value::Null) => true,
356 Some(Value::String(value)) => value.is_empty(),
357 Some(_) => false,
358 }
359}
360
361fn is_edit_sentinel_item(item: &Value) -> bool {
373 let Some(obj) = item.as_object() else {
374 return false;
375 };
376 let old_string_empty =
378 obj.contains_key("oldString") && is_null_or_empty_string(obj.get("oldString"));
379 if !old_string_empty {
380 return false;
381 }
382 if matches!(obj.get("oldString"), Some(Value::Null))
385 && ["startLine", "endLine"]
386 .iter()
387 .any(|key| !matches!(obj.get(*key), None | Some(Value::Null)))
388 {
389 return false;
390 }
391 is_null_or_empty_string(obj.get("newString"))
395 && is_null_or_empty_string(obj.get("content"))
396 && matches!(
397 obj.get("replaceAll"),
398 None | Some(Value::Null) | Some(Value::Bool(false))
399 )
400 && is_default_occurrence(obj.get("occurrence"))
401}
402
403fn has_meaningful_find_payload(item: &Map<String, Value>) -> bool {
404 is_non_empty_string(item.get("oldString"))
405}
406
407fn is_default_occurrence(value: Option<&Value>) -> bool {
408 match value {
409 None | Some(Value::Null) => true,
410 Some(Value::Number(value)) => value.as_u64() == Some(1),
411 _ => false,
412 }
413}
414
415fn normalize_edit_array_sentinels(map: &mut Map<String, Value>) -> bool {
421 let Some(value) = map.get("edits") else {
422 return false;
423 };
424 match value {
425 Value::Array(items) => {
426 let survivors: Vec<Value> = items
427 .iter()
428 .filter(|item| !is_edit_sentinel_item(item))
429 .cloned()
430 .collect();
431 if survivors.is_empty() {
432 false
433 } else {
434 map.insert("edits".to_string(), Value::Array(survivors));
435 true
436 }
437 }
438 Value::String(raw) if raw.is_empty() => false,
439 Value::String(raw) => match serde_json::from_str::<Value>(raw) {
440 Ok(Value::Array(items)) => {
441 let survivors: Vec<Value> = items
442 .iter()
443 .filter(|item| !is_edit_sentinel_item(item))
444 .cloned()
445 .collect();
446 if survivors.is_empty() {
447 false
448 } else {
449 map.insert("edits".to_string(), Value::Array(survivors));
450 true
451 }
452 }
453 _ => true,
454 },
455 _ => false,
456 }
457}
458
459fn has_orphaned_symbol_content(map: &Map<String, Value>) -> bool {
460 is_non_empty_string(map.get("content")) && !is_non_empty_string(map.get("symbol"))
461}
462
463fn format_unknown_keys(mut keys: Vec<String>) -> String {
464 keys.sort();
465 format!(
466 "Unrecognized keys: {}",
467 keys.iter()
468 .map(|key| format!("\"{key}\""))
469 .collect::<Vec<_>>()
470 .join(", ")
471 )
472}
473
474fn parse_edit_array(value: Option<Value>) -> Result<Vec<Value>, TranslateError> {
475 let Some(value) = value else {
476 return Err(invalid_request("edit: 'edits' must be a non-empty array"));
477 };
478 let value = if let Value::String(raw) = value {
479 serde_json::from_str::<Value>(&raw).map_err(|_| {
480 invalid_request("edit: 'edits' must contain valid JSON representing an array")
481 })?
482 } else {
483 value
484 };
485 let Value::Array(items) = value else {
486 return Err(invalid_request(
487 "edit: 'edits' JSON must have an array root",
488 ));
489 };
490 if items.is_empty() {
491 return Err(invalid_request("edit: 'edits' array must not be empty"));
492 }
493 Ok(items)
494}
495
496fn normalize_edit_item_sentinels(item: &mut Map<String, Value>) {
503 let had_range_fields = ["startLine", "endLine", "content"]
504 .iter()
505 .any(|key| item.contains_key(*key));
506
507 for key in [
510 "oldString",
511 "newString",
512 "replaceAll",
513 "occurrence",
514 "startLine",
515 "endLine",
516 "content",
517 ] {
518 if matches!(item.get(key), Some(Value::Null)) {
519 item.remove(key);
520 }
521 }
522
523 let content_is_empty =
524 matches!(item.get("content"), Some(Value::String(value)) if value.is_empty());
525 if has_meaningful_find_payload(item) && (!item.contains_key("content") || content_is_empty) {
526 for key in ["startLine", "endLine", "content"] {
529 item.remove(key);
530 }
531 if had_range_fields {
535 if matches!(item.get("replaceAll"), Some(Value::Bool(false))) {
536 item.remove("replaceAll");
537 }
538 if is_default_occurrence(item.get("occurrence")) && item.contains_key("occurrence") {
539 item.remove("occurrence");
540 }
541 }
542 return;
543 }
544
545 if !is_non_empty_string(item.get("content")) {
546 return;
547 }
548
549 if matches!(item.get("oldString"), Some(Value::String(value)) if value.is_empty()) {
553 item.remove("oldString");
554 }
555 if matches!(item.get("newString"), Some(Value::String(value)) if value.is_empty()) {
556 item.remove("newString");
557 }
558 if matches!(item.get("replaceAll"), Some(Value::Bool(false))) {
559 item.remove("replaceAll");
560 }
561 if is_default_occurrence(item.get("occurrence")) && item.contains_key("occurrence") {
562 item.remove("occurrence");
563 }
564}
565
566fn normalize_edit_item(value: Value, index: usize) -> Result<Map<String, Value>, TranslateError> {
567 let Value::Object(mut item) = value else {
568 return Err(invalid_request(format!(
569 "edit: edits[{index}] must be an object"
570 )));
571 };
572
573 normalize_item_alias(&mut item, "oldString", "oldText");
574 normalize_item_alias(&mut item, "newString", "newText");
575 normalize_edit_item_sentinels(&mut item);
576
577 let has_find = ["oldString", "newString", "replaceAll", "occurrence"]
578 .iter()
579 .any(|key| item.contains_key(*key));
580 let has_range = ["startLine", "endLine", "content"]
581 .iter()
582 .any(|key| item.contains_key(*key));
583 if has_find && has_range {
584 return Err(invalid_request(format!(
585 "edit: edits[{index}] mixes find/replace and line-range fields"
586 )));
587 }
588
589 if has_find {
590 if !matches!(item.get("oldString"), Some(Value::String(_))) {
591 return Err(invalid_request(format!(
592 "edit: edits[{index}] requires string 'oldString'"
593 )));
594 }
595 if item.contains_key("newString")
596 && !matches!(item.get("newString"), Some(Value::String(_)))
597 {
598 return Err(invalid_request(format!(
599 "edit: edits[{index}].newString must be a string"
600 )));
601 }
602 coerce_edit_scalars(&mut item, index)?;
603 validate_edit_item_keys(&item, index)?;
604 return Ok(item);
605 }
606
607 if has_range {
608 for key in ["startLine", "endLine"] {
609 let valid = item
610 .get(key)
611 .and_then(Value::as_u64)
612 .is_some_and(|value| value >= 1 && value <= MAX_SAFE_INTEGER as u64);
613 if !valid {
614 return Err(invalid_request(format!(
615 "edit: edits[{index}].{key} must be a positive integer"
616 )));
617 }
618 }
619 let start = item.get("startLine").and_then(Value::as_u64).unwrap();
620 let end = item.get("endLine").and_then(Value::as_u64).unwrap();
621 if start > end {
622 return Err(invalid_request(format!(
623 "edit: edits[{index}] requires startLine <= endLine"
624 )));
625 }
626 if !matches!(item.get("content"), Some(Value::String(_))) {
627 return Err(invalid_request(format!(
628 "edit: edits[{index}] requires string 'content'"
629 )));
630 }
631 validate_edit_item_keys(&item, index)?;
632 return Ok(item);
633 }
634
635 Err(invalid_request(format!(
636 "edit: edits[{index}] must be a find/replace or line-range item"
637 )))
638}
639
640fn normalize_item_alias(item: &mut Map<String, Value>, canonical: &str, legacy: &str) {
641 if let Some(legacy_value) = item.remove(legacy) {
642 if !item.contains_key(canonical) {
643 item.insert(canonical.to_string(), legacy_value);
644 }
645 }
646}
647
648fn validate_edit_item_keys(item: &Map<String, Value>, index: usize) -> Result<(), TranslateError> {
649 let unknown = item
650 .keys()
651 .filter(|key| {
652 !matches!(
653 key.as_str(),
654 "oldString"
655 | "newString"
656 | "replaceAll"
657 | "occurrence"
658 | "startLine"
659 | "endLine"
660 | "content"
661 )
662 })
663 .cloned()
664 .collect::<Vec<_>>();
665 if unknown.is_empty() {
666 Ok(())
667 } else {
668 Err(invalid_request(format!(
669 "edit: edits[{index}] contains {}",
670 format_unknown_keys(unknown)
671 )))
672 }
673}
674
675fn coerce_edit_scalars(item: &mut Map<String, Value>, index: usize) -> Result<(), TranslateError> {
676 if item.contains_key("replaceAll") && item.contains_key("occurrence") {
677 return Err(invalid_request(format!(
678 "edit: edits[{index}] cannot contain both 'replaceAll' and 'occurrence'"
679 )));
680 }
681 if let Some(value) = item.get("replaceAll") {
682 let coerced = match value {
683 Value::Bool(value) => Some(*value),
684 Value::Number(number) if number.as_f64() == Some(0.0) => Some(false),
685 Value::Number(number) if number.as_f64() == Some(1.0) => Some(true),
686 Value::String(value) if value == "0" => Some(false),
687 Value::String(value) if value == "1" => Some(true),
688 Value::String(value) if value.eq_ignore_ascii_case("true") => Some(true),
689 Value::String(value) if value.eq_ignore_ascii_case("false") => Some(false),
690 _ => None,
691 };
692 let Some(coerced) = coerced else {
693 return Err(invalid_request(format!(
694 "edit: edits[{index}].replaceAll must be a boolean, true/false string, or 0/1"
695 )));
696 };
697 item.insert("replaceAll".to_string(), Value::Bool(coerced));
698 }
699
700 if item.contains_key("occurrence") {
701 let value = item.get("occurrence").cloned().unwrap();
702 match coerce_edit_occurrence(&value, index)? {
703 Some(value) => {
704 item.insert("occurrence".to_string(), Value::Number(value.into()));
705 }
706 None => {
707 item.remove("occurrence");
708 }
709 }
710 }
711 Ok(())
712}
713
714fn coerce_edit_occurrence(value: &Value, index: usize) -> Result<Option<u64>, TranslateError> {
715 if value.is_null() {
716 return Ok(None);
717 }
718 let parsed = match value {
719 Value::Number(number) => number
720 .as_u64()
721 .filter(|value| *value <= MAX_SAFE_INTEGER as u64)
722 .or_else(|| {
723 number.as_f64().and_then(|value| {
724 (value.is_finite()
725 && value.fract() == 0.0
726 && value >= 1.0
727 && value <= MAX_SAFE_INTEGER as f64)
728 .then_some(value as u64)
729 })
730 }),
731 Value::String(raw) => {
732 let trimmed = raw.trim_matches(|ch: char| ch.is_ascii_whitespace());
733 if trimmed.is_empty() {
734 return Ok(None);
735 }
736 let digits = trimmed.strip_prefix('+').unwrap_or(trimmed);
737 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
738 None
739 } else {
740 digits
741 .parse::<u64>()
742 .ok()
743 .filter(|value| *value <= MAX_SAFE_INTEGER as u64)
744 }
745 }
746 _ => None,
747 };
748 match parsed {
749 Some(value) if value >= 1 => Ok(Some(value)),
750 _ => Err(invalid_request(format!(
751 "edit: edits[{index}].occurrence must be a positive integer"
752 ))),
753 }
754}
755
756fn unsupported_tool(message: impl Into<String>) -> TranslateError {
757 TranslateError {
758 code: "unsupported_tool",
759 message: message.into(),
760 }
761}
762
763fn resolve_home_dir() -> Option<PathBuf> {
764 let raw = crate::environment::non_empty_os_var("HOME")
765 .or_else(|| crate::environment::non_empty_os_var("USERPROFILE"))
766 .map(PathBuf::from)?;
767 Some(raw)
768}
769
770fn expand_tilde(target: &str) -> Cow<'_, str> {
771 if target == "~" {
772 return resolve_home_dir()
773 .map(|h| Cow::Owned(h.to_string_lossy().into_owned()))
774 .unwrap_or(Cow::Borrowed(target));
775 }
776 if let Some(rest) = target.strip_prefix("~/") {
777 if let Some(home) = resolve_home_dir() {
778 return Cow::Owned(home.join(rest).to_string_lossy().into_owned());
779 }
780 }
781 Cow::Borrowed(target)
783}
784
785fn decode_file_url(target: &str) -> Option<String> {
797 let rest = target.strip_prefix("file:")?;
798 let path_part = if let Some(after) = rest.strip_prefix("//") {
799 let (authority, path) = match after.find('/') {
800 Some(index) => after.split_at(index),
801 None => (after, ""),
802 };
803 match authority {
804 "" | "localhost" => path.to_string(),
805 server if cfg!(windows) => format!("//{server}{path}"),
806 _ => return None,
807 }
808 } else {
809 if !rest.starts_with('/') {
811 return None;
812 }
813 rest.to_string()
814 };
815 let decoded = percent_decode(&path_part);
816 if cfg!(windows) {
819 let bytes = decoded.as_bytes();
820 if bytes.len() >= 3
821 && bytes[0] == b'/'
822 && bytes[1].is_ascii_alphabetic()
823 && bytes[2] == b':'
824 {
825 return Some(decoded[1..].to_string());
826 }
827 }
828 Some(decoded)
829}
830
831fn percent_decode(input: &str) -> String {
832 let bytes = input.as_bytes();
833 let mut out = Vec::with_capacity(bytes.len());
834 let mut index = 0;
835 while index < bytes.len() {
836 if bytes[index] == b'%' && index + 2 < bytes.len() {
837 let hex = &input[index + 1..index + 3];
838 if let Ok(value) = u8::from_str_radix(hex, 16) {
839 out.push(value);
840 index += 3;
841 continue;
842 }
843 }
844 out.push(bytes[index]);
845 index += 1;
846 }
847 String::from_utf8_lossy(&out).into_owned()
848}
849
850pub fn resolve_path_from_project_root(project_root: &Path, target: &str) -> PathBuf {
851 let target = decode_file_url(target)
852 .map(std::borrow::Cow::Owned)
853 .unwrap_or(std::borrow::Cow::Borrowed(target));
854 let expanded = expand_tilde(&target);
855 let path = Path::new(expanded.as_ref());
856 let joined = if path.is_absolute() {
857 path.to_path_buf()
858 } else {
859 project_root.join(path)
860 };
861 normalize_lexically(&joined)
862}
863
864fn normalize_lexically(path: &Path) -> PathBuf {
865 use std::path::Component;
866
867 let mut out = PathBuf::new();
868 for component in path.components() {
869 match component {
870 Component::CurDir => {}
871 Component::ParentDir => {
872 if !out.pop() {
873 out.push(component.as_os_str());
874 }
875 }
876 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
877 out.push(component.as_os_str());
878 }
879 }
880 }
881 if out.as_os_str().is_empty() {
882 PathBuf::from(".")
883 } else {
884 out
885 }
886}
887
888fn is_empty_param(value: &Value) -> bool {
889 match value {
890 Value::Null => true,
891 Value::String(s) => s.is_empty(),
892 Value::Array(a) => a.is_empty(),
893 Value::Object(o) => o.is_empty(),
894 _ => false,
895 }
896}
897
898fn coerce_optional_int_result(
899 value: Option<&Value>,
900 param_name: &str,
901 min: i64,
902 max: i64,
903) -> Result<Option<u64>, TranslateError> {
904 let Some(value) = value else {
905 return Ok(None);
906 };
907 if value.is_null()
908 || matches!(value, Value::String(s) if s.is_empty())
909 || matches!(value, Value::Array(a) if a.is_empty())
910 || matches!(value, Value::Object(o) if o.is_empty())
911 {
912 return Ok(None);
913 }
914 if matches!(value, Value::Number(num) if num.as_i64() == Some(0) && min > 0) {
915 return Ok(None);
916 }
917
918 let int_error = || {
919 invalid_request(format!(
920 "{param_name} must be an integer between {min} and {max}"
921 ))
922 };
923 let n = match value {
924 Value::Number(num) => num.as_i64().ok_or_else(int_error)?,
925 Value::String(s) => {
926 let parsed = s.parse::<f64>().map_err(|_| int_error())?;
927 if !parsed.is_finite() || parsed.fract() != 0.0 {
928 return Err(int_error());
929 }
930 parsed as i64
931 }
932 _ => return Err(int_error()),
933 };
934 if n < min || n > max {
935 return Err(invalid_request(format!(
936 "{param_name} must be between {min} and {max}"
937 )));
938 }
939 Ok(Some(n as u64))
940}
941
942fn agent_args_map(args: Value) -> Map<String, Value> {
943 match args {
944 Value::Object(map) => map,
945 _ => Map::new(),
946 }
947}
948
949pub(crate) fn supports_tool(bare_name: &str) -> bool {
950 matches!(
951 bare_name,
952 "bash"
953 | "powershell"
954 | "status"
955 | "read"
956 | "write"
957 | "edit"
958 | "apply_patch"
959 | "grep"
960 | "glob"
961 | "search"
962 | "outline"
963 | "zoom"
964 | "inspect"
965 | "callgraph"
966 | "conflicts"
967 | "ast_search"
968 | "ast_replace"
969 | "delete"
970 | "move"
971 | "import"
972 | "safety"
973 )
974}
975
976fn insert_resolved_file(map: &mut Map<String, Value>, project_root: &Path, file_path: &str) {
977 let resolved = resolve_path_from_project_root(project_root, file_path);
978 map.insert(
979 "file".to_string(),
980 Value::String(resolved.to_string_lossy().into_owned()),
981 );
982}
983
984fn insert_file_or_github_target(
989 map: &mut Map<String, Value>,
990 project_root: &Path,
991 file_path: &str,
992) {
993 if is_github_resource_target(file_path) {
994 map.insert("file".to_string(), Value::String(file_path.to_string()));
995 } else {
996 insert_resolved_file(map, project_root, file_path);
997 }
998}
999
1000fn is_github_resource_target(target: &str) -> bool {
1001 target.starts_with("issue://") || target.starts_with("pr://")
1002}
1003
1004pub fn subc_translate(
1005 bare_name: &str,
1006 agent_args: &Value,
1007 project_root: &Path,
1008) -> Result<Translated, TranslateError> {
1009 subc_translate_owned(bare_name, agent_args.clone(), project_root)
1010}
1011
1012pub fn subc_translate_owned(
1013 bare_name: &str,
1014 agent_args: Value,
1015 project_root: &Path,
1016) -> Result<Translated, TranslateError> {
1017 subc_translate_owned_with_context(
1018 bare_name,
1019 agent_args,
1020 project_root,
1021 TranslateContext::default(),
1022 )
1023}
1024
1025pub fn subc_translate_with_context(
1026 bare_name: &str,
1027 agent_args: &Value,
1028 project_root: &Path,
1029 ctx: TranslateContext,
1030) -> Result<Translated, TranslateError> {
1031 subc_translate_owned_with_context(bare_name, agent_args.clone(), project_root, ctx)
1032}
1033
1034pub fn subc_translate_owned_with_context(
1035 bare_name: &str,
1036 agent_args: Value,
1037 project_root: &Path,
1038 ctx: TranslateContext,
1039) -> Result<Translated, TranslateError> {
1040 if bare_name == "edit" && ctx.effective_hashline {
1041 return crate::hashline::integration::translate_gate_on_edit(&agent_args)
1042 .map(|translation| {
1043 let mut args = translation
1044 .to_native_args()
1045 .as_object()
1046 .cloned()
1047 .expect("hashline native arguments are always an object");
1048 if ctx.preview {
1049 args.insert("preview".to_string(), Value::Bool(true));
1050 }
1051 Translated {
1052 command: translation.command.to_string(),
1053 args,
1054 }
1055 })
1056 .map_err(|rejection| TranslateError {
1057 code: rejection.code.as_str(),
1058 message: format!(
1059 "{} at {}: {}\n{}",
1060 rejection.code.as_str(),
1061 rejection.stage.as_str(),
1062 rejection.message,
1063 rejection.steering
1064 ),
1065 });
1066 }
1067 let agent_args = normalize_path_arguments(bare_name, agent_args)?;
1068 match bare_name {
1069 "bash" => translate_bash(agent_args, project_root),
1070 "powershell" => translate_powershell(agent_args, project_root),
1071 "status" => Ok(Translated {
1072 command: "status".into(),
1073 args: Map::new(),
1074 }),
1075 "read" => translate_read(agent_args, project_root),
1076 "write" => translate_write(agent_args, project_root, ctx),
1077 "edit" => translate_edit(agent_args, project_root, ctx),
1078 "apply_patch" => translate_apply_patch(agent_args),
1079 "grep" => translate_grep(agent_args, project_root),
1080 "glob" => translate_glob(agent_args),
1081 "search" => translate_search(agent_args),
1082 "outline" => translate_outline(agent_args, project_root),
1083 "zoom" => translate_zoom(agent_args, project_root),
1084 "inspect" => translate_inspect(agent_args, project_root),
1085 "callgraph" => translate_callgraph(agent_args, project_root),
1086 "conflicts" => translate_conflicts(agent_args),
1087 "ast_search" => translate_ast_search(agent_args),
1088 "ast_replace" => translate_ast_replace(agent_args),
1089 "delete" => translate_delete(agent_args, project_root),
1090 "move" => translate_move(agent_args, project_root),
1091 "import" => translate_import(agent_args),
1092 "safety" => translate_safety(agent_args, project_root),
1093 other => Err(unsupported_tool(format!(
1094 "subc_translate: unsupported tool {other:?}"
1095 ))),
1096 }
1097}
1098
1099fn coerce_boolean(value: &Value) -> bool {
1100 match value {
1101 Value::Bool(value) => *value,
1102 Value::Number(num) => num.as_i64() == Some(1) || num.as_u64() == Some(1),
1103 Value::String(raw) => {
1104 let normalized = raw.trim().to_ascii_lowercase();
1105 normalized == "true" || normalized == "1"
1106 }
1107 _ => false,
1108 }
1109}
1110
1111fn translate_bash(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1112 let mut map_in = agent_args_map(args);
1113 if let Some(Value::Object(params)) = map_in.remove("params") {
1114 map_in = params;
1115 }
1116 let command = map_in
1117 .get("command")
1118 .and_then(Value::as_str)
1119 .ok_or_else(|| invalid_request("'command' is required"))?;
1120
1121 let mut out = Map::new();
1122 out.insert("command".to_string(), Value::String(command.to_string()));
1123
1124 if let Some(shell) = map_in.get("shell") {
1125 if shell.as_str() != Some("powershell") {
1126 return Err(invalid_request("bash: 'shell' must be 'powershell'"));
1127 }
1128 out.insert("shell".to_string(), Value::String("powershell".to_string()));
1129 }
1130
1131 if let Some(timeout) =
1132 coerce_optional_int_result(map_in.get("timeout"), "timeout", 1, MAX_SAFE_INTEGER)?
1133 {
1134 out.insert("timeout".to_string(), Value::Number(timeout.into()));
1135 }
1136
1137 if let Some(workdir) = map_in
1138 .get("workdir")
1139 .and_then(Value::as_str)
1140 .filter(|value| !value.is_empty())
1141 {
1142 let resolved = resolve_path_from_project_root(project_root, workdir);
1143 out.insert(
1144 "workdir".to_string(),
1145 Value::String(resolved.to_string_lossy().into_owned()),
1146 );
1147 }
1148
1149 if let Some(description) = map_in
1150 .get("description")
1151 .and_then(Value::as_str)
1152 .filter(|value| !value.is_empty())
1153 {
1154 out.insert(
1155 "description".to_string(),
1156 Value::String(description.to_string()),
1157 );
1158 }
1159
1160 let background = map_in.get("background").is_some_and(coerce_boolean);
1161 let pty = map_in.get("pty").is_some_and(coerce_boolean);
1162 let wait = map_in.get("wait").is_some_and(coerce_boolean);
1163 if wait && pty {
1164 return Err(invalid_request(
1165 "bash: wait:true cannot be used with pty:true because PTY sessions run in background",
1166 ));
1167 }
1168 if wait && background {
1169 return Err(invalid_request(
1170 "bash: wait:true cannot be used with background:true",
1171 ));
1172 }
1173 out.insert("background".to_string(), Value::Bool(background));
1174 out.insert("pty".to_string(), Value::Bool(pty));
1175 out.insert("wait".to_string(), Value::Bool(wait));
1176 out.insert(
1177 "notify_on_completion".to_string(),
1178 Value::Bool(background || pty),
1179 );
1180
1181 if let Some(rows) = coerce_optional_int_result(
1182 map_in.get("ptyRows").or_else(|| map_in.get("pty_rows")),
1183 "ptyRows",
1184 1,
1185 60,
1186 )? {
1187 out.insert("pty_rows".to_string(), Value::Number(rows.into()));
1188 }
1189 if let Some(cols) = coerce_optional_int_result(
1190 map_in.get("ptyCols").or_else(|| map_in.get("pty_cols")),
1191 "ptyCols",
1192 1,
1193 140,
1194 )? {
1195 out.insert("pty_cols".to_string(), Value::Number(cols.into()));
1196 }
1197
1198 if let Some(compressed) = map_in.get("compressed") {
1199 out.insert(
1200 "compressed".to_string(),
1201 Value::Bool(coerce_boolean(compressed)),
1202 );
1203 }
1204
1205 let foreground_orchestrate = map_in
1206 .get("foreground_orchestrate")
1207 .map(coerce_boolean)
1208 .unwrap_or(true);
1209 let block_to_completion = map_in
1210 .get("block_to_completion")
1211 .map(coerce_boolean)
1212 .unwrap_or(false);
1213 out.insert(
1214 "foreground_orchestrate".to_string(),
1215 Value::Bool(foreground_orchestrate),
1216 );
1217 out.insert(
1218 "block_to_completion".to_string(),
1219 Value::Bool(block_to_completion),
1220 );
1221
1222 if let Some(permissions_granted) = map_in.get("permissions_granted") {
1223 out.insert(
1224 "permissions_granted".to_string(),
1225 permissions_granted.clone(),
1226 );
1227 }
1228 if let Some(permissions_requested) = map_in.get("permissions_requested") {
1229 out.insert(
1230 "permissions_requested".to_string(),
1231 Value::Bool(coerce_boolean(permissions_requested)),
1232 );
1233 }
1234 if let Some(env) = map_in.get("env") {
1235 out.insert("env".to_string(), env.clone());
1236 }
1237 if let Some(sandbox) = map_in.get("sandbox") {
1238 if sandbox.as_str() != Some("host") {
1239 return Err(invalid_request("bash: 'sandbox' must be 'host'"));
1240 }
1241 out.insert("sandbox".to_string(), sandbox.clone());
1242 }
1243
1244 Ok(Translated {
1245 command: "bash".into(),
1246 args: out,
1247 })
1248}
1249
1250fn translate_powershell(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1251 let mut translated = translate_bash(args, project_root)?;
1252 translated
1253 .args
1254 .insert("shell".to_string(), Value::String("powershell".to_string()));
1255 Ok(translated)
1256}
1257
1258fn translate_callgraph(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1259 let map_in = agent_args_map(args);
1260 let op = map_in
1261 .get("op")
1262 .and_then(Value::as_str)
1263 .filter(|s| !s.is_empty())
1264 .ok_or_else(|| invalid_request("'op' is required"))?;
1265 if !matches!(
1266 op,
1267 "call_tree" | "callers" | "trace_to" | "trace_to_symbol" | "impact" | "trace_data"
1268 ) {
1269 return Err(invalid_request(format!("callgraph: invalid op '{op}'")));
1270 }
1271
1272 let file_path = map_in
1273 .get("path")
1274 .and_then(Value::as_str)
1275 .filter(|s| !s.is_empty())
1276 .ok_or_else(|| invalid_request("'path' is required"))?;
1277 let symbol = map_in
1278 .get("symbol")
1279 .and_then(Value::as_str)
1280 .filter(|s| !s.is_empty())
1281 .ok_or_else(|| invalid_request("'symbol' is required"))?;
1282
1283 if op == "trace_data" && map_in.get("expression").is_none_or(is_empty_param) {
1284 return Err(invalid_request(
1285 "'expression' is required for 'trace_data' op",
1286 ));
1287 }
1288 if op == "trace_to_symbol" && map_in.get("toSymbol").is_none_or(is_empty_param) {
1289 return Err(invalid_request(
1290 "'toSymbol' is required for 'trace_to_symbol' op",
1291 ));
1292 }
1293
1294 let mut out = Map::new();
1295 insert_resolved_file(&mut out, project_root, file_path);
1296 out.insert("symbol".to_string(), Value::String(symbol.to_string()));
1297
1298 if let Some(depth) =
1299 coerce_optional_int_result(map_in.get("depth"), "depth", 1, 9_007_199_254_740_991)?
1300 {
1301 out.insert("depth".to_string(), Value::Number(depth.into()));
1302 }
1303 if let Some(expression) = map_in.get("expression") {
1304 if !is_empty_param(expression) {
1305 out.insert("expression".to_string(), expression.clone());
1306 }
1307 }
1308 if let Some(to_symbol) = map_in.get("toSymbol") {
1309 if !is_empty_param(to_symbol) {
1310 out.insert("toSymbol".to_string(), to_symbol.clone());
1311 }
1312 }
1313 if let Some(to_file) = map_in.get("toPath") {
1314 if !is_empty_param(to_file) {
1315 let to_file = to_file
1316 .as_str()
1317 .ok_or_else(|| invalid_request("'toPath' must be a string"))?;
1318 let resolved = resolve_path_from_project_root(project_root, to_file);
1319 out.insert(
1320 "toFile".to_string(),
1321 Value::String(resolved.to_string_lossy().into_owned()),
1322 );
1323 }
1324 }
1325 if let Some(include_tests) = map_in.get("includeTests") {
1326 if !is_empty_param(include_tests) {
1327 out.insert(
1328 "include_tests".to_string(),
1329 Value::Bool(coerce_boolean(include_tests)),
1330 );
1331 }
1332 }
1333
1334 Ok(Translated {
1335 command: op.to_string(),
1336 args: out,
1337 })
1338}
1339
1340fn insert_common_mutation_flags(out: &mut Map<String, Value>, ctx: TranslateContext) {
1341 out.insert(
1342 "diagnostics".to_string(),
1343 Value::Bool(ctx.diagnostics_on_edit),
1344 );
1345 out.insert("include_diff_content".to_string(), Value::Bool(true));
1346 out.insert("preview".to_string(), Value::Bool(ctx.preview));
1347}
1348
1349fn translate_read(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1350 let map_in = agent_args_map(args);
1351 let file_path = map_in
1352 .get("path")
1353 .and_then(Value::as_str)
1354 .filter(|s| !s.is_empty())
1355 .ok_or_else(|| invalid_request("'path' is required"))?;
1356
1357 let mut out = Map::new();
1358 insert_file_or_github_target(&mut out, project_root, file_path);
1359
1360 let mut start_line = map_in.get("startLine").and_then(Value::as_u64);
1361 let mut end_line = map_in.get("endLine").and_then(Value::as_u64);
1362
1363 if start_line.is_none() {
1364 if let Some(offset) = map_in.get("offset").and_then(Value::as_u64) {
1365 start_line = Some(offset);
1366 if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
1367 end_line = Some(offset.saturating_add(limit).saturating_sub(1));
1368 }
1369 }
1370 }
1371
1372 if let Some(sl) = start_line {
1373 out.insert("start_line".to_string(), Value::Number(sl.into()));
1374 }
1375 if let Some(el) = end_line {
1376 out.insert("end_line".to_string(), Value::Number(el.into()));
1377 }
1378 if map_in.get("offset").is_none() {
1379 if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
1380 out.insert("limit".to_string(), Value::Number(limit.into()));
1381 }
1382 }
1383 if let Some(vision_capability) = map_in.get("vision_capability").and_then(Value::as_bool) {
1384 out.insert(
1385 "vision_capability".to_string(),
1386 Value::Bool(vision_capability),
1387 );
1388 }
1389
1390 Ok(Translated {
1391 command: "read".into(),
1392 args: out,
1393 })
1394}
1395
1396fn translate_write(
1397 args: Value,
1398 project_root: &Path,
1399 ctx: TranslateContext,
1400) -> Result<Translated, TranslateError> {
1401 let mut map_in = agent_args_map(args);
1402 let file_path = match map_in.remove("path") {
1403 Some(Value::String(path)) if !path.is_empty() => path,
1404 _ => return Err(invalid_request("'path' is required")),
1405 };
1406 let content = match map_in.remove("content") {
1407 Some(Value::String(content)) => content,
1408 _ => return Err(invalid_request("write: missing required param 'content'")),
1409 };
1410
1411 let mut out = Map::new();
1412 insert_resolved_file(&mut out, project_root, &file_path);
1413 out.insert("content".to_string(), Value::String(content));
1414 out.insert("create_dirs".to_string(), Value::Bool(true));
1415 insert_common_mutation_flags(&mut out, ctx);
1416
1417 Ok(Translated {
1418 command: "write".into(),
1419 args: out,
1420 })
1421}
1422
1423fn translate_edit(
1424 args: Value,
1425 project_root: &Path,
1426 ctx: TranslateContext,
1427) -> Result<Translated, TranslateError> {
1428 let map_in = agent_args_map(args);
1429
1430 if map_in.get("startLine").is_some() || map_in.get("endLine").is_some() {
1431 return Err(invalid_request(
1432 "edit: 'startLine'/'endLine' are not top-level parameters. \
1433 For line-range edits, nest them inside the `edits` array. \
1434 For find/replace, use 'oldString'/'newString'.",
1435 ));
1436 }
1437
1438 let file_path = map_in
1439 .get("path")
1440 .and_then(Value::as_str)
1441 .filter(|s| !s.is_empty())
1442 .ok_or_else(|| invalid_request("'path' is required"))?;
1443
1444 let file_str = resolve_path_from_project_root(project_root, file_path)
1445 .to_string_lossy()
1446 .into_owned();
1447
1448 if let Some(append) = map_in.get("appendContent").and_then(Value::as_str) {
1449 let mut out = Map::new();
1450 out.insert("file".to_string(), Value::String(file_str));
1451 out.insert("op".to_string(), Value::String("append".into()));
1452 out.insert(
1453 "append_content".to_string(),
1454 Value::String(append.to_string()),
1455 );
1456 out.insert("create_dirs".to_string(), Value::Bool(true));
1457 insert_common_mutation_flags(&mut out, ctx);
1458 return Ok(Translated {
1459 command: "edit_match".into(),
1460 args: out,
1461 });
1462 }
1463
1464 if let Some(edits) = map_in.get("edits").and_then(Value::as_array) {
1465 if path_is_glob_pattern(file_path) {
1470 if let [single] = edits.as_slice() {
1471 if let Some(obj) = single.as_object() {
1472 let is_find_replace = obj.contains_key("oldString")
1473 && !obj.contains_key("startLine")
1474 && !obj.contains_key("endLine");
1475 if is_find_replace {
1476 return translate_single_edit_match(obj, file_str, ctx);
1477 }
1478 }
1479 }
1480 return Err(invalid_request(
1481 "edit: glob targets support exactly one find/replace edit \
1482 (oldString/newString); line-range and multi-item batches \
1483 need a concrete file path",
1484 ));
1485 }
1486 let mut out = Map::new();
1487 out.insert("file".to_string(), Value::String(file_str));
1488 let translated_edits: Vec<Value> = edits
1489 .iter()
1490 .filter_map(|edit| {
1491 let obj = edit.as_object()?;
1492 let mut t = Map::new();
1493 for (key, value) in obj {
1494 let native_key = match key.as_str() {
1495 "oldString" => "match",
1496 "newString" => "replacement",
1497 "startLine" => "line_start",
1498 "endLine" => "line_end",
1499 other => other,
1500 };
1501 t.insert(native_key.to_string(), value.clone());
1502 }
1503 Some(Value::Object(t))
1504 })
1505 .collect();
1506 out.insert("edits".to_string(), Value::Array(translated_edits));
1507 insert_common_mutation_flags(&mut out, ctx);
1508 return Ok(Translated {
1509 command: "batch".into(),
1510 args: out,
1511 });
1512 }
1513
1514 let symbol_is_string = map_in.get("symbol").and_then(Value::as_str).is_some();
1515 let old_string_is_string = map_in.get("oldString").and_then(Value::as_str).is_some();
1516 let has_content = map_in.get("content").is_some();
1517
1518 if symbol_is_string && !old_string_is_string && has_content {
1519 let mut out = Map::new();
1520 out.insert("file".to_string(), Value::String(file_str));
1521 out.insert(
1522 "symbol".to_string(),
1523 map_in.get("symbol").cloned().unwrap_or(Value::Null),
1524 );
1525 out.insert("operation".to_string(), Value::String("replace".into()));
1526 out.insert(
1527 "content".to_string(),
1528 map_in.get("content").cloned().unwrap_or(Value::Null),
1529 );
1530 insert_common_mutation_flags(&mut out, ctx);
1531 return Ok(Translated {
1532 command: "edit_symbol".into(),
1533 args: out,
1534 });
1535 }
1536
1537 if old_string_is_string {
1538 return translate_single_edit_match(&map_in, file_str, ctx);
1539 }
1540
1541 Err(invalid_request(
1542 "edit: no edit mode resolved from arguments.",
1543 ))
1544}
1545
1546fn path_is_glob_pattern(path: &str) -> bool {
1548 path.contains('*') || path.contains('?') || path.contains('{') || path.contains('[')
1549}
1550
1551fn translate_single_edit_match(
1554 fields: &Map<String, Value>,
1555 file_str: String,
1556 ctx: TranslateContext,
1557) -> Result<Translated, TranslateError> {
1558 let mut out = Map::new();
1559 out.insert("file".to_string(), Value::String(file_str));
1560 out.insert(
1561 "match".to_string(),
1562 Value::String(
1563 fields
1564 .get("oldString")
1565 .and_then(Value::as_str)
1566 .unwrap_or("")
1567 .to_string(),
1568 ),
1569 );
1570 let replacement = fields
1571 .get("newString")
1572 .and_then(Value::as_str)
1573 .unwrap_or("");
1574 out.insert(
1575 "replacement".to_string(),
1576 Value::String(replacement.to_string()),
1577 );
1578 if let Some(v) = fields.get("replaceAll") {
1579 out.insert("replace_all".to_string(), v.clone());
1580 }
1581 if let Some(v) = fields.get("occurrence") {
1582 out.insert("occurrence".to_string(), v.clone());
1583 }
1584 insert_common_mutation_flags(&mut out, ctx);
1585 Ok(Translated {
1586 command: "edit_match".into(),
1587 args: out,
1588 })
1589}
1590
1591fn translate_apply_patch(args: Value) -> Result<Translated, TranslateError> {
1592 let map_in = agent_args_map(args);
1593 let patch_text = map_in
1594 .get("patchText")
1595 .and_then(Value::as_str)
1596 .filter(|s| !s.is_empty())
1597 .ok_or_else(|| invalid_request("apply_patch: missing required param 'patchText'"))?;
1598
1599 let mut out = Map::new();
1600 out.insert(
1601 "patch_text".to_string(),
1602 Value::String(patch_text.to_string()),
1603 );
1604 Ok(Translated {
1605 command: "apply_patch".into(),
1606 args: out,
1607 })
1608}
1609
1610fn translate_grep(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1611 let map_in = agent_args_map(args);
1612 let pattern = map_in
1613 .get("pattern")
1614 .and_then(Value::as_str)
1615 .filter(|s| !s.is_empty())
1616 .ok_or_else(|| invalid_request("grep: missing required param 'pattern'"))?;
1617
1618 let mut out = Map::new();
1619 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1620 out.insert("case_sensitive".to_string(), Value::Bool(true));
1621 if let Some(include) = map_in.get("include") {
1622 if !is_empty_param(include) {
1623 let include_arg = include.as_str().ok_or_else(|| {
1624 invalid_request("grep: 'include' must be a comma-separated string")
1625 })?;
1626 let includes = split_include_arg(include_arg)
1627 .into_iter()
1628 .map(|pattern| Value::String(normalize_glob(&pattern)))
1629 .collect::<Vec<_>>();
1630 if !includes.is_empty() {
1631 out.insert("include".to_string(), Value::Array(includes));
1632 }
1633 }
1634 }
1635 if let Some(path_val) = map_in.get("path") {
1636 if !is_empty_param(path_val) {
1637 if let Some(path_str) = path_val.as_str() {
1638 out.insert(
1639 "path".to_string(),
1640 Value::String(resolve_grep_path_arg(project_root, path_str)),
1641 );
1642 }
1643 }
1644 }
1645 out.insert("max_results".to_string(), Value::Number(100u64.into()));
1646
1647 Ok(Translated {
1648 command: "grep".into(),
1649 args: out,
1650 })
1651}
1652
1653fn translate_ast_search(args: Value) -> Result<Translated, TranslateError> {
1654 let map_in = agent_args_map(args);
1655 let pattern = map_in
1656 .get("pattern")
1657 .and_then(Value::as_str)
1658 .filter(|s| !s.is_empty())
1659 .ok_or_else(|| invalid_request("ast_search: missing required param 'pattern'"))?;
1660 let lang = map_in
1661 .get("lang")
1662 .and_then(Value::as_str)
1663 .filter(|s| !s.is_empty())
1664 .ok_or_else(|| invalid_request("ast_search: missing required param 'lang'"))?;
1665
1666 let mut out = Map::new();
1667 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1668 out.insert("lang".to_string(), Value::String(lang.to_string()));
1669 insert_non_empty_array(&mut out, &map_in, "paths");
1670 insert_non_empty_array(&mut out, &map_in, "globs");
1671 if let Some(context) = coerce_optional_int_result(
1672 map_in.get("contextLines"),
1673 "contextLines",
1674 1,
1675 9_007_199_254_740_991,
1676 )? {
1677 out.insert("context".to_string(), Value::Number(context.into()));
1678 }
1679
1680 Ok(Translated {
1681 command: "ast_search".into(),
1682 args: out,
1683 })
1684}
1685
1686fn translate_ast_replace(args: Value) -> Result<Translated, TranslateError> {
1687 let map_in = agent_args_map(args);
1688 let pattern = map_in
1689 .get("pattern")
1690 .and_then(Value::as_str)
1691 .filter(|s| !s.is_empty())
1692 .ok_or_else(|| invalid_request("ast_replace: missing required param 'pattern'"))?;
1693 let rewrite = map_in
1694 .get("rewrite")
1695 .and_then(Value::as_str)
1696 .ok_or_else(|| invalid_request("ast_replace: missing required param 'rewrite'"))?;
1697 let lang = map_in
1698 .get("lang")
1699 .and_then(Value::as_str)
1700 .filter(|s| !s.is_empty())
1701 .ok_or_else(|| invalid_request("ast_replace: missing required param 'lang'"))?;
1702
1703 let mut out = Map::new();
1704 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1705 out.insert("rewrite".to_string(), Value::String(rewrite.to_string()));
1706 out.insert("lang".to_string(), Value::String(lang.to_string()));
1707 insert_non_empty_array(&mut out, &map_in, "paths");
1708 insert_non_empty_array(&mut out, &map_in, "globs");
1709 let dry_run = map_in
1710 .get("dryRun")
1711 .or_else(|| map_in.get("dry_run"))
1712 .is_some_and(coerce_boolean);
1713 out.insert("dry_run".to_string(), Value::Bool(dry_run));
1714
1715 Ok(Translated {
1716 command: "ast_replace".into(),
1717 args: out,
1718 })
1719}
1720
1721fn insert_present_renamed(
1722 out: &mut Map<String, Value>,
1723 map_in: &Map<String, Value>,
1724 from: &str,
1725 to: &str,
1726) {
1727 if let Some(value) = map_in.get(from) {
1728 out.insert(to.to_string(), value.clone());
1729 }
1730}
1731
1732fn translate_delete(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1733 let map_in = agent_args_map(args);
1734 let files = map_in
1735 .get("files")
1736 .and_then(Value::as_array)
1737 .filter(|items| !items.is_empty())
1738 .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
1739
1740 let mut resolved_files = Vec::with_capacity(files.len());
1741 for file in files {
1742 let file = file
1743 .as_str()
1744 .filter(|path| !path.is_empty())
1745 .ok_or_else(|| invalid_request("delete: 'files' must be a non-empty array of paths"))?;
1746 let resolved = resolve_path_from_project_root(project_root, file);
1747 resolved_files.push(Value::String(resolved.to_string_lossy().into_owned()));
1748 }
1749
1750 let mut out = Map::new();
1751 out.insert("files".to_string(), Value::Array(resolved_files));
1752 out.insert(
1753 "recursive".to_string(),
1754 Value::Bool(map_in.get("recursive").is_some_and(coerce_boolean)),
1755 );
1756
1757 Ok(Translated {
1758 command: "delete_file".into(),
1759 args: out,
1760 })
1761}
1762
1763fn translate_move(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1764 let map_in = agent_args_map(args);
1765 let file_path = map_in
1766 .get("path")
1767 .and_then(Value::as_str)
1768 .filter(|s| !s.is_empty())
1769 .ok_or_else(|| invalid_request("aft_move: missing required param 'path'"))?;
1770 let destination = map_in
1771 .get("destination")
1772 .and_then(Value::as_str)
1773 .filter(|s| !s.is_empty())
1774 .ok_or_else(|| invalid_request("aft_move: missing required param 'destination'"))?;
1775
1776 let file_path = resolve_path_from_project_root(project_root, file_path);
1777 let destination = resolve_path_from_project_root(project_root, destination);
1778
1779 let mut out = Map::new();
1780 out.insert(
1781 "file".to_string(),
1782 Value::String(file_path.to_string_lossy().into_owned()),
1783 );
1784 out.insert(
1785 "destination".to_string(),
1786 Value::String(destination.to_string_lossy().into_owned()),
1787 );
1788
1789 Ok(Translated {
1790 command: "move_file".into(),
1791 args: out,
1792 })
1793}
1794
1795fn translate_import(args: Value) -> Result<Translated, TranslateError> {
1796 let map_in = agent_args_map(args);
1797 let op = map_in
1798 .get("op")
1799 .and_then(Value::as_str)
1800 .ok_or_else(|| invalid_request("aft_import: missing required param 'op'"))?;
1801 let command = match op {
1802 "add" => "add_import",
1803 "remove" => "remove_import",
1804 "organize" => "organize_imports",
1805 other => {
1806 return Err(invalid_request(format!(
1807 "aft_import: invalid op {other:?}; expected 'add', 'remove', or 'organize'"
1808 )));
1809 }
1810 };
1811
1812 let file_path = map_in
1813 .get("path")
1814 .and_then(Value::as_str)
1815 .filter(|s| !s.is_empty())
1816 .ok_or_else(|| invalid_request("aft_import: missing required param 'filePath'"))?;
1817
1818 if matches!(op, "add" | "remove") && map_in.get("module").map_or(true, is_empty_param) {
1819 return Err(invalid_request(format!(
1820 "'module' is required for '{op}' op"
1821 )));
1822 }
1823
1824 let mut out = Map::new();
1825 out.insert("file".to_string(), Value::String(file_path.to_string()));
1826 insert_present_renamed(&mut out, &map_in, "module", "module");
1827 insert_present_renamed(&mut out, &map_in, "names", "names");
1828 insert_present_renamed(&mut out, &map_in, "defaultImport", "default_import");
1829 insert_present_renamed(&mut out, &map_in, "namespace", "namespace");
1830 insert_present_renamed(&mut out, &map_in, "alias", "alias");
1831 insert_present_renamed(&mut out, &map_in, "modifiers", "modifiers");
1832 insert_present_renamed(&mut out, &map_in, "importKind", "import_kind");
1833 insert_present_renamed(&mut out, &map_in, "typeOnly", "type_only");
1834 insert_present_renamed(&mut out, &map_in, "removeName", "name");
1835 insert_present_renamed(&mut out, &map_in, "validate", "validate");
1836
1837 Ok(Translated {
1838 command: command.into(),
1839 args: out,
1840 })
1841}
1842
1843fn translate_safety(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
1844 let map_in = agent_args_map(args);
1845 let op = map_in
1846 .get("op")
1847 .and_then(Value::as_str)
1848 .ok_or_else(|| invalid_request("aft_safety: missing required param 'op'"))?;
1849 let command = match op {
1850 "undo" => "undo",
1851 "history" => "edit_history",
1852 "checkpoint" => "checkpoint",
1853 "restore" => "restore_checkpoint",
1854 "list" => "list_checkpoints",
1855 other => {
1856 return Err(invalid_request(format!(
1857 "aft_safety: invalid op {other:?}; expected 'undo', 'history', 'checkpoint', 'restore', or 'list'"
1858 )));
1859 }
1860 };
1861
1862 if op == "history" && map_in.get("path").and_then(Value::as_str).is_none() {
1863 return Err(invalid_request("'path' is required for 'history' op"));
1864 }
1865 if matches!(op, "checkpoint" | "restore")
1866 && map_in.get("name").and_then(Value::as_str).is_none()
1867 {
1868 return Err(invalid_request(format!("'name' is required for '{op}' op")));
1869 }
1870
1871 let resolve_path = |value: &Value| -> Result<Value, TranslateError> {
1872 let path = value
1873 .as_str()
1874 .filter(|path| !path.is_empty())
1875 .ok_or_else(|| invalid_request("aft_safety: paths must be non-empty strings"))?;
1876 Ok(Value::String(
1877 resolve_path_from_project_root(project_root, path)
1878 .to_string_lossy()
1879 .into_owned(),
1880 ))
1881 };
1882
1883 let mut out = Map::new();
1884 insert_present_renamed(&mut out, &map_in, "name", "name");
1885 let files = map_in
1886 .get("files")
1887 .and_then(Value::as_array)
1888 .filter(|items| !items.is_empty())
1889 .map(|items| {
1890 items
1891 .iter()
1892 .map(resolve_path)
1893 .collect::<Result<Vec<_>, _>>()
1894 })
1895 .transpose()?;
1896
1897 if op == "checkpoint" {
1898 if let Some(files) = files {
1899 out.insert("files".to_string(), Value::Array(files));
1900 } else if let Some(file_path) = map_in.get("path") {
1901 out.insert(
1902 "files".to_string(),
1903 Value::Array(vec![resolve_path(file_path)?]),
1904 );
1905 }
1906 } else {
1907 if let Some(file_path) = map_in.get("path") {
1908 out.insert("file".to_string(), resolve_path(file_path)?);
1909 }
1910 if let Some(files) = files {
1911 out.insert("files".to_string(), Value::Array(files));
1912 }
1913 }
1914
1915 Ok(Translated {
1916 command: command.into(),
1917 args: out,
1918 })
1919}
1920
1921fn insert_non_empty_array(out: &mut Map<String, Value>, map_in: &Map<String, Value>, key: &str) {
1922 if let Some(value) = map_in.get(key) {
1923 if let Some(items) = value.as_array() {
1924 if !items.is_empty() {
1925 out.insert(key.to_string(), Value::Array(items.clone()));
1926 }
1927 }
1928 }
1929}
1930
1931fn translate_glob(args: Value) -> Result<Translated, TranslateError> {
1932 let map_in = agent_args_map(args);
1933 let pattern = map_in
1934 .get("pattern")
1935 .and_then(Value::as_str)
1936 .filter(|s| !s.is_empty())
1937 .ok_or_else(|| invalid_request("glob: missing required param 'pattern'"))?;
1938
1939 let mut out = Map::new();
1940 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
1941 if let Some(path_val) = map_in.get("path") {
1942 if !is_empty_param(path_val) {
1943 if let Some(path_str) = path_val.as_str() {
1944 out.insert("path".to_string(), Value::String(path_str.to_string()));
1945 }
1946 }
1947 }
1948
1949 Ok(Translated {
1950 command: "glob".into(),
1951 args: out,
1952 })
1953}
1954
1955fn normalize_glob(pattern: &str) -> String {
1956 if !pattern.contains('/') && !pattern.starts_with("**/") {
1957 format!("**/{pattern}")
1958 } else {
1959 pattern.to_string()
1960 }
1961}
1962
1963fn split_include_arg(raw: &str) -> Vec<String> {
1964 let mut out = Vec::new();
1965 let mut depth = 0usize;
1966 let mut buf = String::new();
1967 for ch in raw.chars() {
1968 match ch {
1969 '{' => {
1970 depth += 1;
1971 buf.push(ch);
1972 }
1973 '}' => {
1974 depth = depth.saturating_sub(1);
1975 buf.push(ch);
1976 }
1977 ',' if depth == 0 => {
1978 let trimmed = buf.trim();
1979 if !trimmed.is_empty() {
1980 out.push(trimmed.to_string());
1981 }
1982 buf.clear();
1983 }
1984 _ => buf.push(ch),
1985 }
1986 }
1987 let trimmed = buf.trim();
1988 if !trimmed.is_empty() {
1989 out.push(trimmed.to_string());
1990 }
1991 out
1992}
1993
1994fn search_path_exists(project_root: &Path, raw: &str) -> bool {
1995 resolve_path_from_project_root(project_root, raw).exists()
1996}
1997
1998fn split_search_path_arg(project_root: &Path, raw: &str) -> Vec<String> {
1999 if search_path_exists(project_root, raw) || !raw.chars().any(char::is_whitespace) {
2000 return vec![raw.to_string()];
2001 }
2002
2003 let fragments = raw
2004 .split_whitespace()
2005 .filter(|fragment| !fragment.is_empty())
2006 .collect::<Vec<_>>();
2007 if fragments.len() < 2 {
2008 return vec![raw.to_string()];
2009 }
2010
2011 let existing = fragments
2012 .iter()
2013 .filter(|fragment| search_path_exists(project_root, fragment))
2014 .map(|fragment| (*fragment).to_string())
2015 .collect::<Vec<_>>();
2016 if existing.is_empty() {
2017 vec![raw.to_string()]
2018 } else {
2019 existing
2020 }
2021}
2022
2023fn resolve_grep_path_arg(project_root: &Path, raw: &str) -> String {
2024 split_search_path_arg(project_root, raw)
2025 .iter()
2026 .map(|target| {
2027 resolve_path_from_project_root(project_root, target)
2028 .to_string_lossy()
2029 .into_owned()
2030 })
2031 .collect::<Vec<_>>()
2032 .join(" ")
2033}
2034
2035fn translate_search(args: Value) -> Result<Translated, TranslateError> {
2036 let map_in = agent_args_map(args);
2037 let query = map_in
2038 .get("query")
2039 .and_then(Value::as_str)
2040 .filter(|s| !s.trim().is_empty())
2041 .ok_or_else(|| {
2042 invalid_request("semantic_search: invalid params: `query` must be a non-empty string")
2043 })?;
2044
2045 let mut out = Map::new();
2046 out.insert("query".to_string(), Value::String(query.to_string()));
2047 let top_k_value =
2048 coerce_optional_int_result(map_in.get("topK"), "topK", 0, 100).map_err(|error| {
2049 if error.message.starts_with("topK must be between") {
2050 invalid_request("topK must be between 1 and 100")
2051 } else {
2052 error
2053 }
2054 })?;
2055 let top_k = match top_k_value {
2056 Some(0) => return Err(invalid_request("topK must be between 1 and 100")),
2057 Some(value) => value,
2058 None => 10,
2059 };
2060 out.insert("top_k".to_string(), Value::Number(top_k.into()));
2061 if let Some(offset) = coerce_optional_int_result(map_in.get("offset"), "offset", 0, 100_000)? {
2062 out.insert("offset".to_string(), Value::Number(offset.into()));
2063 }
2064 if let Some(include_tests) = map_in.get("includeTests").and_then(Value::as_bool) {
2065 out.insert("include_tests".to_string(), Value::Bool(include_tests));
2066 }
2067 if let Some(path) = map_in
2068 .get("path")
2069 .and_then(Value::as_str)
2070 .map(str::trim)
2071 .filter(|path| !path.is_empty())
2072 {
2073 out.insert("path".to_string(), Value::String(path.to_string()));
2074 }
2075
2076 Ok(Translated {
2077 command: "semantic_search".into(),
2078 args: out,
2079 })
2080}
2081
2082fn translate_outline(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2083 let map_in = agent_args_map(args);
2084 let files_flag = map_in
2085 .get("files")
2086 .and_then(Value::as_bool)
2087 .unwrap_or(false);
2088
2089 let target = map_in
2090 .get("target")
2091 .ok_or_else(|| invalid_request("outline: missing required param 'target'"))?;
2092
2093 if is_empty_param(target) {
2094 return Err(invalid_request(
2095 "'target' must be a non-empty string or array of strings",
2096 ));
2097 }
2098
2099 let mut out = Map::new();
2100 if let Some(include_tests) = map_in
2101 .get("includeTests")
2102 .or_else(|| map_in.get("include_tests"))
2103 .and_then(Value::as_bool)
2104 {
2105 out.insert("includeTests".to_string(), Value::Bool(include_tests));
2106 }
2107
2108 if let Some(arr) = target.as_array() {
2109 if arr.is_empty() {
2110 return Err(invalid_request(
2111 "'target' must be a non-empty string or array of strings",
2112 ));
2113 }
2114 if files_flag {
2115 let resolved: Vec<Value> = arr
2116 .iter()
2117 .filter_map(|v| v.as_str())
2118 .map(|entry| {
2119 let p = resolve_path_from_project_root(project_root, entry);
2120 Value::String(p.to_string_lossy().into_owned())
2121 })
2122 .collect();
2123 out.insert("target".to_string(), Value::Array(resolved));
2124 out.insert("files".to_string(), Value::Bool(true));
2125 return Ok(Translated {
2126 command: "outline".into(),
2127 args: out,
2128 });
2129 }
2130 let resolved: Vec<Value> = arr
2131 .iter()
2132 .filter_map(|v| v.as_str())
2133 .map(|entry| {
2134 let p = resolve_path_from_project_root(project_root, entry);
2135 Value::String(p.to_string_lossy().into_owned())
2136 })
2137 .collect();
2138 out.insert("files".to_string(), Value::Array(resolved));
2139 return Ok(Translated {
2140 command: "outline".into(),
2141 args: out,
2142 });
2143 }
2144
2145 if let Some(url) = target.as_str() {
2146 if !files_flag
2147 && (url.starts_with("http://")
2148 || url.starts_with("https://")
2149 || is_github_resource_target(url))
2150 {
2151 out.insert("file".to_string(), Value::String(url.to_string()));
2152 return Ok(Translated {
2153 command: "outline".into(),
2154 args: out,
2155 });
2156 }
2157 }
2158
2159 let target_str = target.as_str().ok_or_else(|| {
2160 invalid_request("'target' must be a non-empty string or array of strings")
2161 })?;
2162
2163 let resolved = resolve_path_from_project_root(project_root, target_str);
2164 let is_dir = std::fs::metadata(&resolved)
2165 .map(|m| m.is_dir())
2166 .unwrap_or(false);
2167
2168 if files_flag {
2169 if is_dir {
2170 out.insert(
2171 "directory".to_string(),
2172 Value::String(resolved.to_string_lossy().into_owned()),
2173 );
2174 } else {
2175 out.insert(
2176 "file".to_string(),
2177 Value::String(resolved.to_string_lossy().into_owned()),
2178 );
2179 }
2180 out.insert("files".to_string(), Value::Bool(true));
2181 } else if is_dir {
2182 out.insert(
2183 "directory".to_string(),
2184 Value::String(resolved.to_string_lossy().into_owned()),
2185 );
2186 } else {
2187 out.insert(
2188 "file".to_string(),
2189 Value::String(resolved.to_string_lossy().into_owned()),
2190 );
2191 }
2192
2193 Ok(Translated {
2194 command: "outline".into(),
2195 args: out,
2196 })
2197}
2198
2199fn zoom_target_entry_is_empty(entry: &Value) -> bool {
2200 let Some(obj) = entry.as_object() else {
2201 return true;
2202 };
2203 let file_path_empty = obj
2204 .get("path")
2205 .and_then(Value::as_str)
2206 .is_none_or(str::is_empty);
2207 let symbol_empty = obj
2208 .get("symbol")
2209 .and_then(Value::as_str)
2210 .is_none_or(str::is_empty);
2211 file_path_empty && symbol_empty
2212}
2213
2214fn zoom_targets_provided(value: Option<&Value>) -> bool {
2215 let Some(value) = value else {
2216 return false;
2217 };
2218 if is_empty_param(value) {
2219 return false;
2220 }
2221 match value {
2222 Value::Array(items) => !items.iter().all(zoom_target_entry_is_empty),
2223 Value::Object(_) => !zoom_target_entry_is_empty(value),
2224 _ => false,
2225 }
2226}
2227
2228fn translate_zoom_targets(
2229 targets_value: &Value,
2230 project_root: &Path,
2231) -> Result<Vec<Value>, TranslateError> {
2232 let target_values: Vec<&Value> = match targets_value {
2233 Value::Array(items) => items.iter().collect(),
2234 Value::Object(_) => vec![targets_value],
2235 _ => {
2236 return Err(invalid_request(
2237 "'targets' must be a non-empty object or array",
2238 ))
2239 }
2240 };
2241
2242 if target_values.is_empty() {
2243 return Err(invalid_request(
2244 "'targets' must be a non-empty object or array",
2245 ));
2246 }
2247
2248 let mut out = Vec::with_capacity(target_values.len());
2249 for (index, target) in target_values.into_iter().enumerate() {
2250 let obj = target.as_object();
2251 let file_path = obj
2252 .and_then(|obj| obj.get("path"))
2253 .and_then(Value::as_str)
2254 .filter(|file_path| !file_path.is_empty())
2255 .ok_or_else(|| {
2256 invalid_request(format!(
2257 "targets[{index}].filePath must be a non-empty string"
2258 ))
2259 })?;
2260 let symbol = obj
2261 .and_then(|obj| obj.get("symbol"))
2262 .and_then(Value::as_str)
2263 .filter(|symbol| !symbol.is_empty())
2264 .ok_or_else(|| {
2265 invalid_request(format!(
2266 "targets[{index}].symbol must be a non-empty string"
2267 ))
2268 })?;
2269 let mut target_out = Map::new();
2270 insert_file_or_github_target(&mut target_out, project_root, file_path);
2271 target_out.insert("symbol".to_string(), Value::String(symbol.to_string()));
2272 target_out.insert(
2273 "target_label".to_string(),
2274 Value::String(file_path.to_string()),
2275 );
2276 out.push(Value::Object(target_out));
2277 }
2278 Ok(out)
2279}
2280
2281fn translate_zoom(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2282 let map_in = agent_args_map(args);
2283
2284 let has_targets = zoom_targets_provided(map_in.get("targets"));
2285 let has_file_path = map_in
2286 .get("path")
2287 .is_some_and(|value| !is_empty_param(value));
2288 let has_url = map_in
2289 .get("url")
2290 .is_some_and(|value| !is_empty_param(value));
2291 let has_symbols = map_in
2292 .get("symbols")
2293 .is_some_and(|value| !is_empty_param(value));
2294
2295 let mut out = Map::new();
2296
2297 if has_targets {
2298 if has_file_path || has_url || has_symbols {
2299 return Err(invalid_request(
2300 "'targets' is mutually exclusive with 'filePath', 'url', and 'symbols'",
2301 ));
2302 }
2303 let targets_value = map_in
2304 .get("targets")
2305 .expect("has_targets implies a targets value exists");
2306 out.insert(
2307 "targets".to_string(),
2308 Value::Array(translate_zoom_targets(targets_value, project_root)?),
2309 );
2310
2311 if let Some(context_lines) = coerce_optional_int_result(
2312 map_in.get("contextLines"),
2313 "contextLines",
2314 1,
2315 9_007_199_254_740_991,
2316 )? {
2317 out.insert(
2318 "context_lines".to_string(),
2319 Value::Number(context_lines.into()),
2320 );
2321 }
2322
2323 if map_in.get("callgraph").is_some_and(coerce_boolean) {
2324 out.insert("callgraph".to_string(), Value::Bool(true));
2325 }
2326
2327 return Ok(Translated {
2328 command: "zoom".into(),
2329 args: out,
2330 });
2331 }
2332
2333 let file_path = map_in
2334 .get("path")
2335 .and_then(Value::as_str)
2336 .filter(|s| !s.is_empty());
2337 let url = map_in
2338 .get("url")
2339 .and_then(Value::as_str)
2340 .filter(|s| !s.is_empty());
2341
2342 match (file_path, url) {
2343 (None, None) => {
2344 return Err(invalid_request(
2345 "Provide exactly one of 'filePath', 'url', or 'targets'",
2346 ));
2347 }
2348 (Some(_), Some(_)) => {
2349 return Err(invalid_request(
2350 "Provide exactly ONE of 'filePath' or 'url' — not both",
2351 ));
2352 }
2353 _ => {}
2354 }
2355
2356 if let Some(url) = url {
2357 out.insert("file".to_string(), Value::String(url.to_string()));
2358 } else if let Some(file_path) = file_path {
2359 insert_file_or_github_target(&mut out, project_root, file_path);
2360 }
2361
2362 if let Some(symbols) = map_in.get("symbols") {
2363 if !is_empty_param(symbols) {
2364 match symbols {
2365 Value::String(symbol) => {
2366 out.insert("symbol".to_string(), Value::String(symbol.to_string()));
2367 }
2368 Value::Array(items) => {
2369 let names: Vec<Value> = items
2375 .iter()
2376 .filter_map(Value::as_str)
2377 .filter(|name| !name.is_empty())
2378 .map(|name| Value::String(name.to_string()))
2379 .collect();
2380 if !names.is_empty() {
2381 out.insert("symbols".to_string(), Value::Array(names));
2382 }
2383 }
2384 _ => {
2385 return Err(invalid_request(
2386 "'symbols' must be a string or array of strings",
2387 ))
2388 }
2389 }
2390 }
2391 }
2392
2393 if let Some(context_lines) = coerce_optional_int_result(
2394 map_in.get("contextLines"),
2395 "contextLines",
2396 1,
2397 9_007_199_254_740_991,
2398 )? {
2399 out.insert(
2400 "context_lines".to_string(),
2401 Value::Number(context_lines.into()),
2402 );
2403 }
2404
2405 if map_in.get("callgraph").is_some_and(coerce_boolean) {
2406 out.insert("callgraph".to_string(), Value::Bool(true));
2407 }
2408
2409 Ok(Translated {
2410 command: "zoom".into(),
2411 args: out,
2412 })
2413}
2414
2415fn translate_conflicts(args: Value) -> Result<Translated, TranslateError> {
2416 let map_in = agent_args_map(args);
2417 let mut out = Map::new();
2418 if let Some(path_val) = map_in.get("path") {
2419 if !is_empty_param(path_val) {
2420 if let Some(path_str) = path_val.as_str() {
2421 out.insert("path".to_string(), Value::String(path_str.to_string()));
2422 }
2423 }
2424 }
2425
2426 Ok(Translated {
2427 command: "git_conflicts".into(),
2428 args: out,
2429 })
2430}
2431
2432fn translate_inspect(args: Value, project_root: &Path) -> Result<Translated, TranslateError> {
2433 let map_in = agent_args_map(args);
2434 let mut out = Map::new();
2435
2436 if let Some(sections) = map_in.get("sections") {
2437 if !is_empty_param(sections) {
2438 out.insert("sections".to_string(), sections.clone());
2439 }
2440 }
2441
2442 if let Some(scope) = map_in.get("scope") {
2443 if !is_empty_param(scope) {
2444 match scope {
2445 Value::String(s) if !s.is_empty() => {
2446 let resolved = resolve_path_from_project_root(project_root, s);
2447 out.insert(
2448 "scope".to_string(),
2449 Value::String(resolved.to_string_lossy().into_owned()),
2450 );
2451 }
2452 Value::Array(arr) => {
2453 let resolved: Vec<Value> = arr
2454 .iter()
2455 .filter_map(|v| v.as_str())
2456 .map(|entry| {
2457 let p = resolve_path_from_project_root(project_root, entry);
2458 Value::String(p.to_string_lossy().into_owned())
2459 })
2460 .collect();
2461 out.insert("scope".to_string(), Value::Array(resolved));
2462 }
2463 other => {
2464 out.insert("scope".to_string(), other.clone());
2465 }
2466 }
2467 }
2468 }
2469
2470 if let Some(top_k) = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)? {
2471 out.insert("topK".to_string(), Value::Number(top_k.into()));
2472 }
2473
2474 Ok(Translated {
2475 command: "inspect".into(),
2476 args: out,
2477 })
2478}
2479
2480#[cfg(test)]
2481mod tests {
2482 use super::*;
2483
2484 #[test]
2485 fn path_aliases_normalize_equal_and_reject_conflicts() {
2486 let project = Path::new("/project");
2487 let legacy = serde_json::json!({"filePath": "src/main.ts", "content": "x"});
2488 let canonical = serde_json::json!({"path": "src/main.ts", "content": "x"});
2489 assert_eq!(
2490 subc_translate_owned("write", legacy, project).expect("legacy path"),
2491 subc_translate_owned("write", canonical, project).expect("canonical path")
2492 );
2493
2494 let conflict = serde_json::json!({"path": "src/a.ts", "filePath": "src/b.ts"});
2495 let error = subc_translate_owned("read", conflict, project).expect_err("conflict");
2496 assert_eq!(error.code, "invalid_request");
2497 assert!(error.message.contains("path"));
2498 assert!(error.message.contains("filePath"));
2499 }
2500
2501 #[test]
2502 fn path_aliases_keep_unicode_scalar_equality_strict() {
2503 let project = Path::new("/project");
2504 let equal = serde_json::json!({"path": "src/😀.ts", "filePath": "src/😀.ts"});
2505 assert!(subc_translate_owned("read", equal, project).is_ok());
2506
2507 let canonically_different = serde_json::json!({
2508 "path": "src/é.ts",
2509 "filePath": "src/e\u{301}.ts"
2510 });
2511 let error = subc_translate_owned("read", canonically_different, project)
2512 .expect_err("different Unicode normalization");
2513 assert_eq!(error.code, "invalid_request");
2514 }
2515
2516 #[test]
2517 fn github_resource_targets_pass_through_read_outline_and_zoom_unresolved() {
2518 let project = Path::new("/project");
2524 for tool in ["read", "outline"] {
2525 let args = if tool == "read" {
2526 serde_json::json!({ "path": "issue://310" })
2527 } else {
2528 serde_json::json!({ "target": "pr://302" })
2529 };
2530 let translated = subc_translate_owned(tool, args, project).expect(tool);
2531 let file = translated.args["file"].as_str().expect("file");
2532 assert!(
2533 file.starts_with("issue://") || file.starts_with("pr://"),
2534 "{tool}: GitHub target was resolved as a path: {file}"
2535 );
2536 }
2537 let zoom = subc_translate_owned(
2538 "zoom",
2539 serde_json::json!({ "path": "pr://302", "symbols": "4" }),
2540 project,
2541 )
2542 .expect("zoom path form");
2543 assert_eq!(zoom.args["file"].as_str(), Some("pr://302"));
2544 let batched = subc_translate_owned(
2545 "zoom",
2546 serde_json::json!({ "targets": [{ "path": "issue://310", "symbol": "1" }] }),
2547 project,
2548 )
2549 .expect("zoom targets form");
2550 assert_eq!(
2551 batched.args["targets"][0]["file"].as_str(),
2552 Some("issue://310")
2553 );
2554 let plain = subc_translate_owned(
2556 "outline",
2557 serde_json::json!({ "target": "src/lib.rs" }),
2558 project,
2559 )
2560 .expect("plain outline");
2561 let expected = resolve_path_from_project_root(project, "src/lib.rs");
2565 assert_eq!(
2566 plain.args["file"].as_str(),
2567 Some(expected.to_string_lossy().as_ref()),
2568 "a plain relative target must still resolve against the project root"
2569 );
2570 }
2571
2572 #[test]
2573 fn empty_or_null_optional_path_is_stripped_as_absent() {
2574 let project = Path::new("/project");
2575 let conflicts =
2580 subc_translate_owned("conflicts", serde_json::json!({ "path": "" }), project)
2581 .expect("conflicts with empty path");
2582 assert!(!conflicts.args.contains_key("path"));
2583
2584 let grep = subc_translate_owned(
2585 "grep",
2586 serde_json::json!({ "pattern": "x", "path": "" }),
2587 project,
2588 )
2589 .expect("grep with empty path");
2590 assert!(!grep.args.contains_key("path"));
2591
2592 let search = subc_translate_owned(
2593 "search",
2594 serde_json::json!({ "query": "x", "path": null }),
2595 project,
2596 )
2597 .expect("search with null path");
2598 assert!(!search.args.contains_key("path"));
2599
2600 let safety = subc_translate_owned(
2601 "safety",
2602 serde_json::json!({ "op": "list", "path": "" }),
2603 project,
2604 )
2605 .expect("safety with empty path");
2606 assert!(!safety.args.contains_key("path"));
2607
2608 let zoom = subc_translate_owned(
2612 "zoom",
2613 serde_json::json!({ "path": "", "filePath": "" }),
2614 project,
2615 )
2616 .expect_err("zoom with only empty path sentinels");
2617 assert!(zoom.message.contains("Provide exactly one"));
2618
2619 let callgraph = subc_translate_owned(
2621 "callgraph",
2622 serde_json::json!({
2623 "path": "src/main.ts",
2624 "toPath": "",
2625 "op": "callers",
2626 "symbol": "main"
2627 }),
2628 project,
2629 )
2630 .expect("callgraph with empty toPath");
2631 assert!(!callgraph.args.contains_key("to_path"));
2632 }
2633
2634 #[test]
2635 fn required_tools_report_empty_path_as_missing_not_malformed() {
2636 let project = Path::new("/project");
2637 for tool in ["read", "write", "move", "import"] {
2641 let error = subc_translate_owned(tool, serde_json::json!({ "path": "" }), project)
2642 .expect_err("empty required path");
2643 assert_eq!(error.code, "invalid_request");
2644 assert!(
2645 error.message.contains("'path' is required")
2646 || error.message.contains("missing required param"),
2647 "{tool} empty path message: {}",
2648 error.message
2649 );
2650 assert!(
2651 !error.message.contains("well-formed Unicode"),
2652 "{tool} must not report the well-formed error for an empty path"
2653 );
2654 }
2655 let edit = subc_translate_owned("edit", serde_json::json!({ "path": "" }), project)
2658 .expect_err("edit empty path");
2659 assert!(edit.message.contains("exactly one of"));
2660 assert!(!edit.message.contains("well-formed Unicode"));
2661 }
2662
2663 #[test]
2664 fn empty_optional_path_sentinel_does_not_mask_a_non_string_error() {
2665 let project = Path::new("/project");
2666 for tool in ["grep", "search", "conflicts", "zoom", "safety"] {
2671 let error = subc_translate_owned(tool, serde_json::json!({ "path": 42 }), project)
2672 .expect_err("non-string optional path");
2673 assert_eq!(error.code, "invalid_request");
2674 assert!(
2675 error.message.contains("well-formed Unicode"),
2676 "{tool} non-string path message: {}",
2677 error.message
2678 );
2679 }
2680 }
2681
2682 #[test]
2683 fn owned_write_translation_moves_content_buffer() {
2684 let content = "x".repeat(256 * 1024);
2685 let content_ptr = content.as_ptr();
2686 let content_len = content.len();
2687 let mut arguments = Map::new();
2688 arguments.insert(
2689 "filePath".to_string(),
2690 Value::String("src/generated.ts".to_string()),
2691 );
2692 arguments.insert("content".to_string(), Value::String(content));
2693
2694 let translated =
2695 subc_translate_owned("write", Value::Object(arguments), Path::new("/project"))
2696 .expect("write translation succeeds");
2697 let translated_content = translated
2698 .args
2699 .get("content")
2700 .and_then(Value::as_str)
2701 .expect("translated write keeps content");
2702
2703 assert_eq!(translated_content.len(), content_len);
2704 assert_eq!(translated_content.as_ptr(), content_ptr);
2705 }
2706
2707 #[test]
2708 fn edit_normalization_orders_contract_checks_before_path_resolution() {
2709 let project = Path::new("/project");
2710 let conflict = subc_translate_owned(
2711 "edit",
2712 serde_json::json!({
2713 "path": "src/main.ts",
2714 "appendContent": "x",
2715 "edits": "not-json"
2716 }),
2717 project,
2718 )
2719 .expect_err("mode conflict");
2720 assert_eq!(conflict.code, "invalid_request");
2721 assert!(conflict.message.contains("conflicting modes"));
2722
2723 let line_error = subc_translate_owned(
2724 "edit",
2725 serde_json::json!({ "path": 42, "startLine": 1 }),
2726 project,
2727 )
2728 .expect_err("top-level line range");
2729 assert!(line_error.message.contains("startLine"));
2730
2731 let no_mode = subc_translate_owned("edit", serde_json::json!({ "path": "x" }), project)
2732 .expect_err("missing mode");
2733 assert!(no_mode.message.contains("exactly one of"));
2734
2735 let retired_fields = subc_translate_owned(
2736 "edit",
2737 serde_json::json!({ "mode": "write", "file": "src/main.ts" }),
2738 project,
2739 )
2740 .expect_err("retired fields are ordinary unknown keys outside OpenCode aft_edit");
2741 assert_eq!(
2742 retired_fields.message,
2743 "Unrecognized keys: \"file\", \"mode\""
2744 );
2745 }
2746
2747 #[test]
2748 fn edit_normalization_uses_meaningful_mode_presence() {
2749 let project = Path::new("/project");
2750 let cases = [
2751 (
2752 "edits ignores empty mode sentinels",
2753 serde_json::json!({
2754 "filePath": "src/example.ts",
2755 "edits": [{ "oldString": "old", "newString": "new" }],
2756 "appendContent": "",
2757 "symbol": "",
2758 "content": "",
2759 }),
2760 Some("batch"),
2761 None,
2762 ),
2763 (
2764 "append ignores empty edits",
2765 serde_json::json!({
2766 "filePath": "src/example.ts",
2767 "appendContent": "append",
2768 "edits": [],
2769 }),
2770 Some("edit_match"),
2771 None,
2772 ),
2773 (
2774 "symbol deletion keeps empty content",
2775 serde_json::json!({
2776 "filePath": "src/example.ts",
2777 "symbol": "target",
2778 "content": "",
2779 }),
2780 Some("edit_symbol"),
2781 None,
2782 ),
2783 (
2784 "content without a symbol is rejected",
2785 serde_json::json!({
2786 "filePath": "src/example.ts",
2787 "symbol": "",
2788 "content": "replacement",
2789 }),
2790 None,
2791 Some("requires a non-empty string 'symbol'"),
2792 ),
2793 (
2794 "two real modes conflict",
2795 serde_json::json!({
2796 "filePath": "src/example.ts",
2797 "appendContent": "append",
2798 "edits": [{ "oldString": "old", "newString": "new" }],
2799 }),
2800 None,
2801 Some("conflicting modes"),
2802 ),
2803 (
2804 "all empty fields have no mode",
2805 serde_json::json!({
2806 "filePath": "src/example.ts",
2807 "appendContent": "",
2808 "edits": [],
2809 "symbol": "",
2810 "content": "",
2811 "oldString": "",
2812 "newString": "",
2813 "replaceAll": null,
2814 "occurrence": null,
2815 }),
2816 None,
2817 Some("exactly one of"),
2818 ),
2819 ];
2820
2821 for (label, arguments, command, expected_error) in cases {
2822 match (command, expected_error) {
2823 (Some(command), None) => {
2824 let translated = subc_translate_owned("edit", arguments, project)
2825 .unwrap_or_else(|error| panic!("{label}: {}", error.message));
2826 assert_eq!(translated.command, command, "{label}");
2827 match label {
2828 "edits ignores empty mode sentinels" => {
2829 assert_eq!(
2830 translated.args["edits"][0]["match"],
2831 Value::String("old".to_string())
2832 );
2833 assert_eq!(
2834 translated.args["edits"][0]["replacement"],
2835 Value::String("new".to_string())
2836 );
2837 }
2838 "append ignores empty edits" => {
2839 assert_eq!(
2840 translated.args["append_content"],
2841 Value::String("append".to_string())
2842 );
2843 }
2844 "symbol deletion keeps empty content" => {
2845 assert_eq!(translated.args["content"], Value::String(String::new()));
2846 }
2847 _ => unreachable!("unexpected successful edit mode case"),
2848 }
2849 }
2850 (None, Some(expected_error)) => {
2851 let translation_error = subc_translate_owned("edit", arguments, project)
2852 .expect_err("meaningful mode case must fail");
2853 assert!(
2854 translation_error.message.contains(expected_error),
2855 "{label}: {}",
2856 translation_error.message
2857 );
2858 }
2859 _ => unreachable!("case must expect exactly one outcome"),
2860 }
2861 }
2862 }
2863
2864 #[test]
2865 fn edit_normalization_accepts_aliases_and_rejects_ambiguous_scalars() {
2866 let project = Path::new("/project");
2867 let stringified = subc_translate_owned(
2868 "edit",
2869 serde_json::json!({
2870 "path": "src/main.ts",
2871 "edits": "[{\"oldString\":\"before\",\"newString\":\"after\"}]"
2872 }),
2873 project,
2874 )
2875 .expect("stringified non-empty edits array");
2876 assert_eq!(stringified.command, "batch");
2877
2878 let normalized = subc_translate_owned(
2879 "edit",
2880 serde_json::json!({
2881 "filePath": "src/main.ts",
2882 "edits": [{ "oldText": "before", "newText": "after", "occurrence": " +01 " }]
2883 }),
2884 project,
2885 )
2886 .expect("compatibility aliases");
2887 let item = normalized
2888 .args
2889 .get("edits")
2890 .and_then(Value::as_array)
2891 .and_then(|items| items.first())
2892 .expect("translated edit item");
2893 assert_eq!(item.get("match").and_then(Value::as_str), Some("before"));
2894 assert_eq!(item.get("occurrence").and_then(Value::as_u64), Some(1));
2895
2896 for value in ["0", "00", "+0", "1.0", "1e0", "0x1", "-1"] {
2897 let error = subc_translate_owned(
2898 "edit",
2899 serde_json::json!({
2900 "path": "src/main.ts",
2901 "edits": [{ "oldString": "before", "occurrence": value }]
2902 }),
2903 project,
2904 )
2905 .expect_err("invalid occurrence spelling");
2906 assert!(error.message.contains("occurrence"));
2907 }
2908 }
2909
2910 #[test]
2911 fn edit_strips_all_empty_sentinel_edit_items() {
2912 let project = Path::new("/project");
2913
2914 let report = subc_translate_owned(
2918 "edit",
2919 serde_json::json!({
2920 "path": "src/main.ts",
2921 "symbol": "",
2922 "content": "",
2923 "appendContent": "CONTENT IT APPENDS",
2924 "edits": [
2925 { "oldString": "", "newString": "", "replaceAll": false,
2926 "occurrence": 1, "startLine": 1, "endLine": 1, "content": "" }
2927 ]
2928 }),
2929 project,
2930 )
2931 .expect("all-empty sentinel edits must not claim the edits mode");
2932 assert_eq!(report.command, "edit_match");
2933 assert_eq!(
2934 report.args.get("append_content").and_then(Value::as_str),
2935 Some("CONTENT IT APPENDS")
2936 );
2937
2938 let mixed = subc_translate_owned(
2941 "edit",
2942 serde_json::json!({
2943 "path": "src/main.ts",
2944 "edits": [
2945 { "oldString": "", "newString": "", "replaceAll": false,
2946 "occurrence": 1, "startLine": 1, "endLine": 1, "content": "" },
2947 { "oldString": "old", "newString": "new" }
2948 ]
2949 }),
2950 project,
2951 )
2952 .expect("real item must survive sentinel stripping");
2953 assert_eq!(mixed.command, "batch");
2954 let items = mixed.args.get("edits").and_then(Value::as_array).unwrap();
2955 assert_eq!(items.len(), 1, "only the real item survives");
2956 assert_eq!(items[0].get("match").and_then(Value::as_str), Some("old"));
2957 assert_eq!(
2958 items[0].get("replacement").and_then(Value::as_str),
2959 Some("new")
2960 );
2961
2962 let line_delete = subc_translate_owned(
2965 "edit",
2966 serde_json::json!({
2967 "path": "src/main.ts",
2968 "edits": [{ "startLine": 1, "endLine": 1, "content": "" }]
2969 }),
2970 project,
2971 )
2972 .expect("pure line-range delete must stay an edits claim");
2973 assert_eq!(line_delete.command, "batch");
2974
2975 let empty_match = subc_translate_owned(
2981 "edit",
2982 serde_json::json!({
2983 "path": "src/main.ts",
2984 "edits": [{ "oldString": "", "newString": "x" }]
2985 }),
2986 project,
2987 )
2988 .expect("empty oldString must stay an edits claim");
2989 assert_eq!(empty_match.command, "batch");
2990 let kept = empty_match
2991 .args
2992 .get("edits")
2993 .and_then(Value::as_array)
2994 .unwrap();
2995 assert_eq!(kept.len(), 1, "the empty-match item must be kept");
2996 assert_eq!(kept[0].get("match").and_then(Value::as_str), Some(""));
2997
2998 let stringified = subc_translate_owned(
3000 "edit",
3001 serde_json::json!({
3002 "path": "src/main.ts",
3003 "appendContent": "APPEND",
3004 "edits": "[{\"oldString\":\"\",\"newString\":\"\",\"replaceAll\":false,\"occurrence\":1,\"startLine\":1,\"endLine\":1,\"content\":\"\"}]"
3005 }),
3006 project,
3007 )
3008 .expect("stringified all-empty sentinel edits must not claim edits mode");
3009 assert_eq!(stringified.command, "edit_match");
3010 assert_eq!(
3011 stringified
3012 .args
3013 .get("append_content")
3014 .and_then(Value::as_str),
3015 Some("APPEND")
3016 );
3017 }
3018
3019 #[test]
3020 fn meaningful_find_payload_predicate_rejects_empty_match_mutation_control() {
3021 let meaningful = serde_json::json!({ "oldString": "before" });
3022 let empty = serde_json::json!({ "oldString": "" });
3023 let absent = serde_json::json!({});
3024
3025 assert!(has_meaningful_find_payload(
3026 meaningful.as_object().expect("meaningful item object")
3027 ));
3028 assert!(!has_meaningful_find_payload(
3029 empty.as_object().expect("empty item object")
3030 ));
3031 assert!(!has_meaningful_find_payload(
3032 absent.as_object().expect("absent item object")
3033 ));
3034 }
3035
3036 #[test]
3037 fn edit_normalization_applies_symmetric_item_sentinel_precedence() {
3038 let project = Path::new("/project");
3039
3040 for item in [
3043 serde_json::json!({
3044 "oldString": "before", "newString": "after",
3045 "startLine": 1, "endLine": 1, "content": "",
3046 }),
3047 serde_json::json!({
3048 "oldString": "before", "newString": "after",
3049 "startLine": 1, "endLine": 1,
3050 }),
3051 ] {
3052 let translated = subc_translate_owned(
3053 "edit",
3054 serde_json::json!({ "path": "src/example.ts", "edits": [item] }),
3055 project,
3056 )
3057 .expect("empty or absent range payload must yield to find/replace");
3058 assert_eq!(
3059 translated.args["edits"],
3060 serde_json::json!([{ "match": "before", "replacement": "after" }]),
3061 );
3062 }
3063
3064 let line_delete = subc_translate_owned(
3067 "edit",
3068 serde_json::json!({
3069 "path": "src/example.ts",
3070 "edits": [{ "startLine": 1, "endLine": 1, "content": "" }],
3071 }),
3072 project,
3073 )
3074 .expect("bare line-range delete must remain a range edit");
3075 assert_eq!(
3076 line_delete.args["edits"],
3077 serde_json::json!([{ "line_start": 1, "line_end": 1, "content": "" }]),
3078 );
3079
3080 let all_sentinels = subc_translate_owned(
3083 "edit",
3084 serde_json::json!({
3085 "path": "src/example.ts",
3086 "edits": [{
3087 "oldString": "", "newString": "", "replaceAll": false,
3088 "occurrence": 1, "startLine": 1, "endLine": 1, "content": "",
3089 }],
3090 }),
3091 project,
3092 )
3093 .expect_err("all-default item must be dropped");
3094 assert!(all_sentinels.message.contains("exactly one of"));
3095
3096 let issue_payload = serde_json::json!({
3097 "path": "src/example.ts",
3098 "edits": [{
3099 "content": "const value = new;",
3100 "startLine": 14,
3101 "endLine": 14,
3102 "oldString": "",
3103 "newString": "",
3104 "replaceAll": false,
3105 "occurrence": 1,
3106 }],
3107 });
3108 let translated = subc_translate_owned("edit", issue_payload, project)
3109 .expect("line-range sentinels must not select find/replace mode");
3110 assert_eq!(translated.command, "batch");
3111 assert_eq!(
3112 translated.args["edits"],
3113 serde_json::json!([{
3114 "content": "const value = new;",
3115 "line_start": 14,
3116 "line_end": 14,
3117 }]),
3118 );
3119
3120 for arguments in [
3121 serde_json::json!({
3122 "path": "src/example.ts",
3123 "edits": [{
3124 "content": "replacement", "startLine": 14, "endLine": 14,
3125 "oldString": "meaningful", "newString": "",
3126 }],
3127 }),
3128 serde_json::json!({
3129 "path": "src/example.ts",
3130 "edits": [{
3131 "content": "replacement", "startLine": 14, "endLine": 14,
3132 "oldString": "", "newString": "", "replaceAll": true,
3133 }],
3134 }),
3135 serde_json::json!({
3136 "path": "src/example.ts",
3137 "edits": [{
3138 "content": "replacement", "startLine": 14, "endLine": 14,
3139 "oldString": "", "newString": "", "occurrence": 2,
3140 }],
3141 }),
3142 ] {
3143 let error = subc_translate_owned("edit", arguments, project)
3144 .expect_err("meaningful find/replace fields must remain mixed-mode errors");
3145 assert_eq!(
3146 error.message,
3147 "edit: edits[0] mixes find/replace and line-range fields"
3148 );
3149 }
3150
3151 let empty_find = subc_translate_owned(
3152 "edit",
3153 serde_json::json!({
3154 "path": "src/example.ts",
3155 "edits": [{ "oldString": "", "newString": "replacement" }],
3156 }),
3157 project,
3158 )
3159 .expect("empty find match without line-range fields must reach batch validation");
3160 assert_eq!(empty_find.command, "batch");
3161 assert_eq!(
3162 empty_find.args["edits"][0]["match"],
3163 Value::String(String::new())
3164 );
3165 }
3166
3167 #[test]
3168 fn edit_null_sentinels_are_absent_at_both_edit_boundaries() {
3169 let project = Path::new("/project");
3170
3171 let issue_payload = serde_json::json!({
3174 "path": "src/example.ts",
3175 "edits": [{
3176 "oldString": "gamma line three",
3177 "newString": "GAMMA line three",
3178 "replaceAll": false,
3179 "occurrence": null,
3180 "startLine": null,
3181 "endLine": null,
3182 "content": null,
3183 }],
3184 });
3185 let translated = subc_translate_owned("edit", issue_payload, project)
3186 .expect("null range sentinels must not create a mode conflict");
3187 assert_eq!(
3188 translated.args["edits"],
3189 serde_json::json!([{
3190 "match": "gamma line three",
3191 "replacement": "GAMMA line three",
3192 }]),
3193 );
3194
3195 for field in [
3198 "newString",
3199 "replaceAll",
3200 "occurrence",
3201 "startLine",
3202 "endLine",
3203 "content",
3204 ] {
3205 let mut item = serde_json::json!({
3206 "oldString": "before",
3207 "newString": "after",
3208 });
3209 item.as_object_mut()
3210 .expect("edit item object")
3211 .insert(field.to_string(), Value::Null);
3212 let translated = subc_translate_owned(
3213 "edit",
3214 serde_json::json!({ "path": "src/example.ts", "edits": [item] }),
3215 project,
3216 )
3217 .unwrap_or_else(|error| panic!("null {field} sentinel: {}", error.message));
3218 let expected = if field == "newString" {
3219 serde_json::json!([{ "match": "before" }])
3220 } else {
3221 serde_json::json!([{ "match": "before", "replacement": "after" }])
3222 };
3223 assert_eq!(
3224 translated.args["edits"], expected,
3225 "null {field} must be absent",
3226 );
3227 }
3228
3229 let line_delete = subc_translate_owned(
3232 "edit",
3233 serde_json::json!({
3234 "path": "src/example.ts",
3235 "edits": [{
3236 "startLine": 1,
3237 "endLine": 1,
3238 "content": "",
3239 "oldString": null,
3240 "newString": null,
3241 "replaceAll": null,
3242 "occurrence": null,
3243 }],
3244 }),
3245 project,
3246 )
3247 .expect("null find fields must not hide a line-range delete");
3248 assert_eq!(
3249 line_delete.args["edits"],
3250 serde_json::json!([{
3251 "content": "",
3252 "line_start": 1,
3253 "line_end": 1,
3254 }]),
3255 );
3256
3257 let null_item = serde_json::json!({
3261 "oldString": null,
3262 "newString": null,
3263 "replaceAll": null,
3264 "occurrence": null,
3265 "startLine": null,
3266 "endLine": null,
3267 "content": null,
3268 });
3269 let no_mode = subc_translate_owned(
3270 "edit",
3271 serde_json::json!({ "path": "src/example.ts", "edits": [null_item] }),
3272 project,
3273 )
3274 .expect_err("all-null edit item must be dropped as a sentinel");
3275 assert!(no_mode.message.contains("exactly one of"));
3276
3277 let mixed = subc_translate_owned(
3278 "edit",
3279 serde_json::json!({
3280 "path": "src/example.ts",
3281 "edits": [
3282 {
3283 "oldString": null,
3284 "newString": null,
3285 "replaceAll": null,
3286 "occurrence": null,
3287 "startLine": null,
3288 "endLine": null,
3289 "content": null,
3290 },
3291 { "oldString": "before", "newString": "after" },
3292 ],
3293 }),
3294 project,
3295 )
3296 .expect("real edit must survive an all-null sentinel");
3297 assert_eq!(
3298 mixed.args["edits"],
3299 serde_json::json!([{ "match": "before", "replacement": "after" }]),
3300 );
3301
3302 let malformed = subc_translate_owned(
3303 "edit",
3304 serde_json::json!({
3305 "path": "src/example.ts",
3306 "edits": [{ "oldString": null, "newString": "replacement" }],
3307 }),
3308 project,
3309 )
3310 .expect_err("null oldString with real replacement must remain invalid");
3311 assert!(malformed.message.contains("requires string 'oldString'"));
3312
3313 let top_level_base = serde_json::json!({
3316 "path": "src/example.ts",
3317 "edits": [{ "oldString": "before", "newString": "after" }],
3318 });
3319 for field in [
3320 "appendContent",
3321 "symbol",
3322 "content",
3323 "oldString",
3324 "newString",
3325 "replaceAll",
3326 "occurrence",
3327 ] {
3328 let mut arguments = top_level_base.clone();
3329 arguments
3330 .as_object_mut()
3331 .expect("edit arguments object")
3332 .insert(field.to_string(), Value::Null);
3333 subc_translate_owned("edit", arguments, project)
3334 .unwrap_or_else(|error| panic!("top-level null {field}: {}", error.message));
3335 }
3336 let null_edits = subc_translate_owned(
3337 "edit",
3338 serde_json::json!({
3339 "path": "src/example.ts",
3340 "appendContent": "append",
3341 "edits": null,
3342 }),
3343 project,
3344 )
3345 .expect("top-level null edits must be absent");
3346 assert_eq!(null_edits.command, "edit_match");
3347
3348 let symbol_error = subc_translate_owned(
3349 "edit",
3350 serde_json::json!({
3351 "path": "src/example.ts",
3352 "symbol": "greetUser",
3353 "content": null,
3354 }),
3355 project,
3356 )
3357 .expect_err("null symbol content must fail the required-content check");
3358 assert_eq!(
3359 symbol_error.message,
3360 "edit: symbol mode requires both 'symbol' and 'content' string properties"
3361 );
3362
3363 let occurrence_zero = subc_translate_owned(
3364 "edit",
3365 serde_json::json!({
3366 "path": "src/example.ts",
3367 "edits": [{ "oldString": "before", "occurrence": 0 }],
3368 }),
3369 project,
3370 )
3371 .expect_err("occurrence zero must not be treated as a null sentinel");
3372 assert!(occurrence_zero.message.contains("occurrence"));
3373 }
3374
3375 #[test]
3376 fn edit_mode_errors_steer_away_from_empty_sentinels() {
3377 let project = Path::new("/project");
3378 let steering = "Omit unused optional fields entirely; do not send empty strings or empty arrays for them.";
3379
3380 let conflict = subc_translate_owned(
3381 "edit",
3382 serde_json::json!({
3383 "path": "src/main.ts",
3384 "appendContent": "x",
3385 "edits": [{ "oldString": "old", "newString": "new" }]
3386 }),
3387 project,
3388 )
3389 .expect_err("conflicting modes");
3390 assert!(conflict.message.contains("conflicting modes"));
3391 assert!(
3392 conflict.message.contains(steering),
3393 "conflicting-modes error must steer: {}",
3394 conflict.message
3395 );
3396
3397 let no_mode = subc_translate_owned(
3398 "edit",
3399 serde_json::json!({ "path": "src/main.ts" }),
3400 project,
3401 )
3402 .expect_err("no mode");
3403 assert!(no_mode.message.contains("exactly one of"));
3404 assert!(
3405 no_mode.message.contains(steering),
3406 "no-mode error must steer: {}",
3407 no_mode.message
3408 );
3409 }
3410
3411 #[test]
3412 fn search_legacy_hint_is_accepted_and_ignored() {
3413 let translated = subc_translate_owned(
3414 "search",
3415 serde_json::json!({
3416 "query": "outside <touser>",
3417 "topK": 5,
3418 "hint": "literal"
3419 }),
3420 Path::new("/project"),
3421 )
3422 .expect("legacy search hint must not reject the request");
3423
3424 assert_eq!(translated.command, "semantic_search");
3425 assert_eq!(
3426 translated.args.get("query").and_then(Value::as_str),
3427 Some("outside <touser>")
3428 );
3429 assert_eq!(
3430 translated.args.get("top_k").and_then(Value::as_u64),
3431 Some(5)
3432 );
3433 assert!(translated.args.get("hint").is_none());
3434 }
3435
3436 #[test]
3437 fn search_offset_is_forwarded_and_bounded() {
3438 let translated = subc_translate_owned(
3439 "search",
3440 serde_json::json!({"query": "ranked results", "offset": 42}),
3441 Path::new("/project"),
3442 )
3443 .expect("valid search offset");
3444 assert_eq!(
3445 translated.args.get("offset").and_then(Value::as_u64),
3446 Some(42)
3447 );
3448
3449 for offset in [serde_json::json!(-1), serde_json::json!(100_001)] {
3450 let error = subc_translate_owned(
3451 "search",
3452 serde_json::json!({"query": "ranked results", "offset": offset}),
3453 Path::new("/project"),
3454 )
3455 .expect_err("out-of-range search offset must be rejected");
3456 assert_eq!(error.code, "invalid_request");
3457 assert!(error.message.contains("offset"));
3458 }
3459 }
3460
3461 #[test]
3462 fn search_top_k_outside_public_bounds_is_invalid_request() {
3463 for top_k in [serde_json::json!(0), serde_json::json!(101)] {
3464 let error = subc_translate_owned(
3465 "search",
3466 serde_json::json!({"query": "ranked results", "topK": top_k}),
3467 Path::new("/project"),
3468 )
3469 .expect_err("out-of-range search topK must be rejected");
3470 assert_eq!(error.code, "invalid_request");
3471 assert!(error.message.contains("topK"));
3472 }
3473 }
3474
3475 #[test]
3480 fn powershell_translation_selects_the_unified_bash_executor() {
3481 let translated = subc_translate_owned(
3482 "powershell",
3483 serde_json::json!({ "command": "Get-ChildItem", "workdir": "scripts" }),
3484 Path::new("/project"),
3485 )
3486 .expect("PowerShell tool must translate");
3487
3488 assert_eq!(translated.command, "bash");
3489 assert_eq!(
3490 translated.args.get("shell"),
3491 Some(&Value::String("powershell".into()))
3492 );
3493 let workdir = translated
3498 .args
3499 .get("workdir")
3500 .and_then(Value::as_str)
3501 .expect("workdir must translate");
3502 assert_eq!(
3503 Path::new(workdir),
3504 Path::new("/project").join("scripts").as_path()
3505 );
3506 }
3507
3508 #[test]
3509 fn supports_tool_covers_every_translated_arm() {
3510 for name in [
3511 "bash",
3512 "powershell",
3513 "status",
3514 "read",
3515 "write",
3516 "edit",
3517 "apply_patch",
3518 "grep",
3519 "glob",
3520 "search",
3521 "outline",
3522 "zoom",
3523 "inspect",
3524 "callgraph",
3525 "conflicts",
3526 "ast_search",
3527 "ast_replace",
3528 "delete",
3529 "move",
3530 "import",
3531 "safety",
3532 ] {
3533 let err =
3537 subc_translate_owned(name, Value::Object(Map::new()), Path::new("/project")).err();
3538 assert_ne!(
3539 err.as_ref().map(|e| e.code),
3540 Some("unsupported_tool"),
3541 "{name} is in supports_tool but has no translate arm"
3542 );
3543 assert!(
3544 supports_tool(name),
3545 "{name} translates but is missing from supports_tool"
3546 );
3547 }
3548 assert!(!supports_tool("definitely_not_a_tool"));
3550 }
3551}