1use std::path::{Path, PathBuf};
4
5use serde_json::{Map, Value};
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct Translated {
9 pub command: String,
10 pub args: Map<String, Value>,
11}
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
14pub struct TranslateContext {
15 pub diagnostics_on_edit: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct TranslateError {
20 pub code: &'static str,
21 pub message: String,
22}
23
24fn invalid_request(message: impl Into<String>) -> TranslateError {
25 TranslateError {
26 code: "invalid_request",
27 message: message.into(),
28 }
29}
30
31fn resolve_home_dir() -> Option<PathBuf> {
32 let raw = std::env::var_os("HOME")
33 .or_else(|| std::env::var_os("USERPROFILE"))
34 .map(PathBuf::from)?;
35 Some(raw)
36}
37
38fn expand_tilde(target: &str) -> String {
39 if target == "~" {
40 return resolve_home_dir()
41 .map(|h| h.to_string_lossy().into_owned())
42 .unwrap_or_else(|| target.to_string());
43 }
44 if let Some(rest) = target.strip_prefix("~/") {
45 if let Some(home) = resolve_home_dir() {
46 return home.join(rest).to_string_lossy().into_owned();
47 }
48 }
49 target.to_string()
50}
51
52pub fn resolve_path_from_project_root(project_root: &Path, target: &str) -> PathBuf {
53 let expanded = expand_tilde(target);
54 let path = Path::new(&expanded);
55 let joined = if path.is_absolute() {
56 path.to_path_buf()
57 } else {
58 project_root.join(path)
59 };
60 normalize_lexically(&joined)
61}
62
63fn normalize_lexically(path: &Path) -> PathBuf {
64 use std::path::Component;
65
66 let mut out = PathBuf::new();
67 for component in path.components() {
68 match component {
69 Component::CurDir => {}
70 Component::ParentDir => {
71 if !out.pop() {
72 out.push(component.as_os_str());
73 }
74 }
75 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
76 out.push(component.as_os_str());
77 }
78 }
79 }
80 if out.as_os_str().is_empty() {
81 PathBuf::from(".")
82 } else {
83 out
84 }
85}
86
87fn is_empty_param(value: &Value) -> bool {
88 match value {
89 Value::Null => true,
90 Value::String(s) => s.is_empty(),
91 Value::Array(a) => a.is_empty(),
92 Value::Object(o) => o.is_empty(),
93 _ => false,
94 }
95}
96
97fn coerce_optional_int_result(
98 value: Option<&Value>,
99 param_name: &str,
100 min: i64,
101 max: i64,
102) -> Result<Option<u64>, TranslateError> {
103 let Some(value) = value else {
104 return Ok(None);
105 };
106 if value.is_null()
107 || matches!(value, Value::String(s) if s.is_empty())
108 || matches!(value, Value::Array(a) if a.is_empty())
109 || matches!(value, Value::Object(o) if o.is_empty())
110 {
111 return Ok(None);
112 }
113 if matches!(value, Value::Number(num) if num.as_i64() == Some(0) && min > 0) {
114 return Ok(None);
115 }
116
117 let int_error = || {
118 invalid_request(format!(
119 "{param_name} must be an integer between {min} and {max}"
120 ))
121 };
122 let n = match value {
123 Value::Number(num) => num.as_i64().ok_or_else(int_error)?,
124 Value::String(s) => {
125 let parsed = s.parse::<f64>().map_err(|_| int_error())?;
126 if !parsed.is_finite() || parsed.fract() != 0.0 {
127 return Err(int_error());
128 }
129 parsed as i64
130 }
131 _ => return Err(int_error()),
132 };
133 if n < min || n > max {
134 return Err(invalid_request(format!(
135 "{param_name} must be between {min} and {max}"
136 )));
137 }
138 Ok(Some(n as u64))
139}
140
141fn agent_args_map(args: &Value) -> Map<String, Value> {
142 args.as_object().cloned().unwrap_or_default()
143}
144
145fn insert_resolved_file(map: &mut Map<String, Value>, project_root: &Path, file_path: &str) {
146 let resolved = resolve_path_from_project_root(project_root, file_path);
147 map.insert(
148 "file".to_string(),
149 Value::String(resolved.to_string_lossy().into_owned()),
150 );
151}
152
153pub fn subc_translate(
154 bare_name: &str,
155 agent_args: &Value,
156 project_root: &Path,
157) -> Result<Translated, TranslateError> {
158 subc_translate_with_context(
159 bare_name,
160 agent_args,
161 project_root,
162 TranslateContext::default(),
163 )
164}
165
166pub fn subc_translate_with_context(
167 bare_name: &str,
168 agent_args: &Value,
169 project_root: &Path,
170 ctx: TranslateContext,
171) -> Result<Translated, TranslateError> {
172 match bare_name {
173 "status" => Ok(Translated {
174 command: "status".into(),
175 args: Map::new(),
176 }),
177 "read" => translate_read(agent_args, project_root),
178 "write" => translate_write(agent_args, project_root, ctx),
179 "edit" => translate_edit(agent_args, project_root, ctx),
180 "grep" => translate_grep(agent_args, project_root),
181 "search" => translate_search(agent_args),
182 "outline" => translate_outline(agent_args, project_root),
183 "inspect" => translate_inspect(agent_args, project_root),
184 other => Err(invalid_request(format!(
185 "subc_translate: unsupported tool {other:?}"
186 ))),
187 }
188}
189
190fn insert_common_mutation_flags(out: &mut Map<String, Value>, ctx: TranslateContext) {
191 out.insert(
192 "diagnostics".to_string(),
193 Value::Bool(ctx.diagnostics_on_edit),
194 );
195 out.insert("include_diff_content".to_string(), Value::Bool(true));
196}
197
198fn translate_read(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
199 let map_in = agent_args_map(args);
200 let file_path = map_in
201 .get("filePath")
202 .and_then(Value::as_str)
203 .filter(|s| !s.is_empty())
204 .ok_or_else(|| invalid_request("'filePath' is required"))?;
205
206 let mut out = Map::new();
207 insert_resolved_file(&mut out, project_root, file_path);
208
209 let mut start_line = map_in.get("startLine").and_then(Value::as_u64);
210 let mut end_line = map_in.get("endLine").and_then(Value::as_u64);
211
212 if start_line.is_none() {
213 if let Some(offset) = map_in.get("offset").and_then(Value::as_u64) {
214 start_line = Some(offset);
215 if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
216 end_line = Some(offset.saturating_add(limit).saturating_sub(1));
217 }
218 }
219 }
220
221 if let Some(sl) = start_line {
222 out.insert("start_line".to_string(), Value::Number(sl.into()));
223 }
224 if let Some(el) = end_line {
225 out.insert("end_line".to_string(), Value::Number(el.into()));
226 }
227 if map_in.get("offset").is_none() {
228 if let Some(limit) = map_in.get("limit").and_then(Value::as_u64) {
229 out.insert("limit".to_string(), Value::Number(limit.into()));
230 }
231 }
232
233 Ok(Translated {
234 command: "read".into(),
235 args: out,
236 })
237}
238
239fn translate_write(
240 args: &Value,
241 project_root: &Path,
242 ctx: TranslateContext,
243) -> Result<Translated, TranslateError> {
244 let map_in = agent_args_map(args);
245 let file_path = map_in
246 .get("filePath")
247 .and_then(Value::as_str)
248 .filter(|s| !s.is_empty())
249 .ok_or_else(|| invalid_request("'filePath' is required"))?;
250 let content = map_in
251 .get("content")
252 .and_then(Value::as_str)
253 .ok_or_else(|| invalid_request("write: missing required param 'content'"))?;
254
255 let mut out = Map::new();
256 insert_resolved_file(&mut out, project_root, file_path);
257 out.insert("content".to_string(), Value::String(content.to_string()));
258 out.insert("create_dirs".to_string(), Value::Bool(true));
259 insert_common_mutation_flags(&mut out, ctx);
260
261 Ok(Translated {
262 command: "write".into(),
263 args: out,
264 })
265}
266
267fn translate_edit(
268 args: &Value,
269 project_root: &Path,
270 ctx: TranslateContext,
271) -> Result<Translated, TranslateError> {
272 let map_in = agent_args_map(args);
273
274 if map_in.get("startLine").is_some() || map_in.get("endLine").is_some() {
275 return Err(invalid_request(
276 "edit: 'startLine'/'endLine' are not top-level parameters. \
277 For line-range edits, nest them inside the `edits` array. \
278 For find/replace, use 'oldString'/'newString'.",
279 ));
280 }
281
282 let file_path = map_in
283 .get("filePath")
284 .and_then(Value::as_str)
285 .filter(|s| !s.is_empty())
286 .ok_or_else(|| invalid_request("'filePath' is required"))?;
287
288 let file_str = resolve_path_from_project_root(project_root, file_path)
289 .to_string_lossy()
290 .into_owned();
291
292 if let Some(append) = map_in.get("appendContent").and_then(Value::as_str) {
293 let mut out = Map::new();
294 out.insert("file".to_string(), Value::String(file_str));
295 out.insert("op".to_string(), Value::String("append".into()));
296 out.insert(
297 "append_content".to_string(),
298 Value::String(append.to_string()),
299 );
300 out.insert("create_dirs".to_string(), Value::Bool(true));
301 insert_common_mutation_flags(&mut out, ctx);
302 return Ok(Translated {
303 command: "edit_match".into(),
304 args: out,
305 });
306 }
307
308 if let Some(edits) = map_in.get("edits").and_then(Value::as_array) {
309 let mut out = Map::new();
310 out.insert("file".to_string(), Value::String(file_str));
311 let translated_edits: Vec<Value> = edits
312 .iter()
313 .filter_map(|edit| {
314 let obj = edit.as_object()?;
315 let mut t = Map::new();
316 for (key, value) in obj {
317 let native_key = match key.as_str() {
318 "oldString" => "match",
319 "newString" => "replacement",
320 "startLine" => "line_start",
321 "endLine" => "line_end",
322 other => other,
323 };
324 t.insert(native_key.to_string(), value.clone());
325 }
326 Some(Value::Object(t))
327 })
328 .collect();
329 out.insert("edits".to_string(), Value::Array(translated_edits));
330 insert_common_mutation_flags(&mut out, ctx);
331 return Ok(Translated {
332 command: "batch".into(),
333 args: out,
334 });
335 }
336
337 let symbol_is_string = map_in.get("symbol").and_then(Value::as_str).is_some();
338 let old_string_is_string = map_in.get("oldString").and_then(Value::as_str).is_some();
339 let has_content = map_in.get("content").is_some();
340
341 if symbol_is_string && !old_string_is_string && has_content {
342 let mut out = Map::new();
343 out.insert("file".to_string(), Value::String(file_str));
344 out.insert(
345 "symbol".to_string(),
346 map_in.get("symbol").cloned().unwrap_or(Value::Null),
347 );
348 out.insert("operation".to_string(), Value::String("replace".into()));
349 out.insert(
350 "content".to_string(),
351 map_in.get("content").cloned().unwrap_or(Value::Null),
352 );
353 insert_common_mutation_flags(&mut out, ctx);
354 return Ok(Translated {
355 command: "edit_symbol".into(),
356 args: out,
357 });
358 }
359
360 if old_string_is_string {
361 let mut out = Map::new();
362 out.insert("file".to_string(), Value::String(file_str));
363 out.insert(
364 "match".to_string(),
365 Value::String(
366 map_in
367 .get("oldString")
368 .and_then(Value::as_str)
369 .unwrap_or("")
370 .to_string(),
371 ),
372 );
373 let replacement = map_in
374 .get("newString")
375 .and_then(Value::as_str)
376 .unwrap_or("");
377 out.insert(
378 "replacement".to_string(),
379 Value::String(replacement.to_string()),
380 );
381 if let Some(v) = map_in.get("replaceAll") {
382 out.insert("replace_all".to_string(), v.clone());
383 }
384 if map_in.contains_key("occurrence") {
385 if let Some(v) = map_in.get("occurrence") {
386 out.insert("occurrence".to_string(), v.clone());
387 }
388 }
389 insert_common_mutation_flags(&mut out, ctx);
390 return Ok(Translated {
391 command: "edit_match".into(),
392 args: out,
393 });
394 }
395
396 Err(invalid_request(
397 "edit: no edit mode resolved from arguments.",
398 ))
399}
400
401fn translate_grep(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
402 let map_in = agent_args_map(args);
403 let pattern = map_in
404 .get("pattern")
405 .and_then(Value::as_str)
406 .filter(|s| !s.is_empty())
407 .ok_or_else(|| invalid_request("grep: missing required param 'pattern'"))?;
408
409 let mut out = Map::new();
410 out.insert("pattern".to_string(), Value::String(pattern.to_string()));
411 out.insert("case_sensitive".to_string(), Value::Bool(true));
412 if let Some(include) = map_in.get("include") {
413 if !is_empty_param(include) {
414 let include_arg = include.as_str().ok_or_else(|| {
415 invalid_request("grep: 'include' must be a comma-separated string")
416 })?;
417 let includes = split_include_arg(include_arg)
418 .into_iter()
419 .map(|pattern| Value::String(normalize_glob(&pattern)))
420 .collect::<Vec<_>>();
421 if !includes.is_empty() {
422 out.insert("include".to_string(), Value::Array(includes));
423 }
424 }
425 }
426 if let Some(path_val) = map_in.get("path") {
427 if !is_empty_param(path_val) {
428 if let Some(path_str) = path_val.as_str() {
429 out.insert(
430 "path".to_string(),
431 Value::String(resolve_grep_path_arg(project_root, path_str)),
432 );
433 }
434 }
435 }
436 out.insert("max_results".to_string(), Value::Number(100u64.into()));
437
438 Ok(Translated {
439 command: "grep".into(),
440 args: out,
441 })
442}
443
444fn normalize_glob(pattern: &str) -> String {
445 if !pattern.contains('/') && !pattern.starts_with("**/") {
446 format!("**/{pattern}")
447 } else {
448 pattern.to_string()
449 }
450}
451
452fn split_include_arg(raw: &str) -> Vec<String> {
453 let mut out = Vec::new();
454 let mut depth = 0usize;
455 let mut buf = String::new();
456 for ch in raw.chars() {
457 match ch {
458 '{' => {
459 depth += 1;
460 buf.push(ch);
461 }
462 '}' => {
463 depth = depth.saturating_sub(1);
464 buf.push(ch);
465 }
466 ',' if depth == 0 => {
467 let trimmed = buf.trim();
468 if !trimmed.is_empty() {
469 out.push(trimmed.to_string());
470 }
471 buf.clear();
472 }
473 _ => buf.push(ch),
474 }
475 }
476 let trimmed = buf.trim();
477 if !trimmed.is_empty() {
478 out.push(trimmed.to_string());
479 }
480 out
481}
482
483fn search_path_exists(project_root: &Path, raw: &str) -> bool {
484 resolve_path_from_project_root(project_root, raw).exists()
485}
486
487fn split_search_path_arg(project_root: &Path, raw: &str) -> Vec<String> {
488 if search_path_exists(project_root, raw) || !raw.chars().any(char::is_whitespace) {
489 return vec![raw.to_string()];
490 }
491
492 let fragments = raw
493 .split_whitespace()
494 .filter(|fragment| !fragment.is_empty())
495 .collect::<Vec<_>>();
496 if fragments.len() < 2 {
497 return vec![raw.to_string()];
498 }
499
500 let existing = fragments
501 .iter()
502 .filter(|fragment| search_path_exists(project_root, fragment))
503 .map(|fragment| (*fragment).to_string())
504 .collect::<Vec<_>>();
505 if existing.is_empty() {
506 vec![raw.to_string()]
507 } else {
508 existing
509 }
510}
511
512fn resolve_grep_path_arg(project_root: &Path, raw: &str) -> String {
513 split_search_path_arg(project_root, raw)
514 .iter()
515 .map(|target| {
516 resolve_path_from_project_root(project_root, target)
517 .to_string_lossy()
518 .into_owned()
519 })
520 .collect::<Vec<_>>()
521 .join(" ")
522}
523
524fn translate_search(args: &Value) -> Result<Translated, TranslateError> {
525 let map_in = agent_args_map(args);
526 let query = map_in
527 .get("query")
528 .and_then(Value::as_str)
529 .filter(|s| !s.trim().is_empty())
530 .ok_or_else(|| {
531 invalid_request("semantic_search: invalid params: `query` must be a non-empty string")
532 })?;
533
534 let mut out = Map::new();
535 out.insert("query".to_string(), Value::String(query.to_string()));
536 let top_k = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)?.unwrap_or(10);
537 out.insert("top_k".to_string(), Value::Number(top_k.into()));
538 if let Some(hint) = map_in.get("hint") {
539 if !is_empty_param(hint) {
540 out.insert("hint".to_string(), hint.clone());
541 }
542 }
543
544 Ok(Translated {
545 command: "semantic_search".into(),
546 args: out,
547 })
548}
549
550fn translate_outline(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
551 let map_in = agent_args_map(args);
552 let files_flag = map_in
553 .get("files")
554 .and_then(Value::as_bool)
555 .unwrap_or(false);
556
557 let target = map_in
558 .get("target")
559 .ok_or_else(|| invalid_request("outline: missing required param 'target'"))?;
560
561 if is_empty_param(target) {
562 return Err(invalid_request(
563 "'target' must be a non-empty string or array of strings",
564 ));
565 }
566
567 let mut out = Map::new();
568
569 if let Some(arr) = target.as_array() {
570 if arr.is_empty() {
571 return Err(invalid_request(
572 "'target' must be a non-empty string or array of strings",
573 ));
574 }
575 if files_flag {
576 let resolved: Vec<Value> = arr
577 .iter()
578 .filter_map(|v| v.as_str())
579 .map(|entry| {
580 let p = resolve_path_from_project_root(project_root, entry);
581 Value::String(p.to_string_lossy().into_owned())
582 })
583 .collect();
584 out.insert("target".to_string(), Value::Array(resolved));
585 out.insert("files".to_string(), Value::Bool(true));
586 return Ok(Translated {
587 command: "outline".into(),
588 args: out,
589 });
590 }
591 let resolved: Vec<Value> = arr
592 .iter()
593 .filter_map(|v| v.as_str())
594 .map(|entry| {
595 let p = resolve_path_from_project_root(project_root, entry);
596 Value::String(p.to_string_lossy().into_owned())
597 })
598 .collect();
599 out.insert("files".to_string(), Value::Array(resolved));
600 return Ok(Translated {
601 command: "outline".into(),
602 args: out,
603 });
604 }
605
606 if let Some(url) = target.as_str() {
607 if !files_flag && (url.starts_with("http://") || url.starts_with("https://")) {
608 out.insert("file".to_string(), Value::String(url.to_string()));
609 return Ok(Translated {
610 command: "outline".into(),
611 args: out,
612 });
613 }
614 }
615
616 let target_str = target.as_str().ok_or_else(|| {
617 invalid_request("'target' must be a non-empty string or array of strings")
618 })?;
619
620 let resolved = resolve_path_from_project_root(project_root, target_str);
621 let is_dir = std::fs::metadata(&resolved)
622 .map(|m| m.is_dir())
623 .unwrap_or(false);
624
625 if files_flag {
626 if is_dir {
627 out.insert(
628 "directory".to_string(),
629 Value::String(resolved.to_string_lossy().into_owned()),
630 );
631 } else {
632 out.insert(
633 "file".to_string(),
634 Value::String(resolved.to_string_lossy().into_owned()),
635 );
636 }
637 out.insert("files".to_string(), Value::Bool(true));
638 } else if is_dir {
639 out.insert(
640 "directory".to_string(),
641 Value::String(resolved.to_string_lossy().into_owned()),
642 );
643 } else {
644 out.insert(
645 "file".to_string(),
646 Value::String(resolved.to_string_lossy().into_owned()),
647 );
648 }
649
650 Ok(Translated {
651 command: "outline".into(),
652 args: out,
653 })
654}
655
656fn translate_inspect(args: &Value, project_root: &Path) -> Result<Translated, TranslateError> {
657 let map_in = agent_args_map(args);
658 let mut out = Map::new();
659
660 if let Some(sections) = map_in.get("sections") {
661 if !is_empty_param(sections) {
662 out.insert("sections".to_string(), sections.clone());
663 }
664 }
665
666 if let Some(scope) = map_in.get("scope") {
667 if !is_empty_param(scope) {
668 match scope {
669 Value::String(s) if !s.is_empty() => {
670 let resolved = resolve_path_from_project_root(project_root, s);
671 out.insert(
672 "scope".to_string(),
673 Value::String(resolved.to_string_lossy().into_owned()),
674 );
675 }
676 Value::Array(arr) => {
677 let resolved: Vec<Value> = arr
678 .iter()
679 .filter_map(|v| v.as_str())
680 .map(|entry| {
681 let p = resolve_path_from_project_root(project_root, entry);
682 Value::String(p.to_string_lossy().into_owned())
683 })
684 .collect();
685 out.insert("scope".to_string(), Value::Array(resolved));
686 }
687 other => {
688 out.insert("scope".to_string(), other.clone());
689 }
690 }
691 }
692 }
693
694 if let Some(top_k) = coerce_optional_int_result(map_in.get("topK"), "topK", 1, 100)? {
695 out.insert("topK".to_string(), Value::Number(top_k.into()));
696 }
697
698 Ok(Translated {
699 command: "inspect".into(),
700 args: out,
701 })
702}