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