Skip to main content

agent_shell_parser/parse/
resolve.rs

1use std::sync::LazyLock;
2
3use super::tokenize::{find_base_command, is_env_assignment, parse_command};
4use super::types::{
5    CommandConfig, IndirectExecution, ResolvedCommand, UnanalyzableCommand, WrapperSpec,
6};
7
8static DEFAULT_CONFIG: LazyLock<CommandConfig> = LazyLock::new(|| {
9    serde_json::from_str(include_str!("../../config/commands.json"))
10        .expect("embedded commands.json is invalid")
11});
12
13/// Return the embedded default command configuration.
14pub fn default_command_config() -> &'static CommandConfig {
15    &DEFAULT_CONFIG
16}
17
18/// Resolve a command through the indirection layer using the default config.
19///
20/// Recursively strips transparent wrappers and classifies unanalyzable
21/// patterns (eval, shell spawn, source) based on the embedded command config.
22pub fn resolve_command(words: &[String]) -> ResolvedCommand {
23    resolve_command_with(words, &DEFAULT_CONFIG)
24}
25
26/// Maximum recursion depth for wrapper resolution to prevent unbounded loops.
27const MAX_RESOLVE_DEPTH: usize = 32;
28
29/// Resolve a command through the indirection layer using a custom config.
30///
31/// Same as [`resolve_command`] but accepts caller-provided [`CommandConfig`],
32/// allowing consumers to extend or replace the default command knowledge.
33pub fn resolve_command_with(words: &[String], config: &CommandConfig) -> ResolvedCommand {
34    resolve_command_impl(words, config, 0)
35}
36
37/// Classify the surface-level command without recursing into wrappers.
38///
39/// Returns `Some(kind)` if the command is an indirect execution pattern,
40/// `None` if it's a plain command. This is O(1) in wrapper depth — it only
41/// looks at the outermost command.
42pub(crate) fn classify_surface(
43    base: &str,
44    words: &[String],
45    config: &CommandConfig,
46) -> Option<IndirectExecution> {
47    if base.starts_with('$') {
48        return Some(IndirectExecution::Eval);
49    }
50    if config.eval_commands.iter().any(|c| c == base) {
51        return Some(IndirectExecution::Eval);
52    }
53    if config.shells.iter().any(|s| s == base) {
54        let has_c_flag = words.iter().any(|w| w == "-c");
55        return Some(if has_c_flag {
56            IndirectExecution::ShellSpawn
57        } else {
58            IndirectExecution::SourceScript
59        });
60    }
61    if config.source_commands.iter().any(|c| c == base) {
62        return Some(IndirectExecution::SourceScript);
63    }
64    if config.wrappers.iter().any(|w| w.name == base) {
65        return Some(IndirectExecution::CommandWrapper);
66    }
67    None
68}
69
70fn resolve_command_impl(words: &[String], config: &CommandConfig, depth: usize) -> ResolvedCommand {
71    if depth >= MAX_RESOLVE_DEPTH {
72        return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
73            command: find_base_command(words),
74            kind: IndirectExecution::CommandWrapper,
75        });
76    }
77
78    let base = find_base_command(words);
79
80    match classify_surface(&base, words, config) {
81        Some(IndirectExecution::CommandWrapper) => {}
82        Some(kind) => {
83            return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
84                command: base,
85                kind,
86            });
87        }
88        None => return ResolvedCommand::Resolved(parse_command(&words.join(" "))),
89    }
90
91    // It's a wrapper — check for unanalyzable flags, then strip and recurse.
92    let spec = config.wrappers.iter().find(|s| s.name == base).unwrap();
93    if !spec.unanalyzable_flags.is_empty()
94        && words.iter().any(|w| {
95            spec.unanalyzable_flags.iter().any(|f| {
96                w == f
97                    || w.starts_with(&format!("{f}="))
98                    || (f.starts_with('-')
99                        && f.len() == 2
100                        && w.starts_with('-')
101                        && !w.starts_with("--")
102                        && w.contains(f.chars().last().unwrap()))
103            })
104        })
105    {
106        return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
107            command: base,
108            kind: IndirectExecution::Eval,
109        });
110    }
111    let inner_start = strip_with_spec_idx(spec, words);
112    match inner_start {
113        None => return ResolvedCommand::Resolved(parse_command("")),
114        Some(idx) => {
115            debug_assert_ne!(idx, 0, "wrapper should always advance past itself");
116            if idx > 0 {
117                return resolve_command_impl(&words[idx..], config, depth + 1);
118            }
119        }
120    }
121
122    ResolvedCommand::Resolved(parse_command(&words.join(" ")))
123}
124
125/// Strip a wrapper command using its spec and return the remaining arguments.
126///
127/// Correctly handles value-consuming flags, env assignments, and `--`
128/// terminators as specified by the [`WrapperSpec`].
129pub fn strip_with_spec(spec: &WrapperSpec, words: &[String]) -> Vec<String> {
130    match strip_with_spec_idx(spec, words) {
131        None => vec![],
132        Some(idx) => words[idx..].to_vec(),
133    }
134}
135
136/// Strip a wrapper command using its spec and return the index where the inner
137/// command starts, or `None` if no inner command was found.
138///
139/// This avoids allocating a new `Vec<String>` — callers can slice the original
140/// word list directly.
141///
142/// Correctly handles value-consuming flags (including combined short forms like
143/// `-uroot`), env assignments, and `--` terminators as specified by the
144/// [`WrapperSpec`].
145fn strip_with_spec_idx(spec: &WrapperSpec, words: &[String]) -> Option<usize> {
146    let wrapper_idx = words.iter().position(|w| {
147        let base = match w.rsplit_once('/') {
148            Some((_, name)) => name,
149            None => w.as_str(),
150        };
151        base == spec.name
152    });
153    let start = wrapper_idx.map(|i| i + 1).unwrap_or(0);
154
155    let mut i = start;
156    let mut positionals_skipped = 0;
157    while i < words.len() {
158        let w = &words[i];
159
160        if spec.has_terminator && w == "--" {
161            i += 1;
162            break;
163        }
164
165        if spec.skip_env_assignments && is_env_assignment(w) {
166            i += 1;
167            continue;
168        }
169
170        if w.starts_with('-') && w.len() > 1 {
171            // Exact match for value-consuming flags (e.g., `-u` consuming next token)
172            if spec.short_value_flags.iter().any(|f| w == f)
173                || spec.long_value_flags.iter().any(|f| w == f)
174            {
175                i += 2;
176                if i > words.len() {
177                    return None;
178                }
179                continue;
180            }
181            // Long flags with `=` form (e.g., `--user=root`)
182            if let Some((flag_part, _)) = w.split_once('=') {
183                if spec.long_value_flags.iter().any(|f| f == flag_part)
184                    || spec.short_value_flags.iter().any(|f| f == flag_part)
185                {
186                    i += 1;
187                    continue;
188                }
189            }
190            // Combined short flags (e.g., `-uroot` where `-u` is a value flag)
191            // The value is embedded in the token — consume it and continue.
192            if spec
193                .short_value_flags
194                .iter()
195                .any(|f| w.starts_with(f.as_str()) && w.len() > f.len())
196            {
197                i += 1;
198                continue;
199            }
200            // Boolean flag — skip it
201            i += 1;
202            continue;
203        }
204
205        if positionals_skipped < spec.skip_positionals {
206            positionals_skipped += 1;
207            i += 1;
208            continue;
209        }
210
211        break;
212    }
213
214    if i >= words.len() {
215        return None;
216    }
217    Some(i)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn words(s: &str) -> Vec<String> {
225        shlex::split(s).unwrap_or_else(|| s.split_whitespace().map(String::from).collect())
226    }
227
228    fn spec(name: &str) -> WrapperSpec {
229        WrapperSpec {
230            name: name.to_string(),
231            short_value_flags: vec!["-v".to_string()],
232            long_value_flags: vec!["--val".to_string()],
233            unanalyzable_flags: vec![],
234            skip_env_assignments: false,
235            has_terminator: true,
236            skip_positionals: 0,
237        }
238    }
239
240    #[test]
241    fn strip_simple_wrapper() {
242        let s = spec("wrap");
243        let result = strip_with_spec(&s, &words("wrap inner cmd"));
244        assert_eq!(result, words("inner cmd"));
245    }
246
247    #[test]
248    fn strip_value_consuming_short_flag() {
249        let s = spec("wrap");
250        let result = strip_with_spec(&s, &words("wrap -v thing inner cmd"));
251        assert_eq!(result, words("inner cmd"));
252    }
253
254    #[test]
255    fn strip_value_consuming_long_flag() {
256        let s = spec("wrap");
257        let result = strip_with_spec(&s, &words("wrap --val thing inner cmd"));
258        assert_eq!(result, words("inner cmd"));
259    }
260
261    #[test]
262    fn strip_long_flag_equals_form() {
263        let s = spec("wrap");
264        let result = strip_with_spec(&s, &words("wrap --val=thing inner cmd"));
265        assert_eq!(result, words("inner cmd"));
266    }
267
268    #[test]
269    fn strip_terminator_stops_flag_processing() {
270        let s = spec("wrap");
271        let result = strip_with_spec(&s, &words("wrap -x -- -v notflag cmd"));
272        assert_eq!(result, words("-v notflag cmd"));
273    }
274
275    #[test]
276    fn strip_boolean_flag_skipped() {
277        let s = spec("wrap");
278        let result = strip_with_spec(&s, &words("wrap -x --verbose inner"));
279        assert_eq!(result, words("inner"));
280    }
281
282    #[test]
283    fn strip_env_assignments_when_configured() {
284        let s = WrapperSpec {
285            name: "wrap".to_string(),
286            short_value_flags: vec![],
287            long_value_flags: vec![],
288            unanalyzable_flags: vec![],
289            skip_env_assignments: true,
290            has_terminator: false,
291            skip_positionals: 0,
292        };
293        let result = strip_with_spec(&s, &words("wrap FOO=bar BAZ=qux inner cmd"));
294        assert_eq!(result, words("inner cmd"));
295    }
296
297    #[test]
298    fn strip_truncated_value_flag_returns_empty() {
299        let s = spec("wrap");
300        let result = strip_with_spec(&s, &words("wrap -v"));
301        assert!(result.is_empty());
302    }
303
304    #[test]
305    fn strip_no_inner_command_returns_empty() {
306        let s = spec("wrap");
307        let result = strip_with_spec(&s, &words("wrap -x --verbose"));
308        assert!(result.is_empty());
309    }
310
311    #[test]
312    fn strip_path_prefixed_wrapper() {
313        let s = spec("wrap");
314        let result = strip_with_spec(&s, &words("/usr/bin/wrap inner cmd"));
315        assert_eq!(result, words("inner cmd"));
316    }
317
318    #[test]
319    fn resolve_with_custom_config() {
320        let config = CommandConfig {
321            wrappers: vec![WrapperSpec {
322                name: "mywrap".to_string(),
323                short_value_flags: vec!["-x".to_string()],
324                long_value_flags: vec![],
325                unanalyzable_flags: vec![],
326                skip_env_assignments: false,
327                has_terminator: false,
328                skip_positionals: 0,
329            }],
330            shells: vec!["mysh".to_string()],
331            eval_commands: vec!["myeval".to_string()],
332            source_commands: vec!["mysource".to_string()],
333        };
334
335        match resolve_command_with(&words("mywrap -x val inner"), &config) {
336            ResolvedCommand::Resolved(p) => assert_eq!(p.command, "inner"),
337            _ => panic!("expected Resolved"),
338        }
339
340        assert!(matches!(
341            resolve_command_with(&words("mysh -c 'code'"), &config),
342            ResolvedCommand::Unanalyzable(_)
343        ));
344
345        assert!(matches!(
346            resolve_command_with(&words("myeval 'code'"), &config),
347            ResolvedCommand::Unanalyzable(_)
348        ));
349
350        assert!(matches!(
351            resolve_command_with(&words("mysource file.sh"), &config),
352            ResolvedCommand::Unanalyzable(_)
353        ));
354    }
355}