Skip to main content

aft/
subc_translate.rs

1//! Agent-facing tool → native command translation (subc edge only).
2
3use 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}