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