1use 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}