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