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