Skip to main content

vtcode_core/tools/
command_args.rs

1//! Shared helpers for command-style tool arguments.
2
3use serde_json::Value;
4
5use crate::tools::tool_intent::{
6    unified_exec_action, unified_exec_action_in, unified_exec_action_is,
7};
8
9const INDEXED_COMMAND_TYPE_ERROR: &str = "command array must contain only strings";
10const COMMAND_VALUE_TYPE_ERROR: &str = "command must be a string or array of strings";
11
12fn collect_indexed_command_parts(
13    payload: &serde_json::Map<String, Value>,
14    start_index: usize,
15) -> Result<Vec<String>, &'static str> {
16    let mut parts = Vec::new();
17    let mut index = start_index;
18    while let Some(value) = payload.get(&format!("command.{}", index)) {
19        let Some(part) = value.as_str() else {
20            return Err(INDEXED_COMMAND_TYPE_ERROR);
21        };
22        parts.push(part.to_string());
23        index += 1;
24    }
25    Ok(parts)
26}
27
28pub fn has_indexed_command_parts(args: &Value) -> bool {
29    let Some(payload) = args.as_object() else {
30        return false;
31    };
32
33    payload.contains_key("command.0") || payload.contains_key("command.1")
34}
35
36pub fn parse_indexed_command_parts(
37    payload: &serde_json::Map<String, Value>,
38) -> Result<Option<Vec<String>>, &'static str> {
39    let zero_based = collect_indexed_command_parts(payload, 0)?;
40    if !zero_based.is_empty() {
41        return Ok(Some(zero_based));
42    }
43
44    let one_based = collect_indexed_command_parts(payload, 1)?;
45    if one_based.is_empty() {
46        Ok(None)
47    } else {
48        Ok(Some(one_based))
49    }
50}
51
52pub fn normalize_indexed_command_args(args: &Value) -> Result<Option<Value>, &'static str> {
53    let Some(payload) = args.as_object() else {
54        return Ok(None);
55    };
56    if payload.get("command").is_some() {
57        return Ok(None);
58    }
59
60    let Some(parts) = parse_indexed_command_parts(payload)? else {
61        return Ok(None);
62    };
63
64    let mut normalized = payload.clone();
65    normalized.insert(
66        "command".to_string(),
67        Value::String(shell_words::join(parts.iter().map(String::as_str))),
68    );
69    Ok(Some(Value::Object(normalized)))
70}
71
72pub fn normalized_command_value(args: &Value) -> Result<Option<Value>, &'static str> {
73    if let Some(command) = args
74        .get("command")
75        .or_else(|| args.get("cmd"))
76        .or_else(|| args.get("raw_command"))
77    {
78        return Ok(Some(command.clone()));
79    }
80
81    Ok(normalize_indexed_command_args(args)?
82        .and_then(|normalized| normalized.get("command").cloned()))
83}
84
85pub fn command_words(args: &Value) -> Result<Option<Vec<String>>, &'static str> {
86    let Some(command) = normalized_command_value(args)? else {
87        return Ok(None);
88    };
89
90    let mut parts = match command {
91        Value::String(command) => {
92            shell_words::split(&command).map_err(|_| COMMAND_VALUE_TYPE_ERROR)?
93        }
94        Value::Array(values) => values
95            .iter()
96            .map(|value| {
97                value
98                    .as_str()
99                    .map(ToOwned::to_owned)
100                    .ok_or(COMMAND_VALUE_TYPE_ERROR)
101            })
102            .collect::<Result<Vec<_>, _>>()?,
103        _ => return Err(COMMAND_VALUE_TYPE_ERROR),
104    };
105
106    if let Some(extra_args) = args.get("args").and_then(Value::as_array) {
107        for value in extra_args {
108            let Some(part) = value.as_str() else {
109                return Err(COMMAND_VALUE_TYPE_ERROR);
110            };
111            parts.push(part.to_string());
112        }
113    }
114
115    if parts.is_empty() {
116        Ok(None)
117    } else {
118        Ok(Some(parts))
119    }
120}
121
122pub fn command_text(args: &Value) -> Result<Option<String>, &'static str> {
123    let Some(parts) = command_words(args)? else {
124        return Ok(None);
125    };
126    Ok(Some(shell_words::join(parts.iter().map(String::as_str))))
127}
128
129fn has_nonempty_string_field(args: &Value, key: &str) -> bool {
130    args.get(key)
131        .and_then(Value::as_str)
132        .map(str::trim)
133        .is_some_and(|value| !value.is_empty())
134}
135
136pub fn interactive_input_text(args: &Value) -> Option<&str> {
137    args.get("input")
138        .and_then(Value::as_str)
139        .or_else(|| args.get("chars").and_then(Value::as_str))
140        .or_else(|| args.get("text").and_then(Value::as_str))
141        .filter(|value| !value.is_empty())
142}
143
144pub fn session_id_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
145    payload
146        .get("session_id")
147        .or_else(|| payload.get("s"))
148        .and_then(Value::as_str)
149        .map(str::trim)
150        .filter(|value| !value.is_empty())
151}
152
153pub fn session_id_text(args: &Value) -> Option<&str> {
154    args.as_object().and_then(session_id_text_from_payload)
155}
156
157pub fn unified_exec_missing_required_args(args: &Value) -> Vec<&'static str> {
158    if unified_exec_action(args).is_none() {
159        return Vec::new();
160    }
161
162    let mut missing = Vec::new();
163    if unified_exec_action_is(args, "run") {
164        if command_text(args).ok().flatten().is_none() {
165            missing.push("command");
166        }
167    } else if unified_exec_action_is(args, "write") {
168        if session_id_text(args).is_none() {
169            missing.push("session_id");
170        }
171        if interactive_input_text(args).is_none() {
172            missing.push("input or chars or text");
173        }
174    } else if unified_exec_action_in(args, &["poll", "continue", "close"]) {
175        if session_id_text(args).is_none() {
176            missing.push("session_id");
177        }
178    } else if unified_exec_action_is(args, "inspect") {
179        let has_session_id = session_id_text(args).is_some();
180        let has_spool_path = has_nonempty_string_field(args, "spool_path");
181        if !has_session_id && !has_spool_path {
182            missing.push("session_id or spool_path");
183        }
184    } else if unified_exec_action_is(args, "code") {
185        let has_code =
186            has_nonempty_string_field(args, "code") || has_nonempty_string_field(args, "command");
187        if !has_code {
188            missing.push("code or command");
189        }
190    }
191
192    missing
193}
194
195pub fn unified_exec_requires_command_safety(args: &Value) -> bool {
196    unified_exec_action_is(args, "run")
197}
198
199pub fn working_dir_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
200    payload
201        .get("working_dir")
202        .or_else(|| payload.get("cwd"))
203        .or_else(|| payload.get("workdir"))
204        .and_then(Value::as_str)
205        .map(str::trim)
206        .filter(|value| !value.is_empty())
207}
208
209pub fn working_dir_text(args: &Value) -> Option<&str> {
210    args.as_object().and_then(working_dir_text_from_payload)
211}
212
213pub fn normalize_shell_args(args: &Value) -> Result<Value, &'static str> {
214    let mut normalized = match normalize_indexed_command_args(args)? {
215        Some(value) => value,
216        None => args.clone(),
217    };
218
219    let Some(payload) = normalized.as_object_mut() else {
220        return Ok(normalized);
221    };
222
223    if payload.get("command").is_none() {
224        if let Some(command) = payload.get("cmd").cloned() {
225            payload.insert("command".to_string(), command);
226        } else if let Some(command) = payload.get("raw_command").cloned() {
227            payload.insert("command".to_string(), command);
228        }
229    }
230
231    if payload.get("input").is_none() {
232        if let Some(input) = payload.get("chars").cloned() {
233            payload.insert("input".to_string(), input);
234        } else if let Some(input) = payload.get("text").cloned() {
235            payload.insert("input".to_string(), input);
236        }
237    }
238
239    if payload.get("session_id").is_none()
240        && let Some(session_id) = payload.get("s").cloned()
241    {
242        payload.insert("session_id".to_string(), session_id);
243    }
244
245    if payload.get("max_tokens").is_none()
246        && let Some(max_output_tokens) = payload.get("max_output_tokens").cloned()
247    {
248        payload.insert("max_tokens".to_string(), max_output_tokens);
249    }
250
251    if payload.get("max_output_tokens").is_none()
252        && let Some(max_tokens) = payload.get("max_tokens").cloned()
253    {
254        payload.insert("max_output_tokens".to_string(), max_tokens);
255    }
256
257    Ok(normalized)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::{
263        command_text, command_words, has_indexed_command_parts, interactive_input_text,
264        normalize_indexed_command_args, normalize_shell_args, normalized_command_value,
265        parse_indexed_command_parts, session_id_text, session_id_text_from_payload,
266        unified_exec_missing_required_args, unified_exec_requires_command_safety, working_dir_text,
267        working_dir_text_from_payload,
268    };
269    use serde_json::{Value, json};
270
271    #[test]
272    fn detects_indexed_command_keys() {
273        assert!(has_indexed_command_parts(&json!({"command.0": "ls"})));
274        assert!(has_indexed_command_parts(&json!({"command.1": "ls"})));
275        assert!(!has_indexed_command_parts(&json!({"command.2": "ls"})));
276    }
277
278    #[test]
279    fn parses_zero_based_indexed_command_parts() {
280        let parts = parse_indexed_command_parts(
281            json!({
282                "command.0": "ls",
283                "command.1": "-a"
284            })
285            .as_object()
286            .expect("object"),
287        )
288        .expect("valid indexed args");
289
290        assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
291    }
292
293    #[test]
294    fn parses_one_based_indexed_command_parts() {
295        let parts = parse_indexed_command_parts(
296            json!({
297                "command.1": "ls",
298                "command.2": "-a"
299            })
300            .as_object()
301            .expect("object"),
302        )
303        .expect("valid indexed args");
304
305        assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
306    }
307
308    #[test]
309    fn rejects_non_string_indexed_command_parts() {
310        let error = parse_indexed_command_parts(
311            json!({
312                "command.0": 42
313            })
314            .as_object()
315            .expect("object"),
316        )
317        .expect_err("non-string segment should fail");
318
319        assert_eq!(error, "command array must contain only strings");
320    }
321
322    #[test]
323    fn normalizes_indexed_command_args_into_command_string() {
324        let normalized = normalize_indexed_command_args(&json!({
325            "command.1": "ls",
326            "command.2": "-a",
327            "working_dir": "."
328        }))
329        .expect("valid indexed args")
330        .expect("normalized payload");
331
332        assert_eq!(
333            normalized.get("command").and_then(Value::as_str),
334            Some("ls -a")
335        );
336        assert_eq!(
337            normalized.get("working_dir").and_then(Value::as_str),
338            Some(".")
339        );
340    }
341
342    #[test]
343    fn normalized_command_value_prefers_cmd_aliases() {
344        let normalized = normalized_command_value(&json!({"cmd": "ls -a"}))
345            .expect("valid command alias")
346            .expect("command value");
347
348        assert_eq!(normalized.as_str(), Some("ls -a"));
349    }
350
351    #[test]
352    fn command_text_joins_command_arrays() {
353        let command = command_text(&json!({"command": ["git", "status", "--short"]}))
354            .expect("valid command")
355            .expect("command text");
356
357        assert_eq!(command, "git status --short");
358    }
359
360    #[test]
361    fn command_words_append_extra_args() {
362        let words = command_words(&json!({
363            "command": "cargo test",
364            "args": ["-p", "vtcode-core"]
365        }))
366        .expect("valid command")
367        .expect("command words");
368
369        assert_eq!(words, vec!["cargo", "test", "-p", "vtcode-core"]);
370    }
371
372    #[test]
373    fn interactive_input_text_preserves_whitespace() {
374        assert_eq!(
375            interactive_input_text(&json!({"chars": "  echo hi\n"})),
376            Some("  echo hi\n")
377        );
378    }
379
380    #[test]
381    fn session_id_text_trims_whitespace() {
382        assert_eq!(
383            session_id_text(&json!({"session_id": " run-1 "})),
384            Some("run-1")
385        );
386    }
387
388    #[test]
389    fn session_id_text_accepts_compact_alias() {
390        assert_eq!(session_id_text(&json!({"s": " run-1 "})), Some("run-1"));
391    }
392
393    #[test]
394    fn session_id_text_from_payload_accepts_aliases() {
395        let value = json!({"s": " run-1 "});
396        let payload = value.as_object().expect("object");
397        assert_eq!(session_id_text_from_payload(payload), Some("run-1"));
398    }
399
400    #[test]
401    fn working_dir_text_accepts_aliases() {
402        assert_eq!(working_dir_text(&json!({"workdir": " src "})), Some("src"));
403        assert_eq!(working_dir_text(&json!({"cwd": "."})), Some("."));
404    }
405
406    #[test]
407    fn working_dir_text_from_payload_accepts_aliases() {
408        let value = json!({"workdir": " src "});
409        let payload = value.as_object().expect("object");
410        assert_eq!(working_dir_text_from_payload(payload), Some("src"));
411    }
412
413    #[test]
414    fn normalize_shell_args_maps_codex_fields() {
415        let normalized = normalize_shell_args(&json!({
416            "cmd": "echo hi",
417            "chars": "status\n"
418        }))
419        .expect("valid shell args");
420
421        assert_eq!(
422            normalized.get("command").and_then(Value::as_str),
423            Some("echo hi")
424        );
425        assert_eq!(
426            normalized.get("input").and_then(Value::as_str),
427            Some("status\n")
428        );
429    }
430
431    #[test]
432    fn normalize_shell_args_maps_compact_session_id() {
433        let normalized = normalize_shell_args(&json!({
434            "s": "run-1"
435        }))
436        .expect("valid shell args");
437
438        assert_eq!(
439            normalized.get("session_id").and_then(Value::as_str),
440            Some("run-1")
441        );
442    }
443
444    #[test]
445    fn normalize_shell_args_copies_max_output_tokens_to_max_tokens() {
446        let normalized = normalize_shell_args(&json!({
447            "command": "echo hi",
448            "max_output_tokens": 42
449        }))
450        .expect("valid shell args");
451
452        assert_eq!(
453            normalized.get("max_output_tokens").and_then(Value::as_u64),
454            Some(42)
455        );
456        assert_eq!(
457            normalized.get("max_tokens").and_then(Value::as_u64),
458            Some(42)
459        );
460    }
461
462    #[test]
463    fn normalize_shell_args_copies_max_tokens_to_max_output_tokens() {
464        let normalized = normalize_shell_args(&json!({
465            "command": "echo hi",
466            "max_tokens": 42
467        }))
468        .expect("valid shell args");
469
470        assert_eq!(
471            normalized.get("max_tokens").and_then(Value::as_u64),
472            Some(42)
473        );
474        assert_eq!(
475            normalized.get("max_output_tokens").and_then(Value::as_u64),
476            Some(42)
477        );
478    }
479
480    #[test]
481    fn unified_exec_missing_required_args_is_action_aware() {
482        assert_eq!(
483            unified_exec_missing_required_args(&json!({"action": "run"})),
484            vec!["command"]
485        );
486        assert_eq!(
487            unified_exec_missing_required_args(&json!({"action": "write", "session_id": "run-1"})),
488            vec!["input or chars or text"]
489        );
490        assert_eq!(
491            unified_exec_missing_required_args(&json!({"action": "inspect"})),
492            vec!["session_id or spool_path"]
493        );
494        assert!(unified_exec_missing_required_args(&json!({"action": "list"})).is_empty());
495    }
496
497    #[test]
498    fn unified_exec_requires_command_safety_only_for_run() {
499        assert!(unified_exec_requires_command_safety(&json!({
500            "action": "run",
501            "command": "cargo check"
502        })));
503        assert!(!unified_exec_requires_command_safety(&json!({
504            "action": "poll",
505            "session_id": "run-1"
506        })));
507    }
508}