raz_override/parser/
smart_parser.rs

1//! Smart override parser for simplified CLI syntax
2//!
3//! Parses user input without requiring --env, --options, --args flags
4
5use regex::Regex;
6use std::collections::HashMap;
7
8/// Parsed override components
9#[derive(Debug, Clone, Default)]
10pub struct ParsedOverrides {
11    /// Environment variables
12    pub env_vars: HashMap<String, String>,
13    /// Command options (before --)
14    pub options: Vec<String>,
15    /// Arguments (after --)
16    pub args: Vec<String>,
17    /// Raw input for debugging
18    pub raw_input: String,
19}
20
21impl ParsedOverrides {
22    /// Check if the overrides are empty
23    pub fn is_empty(&self) -> bool {
24        self.env_vars.is_empty() && self.options.is_empty() && self.args.is_empty()
25    }
26}
27
28/// Override operator for VS Code syntax
29#[derive(Debug, Clone, PartialEq)]
30pub enum OverrideOperator {
31    /// Default: replace existing
32    Replace,
33    /// +: Add/merge with existing
34    Add,
35    /// -: Remove from existing
36    Remove,
37    /// !: Force replace (override conflicts)
38    Force,
39}
40
41/// Parse operator prefix from option
42fn parse_operator(option: &str) -> (OverrideOperator, &str) {
43    if option.starts_with("+--") {
44        (OverrideOperator::Add, &option[1..])
45    } else if option.starts_with("---") {
46        (OverrideOperator::Remove, &option[1..])
47    } else if option.starts_with("!--") {
48        (OverrideOperator::Force, &option[1..])
49    } else {
50        (OverrideOperator::Replace, option)
51    }
52}
53
54/// Smart override parser
55pub struct SmartOverrideParser {
56    /// Regex for environment variables
57    env_regex: Regex,
58    /// Current cargo subcommand context
59    #[allow(dead_code)]
60    subcommand: String,
61}
62
63impl SmartOverrideParser {
64    /// Create a new parser with the given cargo subcommand context
65    pub fn new(subcommand: impl Into<String>) -> Self {
66        Self {
67            // Match SCREAMING_CASE=value or PascalCase=value
68            env_regex: Regex::new(r#"^([A-Z][A-Z0-9_]*|[A-Z][a-zA-Z0-9]*)=(.+)$"#).unwrap(),
69            subcommand: subcommand.into(),
70        }
71    }
72
73    /// Parse raw input string into override components
74    pub fn parse(&self, input: &str) -> ParsedOverrides {
75        let mut result = ParsedOverrides {
76            raw_input: input.to_string(),
77            ..Default::default()
78        };
79
80        // First, split by -- to separate options from args
81        // Special case: if input starts with "-- ", everything is args
82        if let Some(stripped) = input.strip_prefix("-- ") {
83            result.args = shell_words::split(stripped)
84                .unwrap_or_else(|_| stripped.split_whitespace().map(String::from).collect());
85            return result;
86        }
87
88        let parts: Vec<&str> = input.splitn(2, " -- ").collect();
89        let before_dash = parts[0];
90        let after_dash = parts.get(1);
91
92        // Parse everything after --
93        if let Some(args_str) = after_dash {
94            result.args = shell_words::split(args_str).unwrap_or_else(|_| {
95                // Fallback to simple split if shell_words fails
96                args_str.split_whitespace().map(String::from).collect()
97            });
98        }
99
100        // Parse everything before --
101        let tokens = shell_words::split(before_dash).unwrap_or_else(|_| {
102            // Fallback to simple split if shell_words fails
103            before_dash.split_whitespace().map(String::from).collect()
104        });
105
106        let mut i = 0;
107        while i < tokens.len() {
108            let token = &tokens[i];
109
110            // Check if it's an environment variable
111            if let Some(captures) = self.env_regex.captures(token) {
112                let key = captures.get(1).unwrap().as_str().to_string();
113                let value = captures.get(2).unwrap().as_str().to_string();
114                result.env_vars.insert(key, value);
115                i += 1;
116                continue;
117            }
118
119            // Check if it's an option (starts with -)
120            let (_operator, clean_option) = parse_operator(token);
121
122            if clean_option.starts_with("-") {
123                // It's an option
124                result.options.push(clean_option.to_string());
125
126                // Check if next token might be its value
127                if i + 1 < tokens.len() && !tokens[i + 1].starts_with("-") {
128                    // Simple heuristic: if next token doesn't start with -, assume it's a value
129                    // Skip obvious non-value patterns
130                    let next_token = &tokens[i + 1];
131                    if !self.env_regex.is_match(next_token) {
132                        result.options.push(next_token.clone());
133                        i += 1;
134                    }
135                }
136                i += 1;
137            } else {
138                // Neither env var nor option - could be a positional argument
139                // This shouldn't happen in override context, but handle gracefully
140                result.options.push(token.clone());
141                i += 1;
142            }
143        }
144
145        result
146    }
147
148    /// Parse VS Code input with operator support
149    pub fn parse_vscode_input(
150        &self,
151        input: &str,
152    ) -> (ParsedOverrides, HashMap<String, OverrideOperator>) {
153        let mut operators = HashMap::new();
154        let mut result = ParsedOverrides {
155            raw_input: input.to_string(),
156            ..Default::default()
157        };
158
159        // Split by -- first
160        let parts: Vec<&str> = input.splitn(2, " -- ").collect();
161        let before_dash = parts[0];
162        let after_dash = parts.get(1);
163
164        // Parse args after --
165        if let Some(args_str) = after_dash {
166            result.args = shell_words::split(args_str)
167                .unwrap_or_else(|_| args_str.split_whitespace().map(String::from).collect());
168        }
169
170        // Parse tokens before --
171        let tokens = shell_words::split(before_dash)
172            .unwrap_or_else(|_| before_dash.split_whitespace().map(String::from).collect());
173
174        let mut i = 0;
175        while i < tokens.len() {
176            let token = &tokens[i];
177
178            // Check for environment variable
179            if let Some(captures) = self.env_regex.captures(token) {
180                let key = captures.get(1).unwrap().as_str().to_string();
181                let value = captures.get(2).unwrap().as_str().to_string();
182                result.env_vars.insert(key, value);
183                i += 1;
184                continue;
185            }
186
187            // Parse operator and option
188            let (operator, clean_option) = parse_operator(token);
189
190            // Store operator for this option
191            if operator != OverrideOperator::Replace {
192                operators.insert(clean_option.to_string(), operator.clone());
193            }
194
195            // Handle remove operator by converting to --no- prefix
196            let final_option =
197                if operator == OverrideOperator::Remove && clean_option.starts_with("--") {
198                    format!("--no-{}", &clean_option[2..])
199                } else {
200                    clean_option.to_string()
201                };
202
203            // Add the option
204            result.options.push(final_option);
205
206            // Check for option value
207            if i + 1 < tokens.len() && !tokens[i + 1].starts_with("-") {
208                result.options.push(tokens[i + 1].clone());
209                i += 2;
210            } else {
211                i += 1;
212            }
213        }
214
215        (result, operators)
216    }
217}
218
219/// Convert parsed overrides back to CLI arguments
220pub fn overrides_to_cli_args(overrides: &ParsedOverrides) -> Vec<String> {
221    let mut args = vec![];
222
223    // Add environment variables
224    for (key, value) in &overrides.env_vars {
225        args.push(format!("--env={key}={value}"));
226    }
227
228    // Add options
229    if !overrides.options.is_empty() {
230        args.push(format!("--options={}", overrides.options.join(" ")));
231    }
232
233    // Add args
234    if !overrides.args.is_empty() {
235        args.push(format!("--args={}", overrides.args.join(" ")));
236    }
237
238    args
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_parse_simple_env() {
247        let parser = SmartOverrideParser::new("run");
248        let result = parser.parse("RUST_BACKTRACE=full");
249
250        assert_eq!(
251            result.env_vars.get("RUST_BACKTRACE"),
252            Some(&"full".to_string())
253        );
254        assert!(result.options.is_empty());
255        assert!(result.args.is_empty());
256    }
257
258    #[test]
259    fn test_parse_quoted_env() {
260        let parser = SmartOverrideParser::new("run");
261        let result = parser.parse(r#"RUST_FLAGS="-A warnings""#);
262
263        assert_eq!(
264            result.env_vars.get("RUST_FLAGS"),
265            Some(&"-A warnings".to_string())
266        );
267    }
268
269    #[test]
270    fn test_parse_options() {
271        let parser = SmartOverrideParser::new("run");
272        let result = parser.parse("--release --target aarch64-apple-darwin");
273
274        assert_eq!(
275            result.options,
276            vec!["--release", "--target", "aarch64-apple-darwin"]
277        );
278        assert!(result.env_vars.is_empty());
279        assert!(result.args.is_empty());
280    }
281
282    #[test]
283    fn test_parse_with_args() {
284        let parser = SmartOverrideParser::new("test");
285        let result = parser.parse("--release -- --exact --nocapture");
286
287        assert_eq!(result.options, vec!["--release"]);
288        assert_eq!(result.args, vec!["--exact", "--nocapture"]);
289    }
290
291    #[test]
292    fn test_parse_mixed() {
293        let parser = SmartOverrideParser::new("run");
294        let result = parser.parse("RUST_LOG=debug --release --features ssr -- --port 3000");
295
296        assert_eq!(result.env_vars.get("RUST_LOG"), Some(&"debug".to_string()));
297        assert_eq!(result.options, vec!["--release", "--features", "ssr"]);
298        assert_eq!(result.args, vec!["--port", "3000"]);
299    }
300
301    #[test]
302    fn test_parse_vscode_operators() {
303        let parser = SmartOverrideParser::new("run");
304        let (result, operators) = parser.parse_vscode_input("+--features new ---default-features");
305
306        assert_eq!(
307            result.options,
308            vec!["--features", "new", "--no-default-features"]
309        );
310        assert_eq!(operators.get("--features"), Some(&OverrideOperator::Add));
311        assert_eq!(
312            operators.get("--default-features"),
313            Some(&OverrideOperator::Remove)
314        );
315    }
316
317    #[test]
318    fn test_framework_options() {
319        let parser = SmartOverrideParser::new("run");
320        let result = parser.parse("--platform web --device true");
321
322        // These are not cargo options, but should still be captured
323        assert_eq!(
324            result.options,
325            vec!["--platform", "web", "--device", "true"]
326        );
327    }
328}