intelli_shell/model/
variable.rs

1use std::{
2    collections::HashSet,
3    convert::Infallible,
4    fmt::{Display, Formatter},
5    mem,
6    str::FromStr,
7};
8
9use heck::ToShoutySnakeCase;
10use itertools::Itertools;
11use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
12
13use crate::utils::{
14    COMMAND_VARIABLE_REGEX, COMMAND_VARIABLE_REGEX_ALT, SplitCaptures, SplitItem, flatten_str, flatten_variable,
15};
16
17/// Suggestion for a variable value
18pub enum VariableSuggestion {
19    /// A new secret value, the user must input it and it won't be stored
20    Secret,
21    /// A new value, if the user enters it, it must be then stored
22    New,
23    /// Suggestion from the environment variables
24    Environment {
25        env_var_name: String,
26        value: Option<String>,
27    },
28    /// Suggestion for an already-stored value
29    Existing(VariableValue),
30    /// Literal suggestion, derived from the variable name itself
31    Derived(String),
32}
33
34/// Type to represent a variable value
35#[derive(Clone)]
36#[cfg_attr(debug_assertions, derive(Debug))]
37pub struct VariableValue {
38    /// The unique identifier for the value (if stored)
39    pub id: Option<i32>,
40    /// The flattened root command (i.e., the first word)
41    pub flat_root_cmd: String,
42    /// The flattened variable name (or multiple, e.g., "var1|var2")
43    pub flat_variable: String,
44    /// The variable value
45    pub value: String,
46}
47
48impl VariableValue {
49    /// Creates a new variable value
50    pub fn new(root_cmd: impl Into<String>, variable_name: impl Into<String>, value: impl Into<String>) -> Self {
51        Self {
52            id: None,
53            flat_root_cmd: flatten_str(root_cmd.into()),
54            flat_variable: flatten_variable(variable_name.into()),
55            value: value.into(),
56        }
57    }
58}
59
60/// A command containing variables
61#[cfg_attr(debug_assertions, derive(Debug))]
62#[derive(Clone)]
63pub struct DynamicCommand {
64    /// The root command (i.e., the first word)
65    pub root: String,
66    /// The different parts of the command, including variables or values
67    pub parts: Vec<CommandPart>,
68}
69impl DynamicCommand {
70    /// Parses the given command as a [DynamicCommand]
71    pub fn parse(cmd: impl AsRef<str>, alt: bool) -> Self {
72        let cmd = cmd.as_ref();
73        let regex = if alt {
74            &COMMAND_VARIABLE_REGEX_ALT
75        } else {
76            &COMMAND_VARIABLE_REGEX
77        };
78        let splitter = SplitCaptures::new(regex, cmd);
79        let parts = splitter
80            .map(|e| match e {
81                SplitItem::Unmatched(t) => CommandPart::Text(t.to_owned()),
82                SplitItem::Captured(c) => {
83                    CommandPart::Variable(Variable::parse(c.get(1).or(c.get(2)).unwrap().as_str()))
84                }
85            })
86            .collect::<Vec<_>>();
87
88        DynamicCommand {
89            root: cmd.split_whitespace().next().unwrap_or(cmd).to_owned(),
90            parts,
91        }
92    }
93
94    /// Checks if the command has any variables without value
95    pub fn has_pending_variable(&self) -> bool {
96        self.parts.iter().any(|part| matches!(part, CommandPart::Variable(_)))
97    }
98
99    /// Retrieves the first variable without value in the command
100    pub fn current_variable(&self) -> Option<&Variable> {
101        self.parts.iter().find_map(|part| {
102            if let CommandPart::Variable(v) = part {
103                Some(v)
104            } else {
105                None
106            }
107        })
108    }
109
110    /// Retrieves the context for the current variable in the command
111    pub fn current_variable_context(&self) -> impl IntoIterator<Item = (String, String)> {
112        self.parts
113            .iter()
114            .take_while(|part| !matches!(part, CommandPart::Variable(_)))
115            .filter_map(|part| {
116                if let CommandPart::VariableValue(v, value) = part
117                    && !v.secret
118                {
119                    Some((v.name.clone(), value.clone()))
120                } else {
121                    None
122                }
123            })
124    }
125
126    /// Updates the first variable in the command for the given value
127    pub fn set_next_variable(&mut self, value: impl Into<String>) {
128        // Find the first part in the command that is an unfilled variable
129        if let Some(part) = self.parts.iter_mut().find(|p| matches!(p, CommandPart::Variable(_))) {
130            // Replace it with the filled variable including the value
131            if let CommandPart::Variable(v) = mem::take(part) {
132                *part = CommandPart::VariableValue(v, value.into());
133            } else {
134                unreachable!();
135            }
136        }
137    }
138
139    /// Creates a [VariableValue] for this command with the given variable name and value
140    pub fn new_variable_value_for(&self, variable_name: impl Into<String>, value: impl Into<String>) -> VariableValue {
141        VariableValue::new(&self.root, variable_name, value)
142    }
143}
144impl Display for DynamicCommand {
145    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
146        for part in self.parts.iter() {
147            write!(f, "{part}")?;
148        }
149        Ok(())
150    }
151}
152
153/// Represents a part of a command, which can be either text, a variable, or a variable value
154#[cfg_attr(debug_assertions, derive(Debug, PartialEq, Eq))]
155#[derive(Clone)]
156pub enum CommandPart {
157    Text(String),
158    Variable(Variable),
159    VariableValue(Variable, String),
160}
161impl Default for CommandPart {
162    fn default() -> Self {
163        Self::Text(String::new())
164    }
165}
166impl Display for CommandPart {
167    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
168        match self {
169            CommandPart::Text(t) => write!(f, "{t}"),
170            CommandPart::Variable(v) => write!(f, "{{{{{}}}}}", v.name),
171            CommandPart::VariableValue(_, value) => write!(f, "{value}"),
172        }
173    }
174}
175
176/// Represents a variable from a command
177#[cfg_attr(debug_assertions, derive(Debug, PartialEq, Eq))]
178#[derive(Clone)]
179pub struct Variable {
180    /// The variable name, as it was displayed on the command
181    pub name: String,
182    /// Parsed variable values derived from the name
183    pub options: Vec<String>,
184    /// Parsed varable functions to apply
185    pub functions: Vec<VariableFunction>,
186    /// Whether the variable is secret
187    pub secret: bool,
188}
189impl Variable {
190    /// Parses a variable into its components
191    pub fn parse(text: impl Into<String>) -> Self {
192        let name: String = text.into();
193
194        // Determine if the variable is secret or not
195        let (variable_name, secret) = match is_secret_variable(&name) {
196            Some(inner) => (inner, true),
197            None => (name.as_str(), false),
198        };
199
200        // Split the variable name in parts
201        let parts: Vec<&str> = variable_name.split(':').collect();
202        let mut functions = Vec::new();
203        let mut boundary_index = parts.len();
204
205        // Iterate from right-to-left to find the boundary between options and functions
206        if parts.len() > 1 {
207            for (i, part) in parts.iter().enumerate().rev() {
208                if let Ok(func) = VariableFunction::from_str(part) {
209                    functions.push(func);
210                    boundary_index = i;
211                } else {
212                    break;
213                }
214            }
215        }
216
217        // The collected functions are in reverse order
218        functions.reverse();
219
220        // Join the option parts back together, then split them by the pipe character
221        let options_str = &parts[..boundary_index].join(":");
222        let options = if options_str.is_empty() {
223            vec![]
224        } else {
225            options_str
226                .split('|')
227                .map(|o| o.trim())
228                .filter(|o| !o.is_empty())
229                .map(String::from)
230                .collect()
231        };
232
233        Self {
234            name,
235            options,
236            functions,
237            secret,
238        }
239    }
240
241    /// Retrieves the env var names where the value for this variable might reside (in order of preference)
242    pub fn env_var_names(&self, include_options: bool) -> HashSet<String> {
243        let mut names = HashSet::new();
244        let env_var_name = self.name.to_shouty_snake_case();
245        if !env_var_name.trim().is_empty() && env_var_name.trim() != "PATH" {
246            names.insert(env_var_name.trim().to_owned());
247        }
248        let env_var_name_no_fn = self.options.iter().join("|").to_shouty_snake_case();
249        if !env_var_name_no_fn.trim().is_empty() && env_var_name_no_fn.trim() != "PATH" {
250            names.insert(env_var_name_no_fn.trim().to_owned());
251        }
252        if include_options {
253            names.extend(
254                self.options
255                    .iter()
256                    .map(|o| o.to_shouty_snake_case())
257                    .filter(|o| !o.trim().is_empty())
258                    .filter(|o| o.trim() != "PATH")
259                    .map(|o| o.trim().to_owned()),
260            );
261        }
262        names
263    }
264
265    /// Applies variable functions to the given text
266    pub fn apply_functions_to(&self, text: impl Into<String>) -> String {
267        let text = text.into();
268        let mut result = text;
269        for func in self.functions.iter() {
270            result = func.apply_to(result);
271        }
272        result
273    }
274
275    /// Iterates every function to check if a char has to be replaced
276    pub fn check_functions_char(&self, ch: char) -> Option<String> {
277        let mut out: Option<String> = None;
278        for func in self.functions.iter() {
279            if let Some(ref mut out) = out {
280                let mut new_out = String::from("");
281                for ch in out.chars() {
282                    if let Some(replacement) = func.check_char(ch) {
283                        new_out.push_str(&replacement);
284                    } else {
285                        new_out.push(ch);
286                    }
287                }
288                *out = new_out;
289            } else if let Some(replacement) = func.check_char(ch) {
290                out = Some(replacement);
291            }
292        }
293        out
294    }
295}
296impl FromStr for Variable {
297    type Err = Infallible;
298
299    fn from_str(s: &str) -> Result<Self, Self::Err> {
300        Ok(Self::parse(s))
301    }
302}
303
304/// Functions that can be applied to variable values
305#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::EnumString)]
306pub enum VariableFunction {
307    #[strum(serialize = "kebab")]
308    KebabCase,
309    #[strum(serialize = "snake")]
310    SnakeCase,
311    #[strum(serialize = "upper")]
312    UpperCase,
313    #[strum(serialize = "lower")]
314    LowerCase,
315    #[strum(serialize = "url")]
316    Urlencode,
317}
318impl VariableFunction {
319    /// Applies this function to the given text
320    pub fn apply_to(&self, input: impl AsRef<str>) -> String {
321        let input = input.as_ref();
322        match self {
323            Self::KebabCase => replace_separators(input, '-'),
324            Self::SnakeCase => replace_separators(input, '_'),
325            Self::UpperCase => input.to_uppercase(),
326            Self::LowerCase => input.to_lowercase(),
327            Self::Urlencode => idempotent_percent_encode(input, NON_ALPHANUMERIC),
328        }
329    }
330
331    /// Checks if this char would be transformed by this function
332    pub fn check_char(&self, ch: char) -> Option<String> {
333        match self {
334            Self::KebabCase | Self::SnakeCase => {
335                let separator = if *self == Self::KebabCase { '-' } else { '_' };
336                if ch != separator && is_separator(ch) {
337                    Some(separator.to_string())
338                } else {
339                    None
340                }
341            }
342            Self::UpperCase => {
343                if ch.is_lowercase() {
344                    Some(ch.to_uppercase().to_string())
345                } else {
346                    None
347                }
348            }
349            Self::LowerCase => {
350                if ch.is_uppercase() {
351                    Some(ch.to_lowercase().to_string())
352                } else {
353                    None
354                }
355            }
356            Self::Urlencode => {
357                if ch.is_ascii_alphanumeric() {
358                    None
359                } else {
360                    Some(idempotent_percent_encode(&ch.to_string(), NON_ALPHANUMERIC))
361                }
362            }
363        }
364    }
365}
366
367/// Checks if a given variable is a secret (must not be stored), returning the inner variable name if it is
368fn is_secret_variable(variable_name: &str) -> Option<&str> {
369    if (variable_name.starts_with('*') && variable_name.ends_with('*') && variable_name.len() > 1)
370        || (variable_name.starts_with('{') && variable_name.ends_with('}'))
371    {
372        Some(&variable_name[1..variable_name.len() - 1])
373    } else {
374        None
375    }
376}
377
378/// Checks if a character is a separator
379fn is_separator(c: char) -> bool {
380    c == '-' || c == '_' || c.is_whitespace()
381}
382
383// This function replaces any sequence of separators with a single one
384fn replace_separators(s: &str, separator: char) -> String {
385    let mut result = String::with_capacity(s.len());
386
387    // Split the string using the custom separator logic and filter out empty results
388    let mut words = s.split(is_separator).filter(|word| !word.is_empty());
389
390    // Join the first word without a separator
391    if let Some(first_word) = words.next() {
392        result.push_str(first_word);
393    }
394    // Append the separator and the rest of the words
395    for word in words {
396        result.push(separator);
397        result.push_str(word);
398    }
399
400    result
401}
402
403/// Idempotently percent-encodes a string.
404///
405/// This function first checks if the input is already a correctly percent-encoded string
406/// according to the provided `encode_set`.
407/// - If it is, the input is returned
408/// - If it is not (i.e., it's unencoded, partially encoded, or incorrectly encoded), the function encodes the entire
409///   input string and returns a new `String`
410pub fn idempotent_percent_encode(input: &str, encode_set: &'static AsciiSet) -> String {
411    // Attempt to decode the input
412    if let Ok(decoded) = percent_decode_str(input).decode_utf8() {
413        // If successful, re-encode the decoded string using the same character set
414        let re_encoded = utf8_percent_encode(&decoded, encode_set).to_string();
415
416        // If the re-encoded string matches the original input, it means the input was already perfectly encoded
417        if re_encoded == input {
418            return re_encoded;
419        }
420    }
421
422    // In all other cases (decoding failed, or the re-encoded string is different), encode it fully
423    utf8_percent_encode(input, encode_set).to_string().to_string()
424}
425
426#[cfg(test)]
427mod tests {
428    use pretty_assertions::assert_eq;
429
430    use super::*;
431    #[test]
432    fn test_parse_command_with_variables() {
433        let cmd = DynamicCommand::parse("git commit -m {{{message}}} --author {{author:kebab}}", false);
434        assert_eq!(cmd.root, "git");
435        assert_eq!(cmd.parts.len(), 4);
436        assert_eq!(cmd.parts[0], CommandPart::Text("git commit -m ".into()));
437        assert!(matches!(cmd.parts[1], CommandPart::Variable(_)));
438        assert_eq!(cmd.parts[2], CommandPart::Text(" --author ".into()));
439        assert!(matches!(cmd.parts[3], CommandPart::Variable(_)));
440    }
441
442    #[test]
443    fn test_parse_command_no_variables() {
444        let cmd = DynamicCommand::parse("echo 'hello world'", false);
445        assert_eq!(cmd.root, "echo");
446        assert_eq!(cmd.parts.len(), 1);
447        assert_eq!(cmd.parts[0], CommandPart::Text("echo 'hello world'".into()));
448    }
449
450    #[test]
451    fn test_set_next_variable() {
452        let mut cmd = DynamicCommand::parse("cmd {{var1}} {{var2}}", false);
453        cmd.set_next_variable("value1");
454        let var1 = Variable::parse("var1");
455        assert_eq!(cmd.parts[1], CommandPart::VariableValue(var1, "value1".into()));
456        cmd.set_next_variable("value2");
457        let var2 = Variable::parse("var2");
458        assert_eq!(cmd.parts[3], CommandPart::VariableValue(var2, "value2".into()));
459    }
460
461    #[test]
462    fn test_has_pending_variable() {
463        let mut cmd = DynamicCommand::parse("cmd {{var1}} {{var2}}", false);
464        assert!(cmd.has_pending_variable());
465        cmd.set_next_variable("value1");
466        assert!(cmd.has_pending_variable());
467        cmd.set_next_variable("value2");
468        assert!(!cmd.has_pending_variable());
469    }
470
471    #[test]
472    fn test_current_variable() {
473        let mut cmd = DynamicCommand::parse("cmd {{var1}} {{var2}}", false);
474        assert_eq!(cmd.current_variable().map(|l| l.name.as_str()), Some("var1"));
475        cmd.set_next_variable("value1");
476        assert_eq!(cmd.current_variable().map(|l| l.name.as_str()), Some("var2"));
477        cmd.set_next_variable("value2");
478        assert_eq!(cmd.current_variable(), None);
479    }
480
481    #[test]
482    fn test_current_variable_context() {
483        let mut cmd = DynamicCommand::parse("cmd {{var1}} {{{secret_var}}} {{var2}}", false);
484
485        // Set value for the first variable
486        cmd.set_next_variable("value1");
487        let context_before_secret: Vec<_> = cmd.current_variable_context().into_iter().collect();
488        assert_eq!(context_before_secret, vec![("var1".to_string(), "value1".to_string())]);
489
490        // Set value for the secret variable
491        cmd.set_next_variable("secret_value");
492        let context_after_secret: Vec<_> = cmd.current_variable_context().into_iter().collect();
493        // The secret variable value should not be in the context
494        assert_eq!(context_after_secret, context_before_secret);
495    }
496
497    #[test]
498    fn test_current_variable_context_is_empty() {
499        let cmd = DynamicCommand::parse("cmd {{var1}}", false);
500        let context: Vec<_> = cmd.current_variable_context().into_iter().collect();
501        assert!(context.is_empty());
502    }
503
504    #[test]
505    fn test_parse_simple_variable() {
506        let variable = Variable::parse("my_variable");
507        assert_eq!(
508            variable,
509            Variable {
510                name: "my_variable".into(),
511                options: vec!["my_variable".into()],
512                functions: vec![],
513                secret: false,
514            }
515        );
516    }
517
518    #[test]
519    fn test_parse_secret_variable() {
520        let variable = Variable::parse("{my_secret}");
521        assert_eq!(
522            variable,
523            Variable {
524                name: "{my_secret}".into(),
525                options: vec!["my_secret".into()],
526                functions: vec![],
527                secret: true,
528            }
529        );
530    }
531
532    #[test]
533    fn test_parse_variable_with_multiple_options() {
534        let variable = Variable::parse("option1|option2|option3");
535        assert_eq!(
536            variable,
537            Variable {
538                name: "option1|option2|option3".into(),
539                options: vec!["option1".into(), "option2".into(), "option3".into()],
540                functions: vec![],
541                secret: false,
542            }
543        );
544    }
545
546    #[test]
547    fn test_parse_variable_with_single_function() {
548        let variable = Variable::parse("my_variable:kebab");
549        assert_eq!(
550            variable,
551            Variable {
552                name: "my_variable:kebab".into(),
553                options: vec!["my_variable".into()],
554                functions: vec![VariableFunction::KebabCase],
555                secret: false,
556            }
557        );
558    }
559
560    #[test]
561    fn test_parse_variable_with_multiple_functions() {
562        let variable = Variable::parse("my_variable:snake:upper");
563        assert_eq!(
564            variable,
565            Variable {
566                name: "my_variable:snake:upper".into(),
567                options: vec!["my_variable".into()],
568                functions: vec![VariableFunction::SnakeCase, VariableFunction::UpperCase],
569                secret: false,
570            }
571        );
572    }
573
574    #[test]
575    fn test_parse_variable_with_options_and_functions() {
576        let variable = Variable::parse("opt1|opt2:lower:kebab");
577        assert_eq!(
578            variable,
579            Variable {
580                name: "opt1|opt2:lower:kebab".into(),
581                options: vec!["opt1".into(), "opt2".into()],
582                functions: vec![VariableFunction::LowerCase, VariableFunction::KebabCase],
583                secret: false,
584            }
585        );
586    }
587
588    #[test]
589    fn test_parse_variable_with_colon_in_options() {
590        let variable = Variable::parse("key:value:kebab");
591        assert_eq!(
592            variable,
593            Variable {
594                name: "key:value:kebab".into(),
595                options: vec!["key:value".into()],
596                functions: vec![VariableFunction::KebabCase],
597                secret: false,
598            }
599        );
600    }
601
602    #[test]
603    fn test_parse_variable_with_only_functions() {
604        let variable = Variable::parse(":snake");
605        assert_eq!(
606            variable,
607            Variable {
608                name: ":snake".into(),
609                options: vec![],
610                functions: vec![VariableFunction::SnakeCase],
611                secret: false,
612            }
613        );
614    }
615
616    #[test]
617    fn test_parse_variable_that_is_a_function_name() {
618        let variable = Variable::parse("kebab");
619        assert_eq!(
620            variable,
621            Variable {
622                name: "kebab".into(),
623                options: vec!["kebab".into()],
624                functions: vec![],
625                secret: false,
626            }
627        );
628    }
629
630    #[test]
631    fn test_variable_env_var_names() {
632        // Simple variable
633        let var1 = Variable::parse("my-variable");
634        assert_eq!(var1.env_var_names(true), HashSet::from(["MY_VARIABLE".into()]));
635
636        // Variable with options
637        let var2 = Variable::parse("option1|option2");
638        assert_eq!(
639            var2.env_var_names(true),
640            HashSet::from(["OPTION1_OPTION2".into(), "OPTION1".into(), "OPTION2".into()])
641        );
642        assert_eq!(var2.env_var_names(false), HashSet::from(["OPTION1_OPTION2".into()]));
643
644        // Variable with functions
645        let var3 = Variable::parse("my-variable:kebab:upper");
646        assert_eq!(
647            var3.env_var_names(true),
648            HashSet::from(["MY_VARIABLE_KEBAB_UPPER".into(), "MY_VARIABLE".into()])
649        );
650
651        // Secret variable with asterisks
652        let var4 = Variable::parse("*my-secret*");
653        assert_eq!(var4.env_var_names(true), HashSet::from(["MY_SECRET".into()]));
654
655        // Secret variable with braces
656        let var5 = Variable::parse("{my-secret}");
657        assert_eq!(var5.env_var_names(true), HashSet::from(["MY_SECRET".into()]));
658    }
659
660    #[test]
661    fn test_variable_apply_functions_to() {
662        // No functions, should not change the input
663        let var_none = Variable::parse("text");
664        assert_eq!(var_none.apply_functions_to("Hello World"), "Hello World");
665
666        // Single function
667        let var_upper = Variable::parse("text:upper");
668        assert_eq!(var_upper.apply_functions_to("Hello World"), "HELLO WORLD");
669
670        // Chained functions: kebab then upper
671        let var_kebab_upper = Variable::parse("text:kebab:upper");
672        assert_eq!(var_kebab_upper.apply_functions_to("Hello World"), "HELLO-WORLD");
673
674        // Chained functions: snake then lower
675        let var_snake_lower = Variable::parse("text:snake:lower");
676        assert_eq!(var_snake_lower.apply_functions_to("Hello World"), "hello_world");
677    }
678
679    #[test]
680    fn test_variable_check_functions_char() {
681        // No functions, should always be None
682        let var_none = Variable::parse("text");
683        assert_eq!(var_none.check_functions_char('a'), None);
684        assert_eq!(var_none.check_functions_char(' '), None);
685
686        // Single function with a change
687        let var_upper = Variable::parse("text:upper");
688        assert_eq!(var_upper.check_functions_char('a'), Some("A".to_string()));
689
690        // Single function with no change
691        let var_lower = Variable::parse("text:lower");
692        assert_eq!(var_lower.check_functions_char('a'), None);
693
694        // Chained functions
695        let var_upper_kebab = Variable::parse("text:upper:kebab");
696        assert_eq!(var_upper_kebab.check_functions_char('a'), Some("A".to_string()));
697        assert_eq!(var_upper_kebab.check_functions_char(' '), Some("-".to_string()));
698        assert_eq!(var_upper_kebab.check_functions_char('-'), None);
699    }
700
701    #[test]
702    fn test_variable_function_apply_to() {
703        // KebabCase
704        assert_eq!(VariableFunction::KebabCase.apply_to("some text"), "some-text");
705        assert_eq!(VariableFunction::KebabCase.apply_to("Some Text"), "Some-Text");
706        assert_eq!(VariableFunction::KebabCase.apply_to("some_text"), "some-text");
707        assert_eq!(VariableFunction::KebabCase.apply_to("-"), "");
708        assert_eq!(VariableFunction::KebabCase.apply_to("_"), "");
709
710        // SnakeCase
711        assert_eq!(VariableFunction::SnakeCase.apply_to("some text"), "some_text");
712        assert_eq!(VariableFunction::SnakeCase.apply_to("Some Text"), "Some_Text");
713        assert_eq!(VariableFunction::SnakeCase.apply_to("some-text"), "some_text");
714        assert_eq!(VariableFunction::SnakeCase.apply_to("-"), "");
715        assert_eq!(VariableFunction::SnakeCase.apply_to("_"), "");
716
717        // UpperCase
718        assert_eq!(VariableFunction::UpperCase.apply_to("some text"), "SOME TEXT");
719        assert_eq!(VariableFunction::UpperCase.apply_to("SomeText"), "SOMETEXT");
720
721        // LowerCase
722        assert_eq!(VariableFunction::LowerCase.apply_to("SOME TEXT"), "some text");
723        assert_eq!(VariableFunction::LowerCase.apply_to("SomeText"), "sometext");
724
725        // Urlencode
726        assert_eq!(VariableFunction::Urlencode.apply_to("some text"), "some%20text");
727        assert_eq!(VariableFunction::Urlencode.apply_to("Some Text"), "Some%20Text");
728        assert_eq!(VariableFunction::Urlencode.apply_to("some-text"), "some%2Dtext");
729        assert_eq!(VariableFunction::Urlencode.apply_to("some_text"), "some%5Ftext");
730        assert_eq!(
731            VariableFunction::Urlencode.apply_to("!@#$%^&*()"),
732            "%21%40%23%24%25%5E%26%2A%28%29"
733        );
734        assert_eq!(VariableFunction::Urlencode.apply_to("some%20text"), "some%20text");
735    }
736
737    #[test]
738    fn test_variable_function_check_char() {
739        // KebabCase
740        assert_eq!(VariableFunction::KebabCase.check_char(' '), Some("-".to_string()));
741        assert_eq!(VariableFunction::KebabCase.check_char('_'), Some("-".to_string()));
742        assert_eq!(VariableFunction::KebabCase.check_char('-'), None);
743        assert_eq!(VariableFunction::KebabCase.check_char('A'), None);
744
745        // SnakeCase
746        assert_eq!(VariableFunction::SnakeCase.check_char(' '), Some("_".to_string()));
747        assert_eq!(VariableFunction::SnakeCase.check_char('-'), Some("_".to_string()));
748        assert_eq!(VariableFunction::SnakeCase.check_char('_'), None);
749        assert_eq!(VariableFunction::SnakeCase.check_char('A'), None);
750
751        // UpperCase
752        assert_eq!(VariableFunction::UpperCase.check_char('a'), Some("A".to_string()));
753        assert_eq!(VariableFunction::UpperCase.check_char('A'), None);
754        assert_eq!(VariableFunction::UpperCase.check_char(' '), None);
755
756        // LowerCase
757        assert_eq!(VariableFunction::LowerCase.check_char('A'), Some("a".to_string()));
758        assert_eq!(VariableFunction::LowerCase.check_char('a'), None);
759        assert_eq!(VariableFunction::LowerCase.check_char(' '), None);
760
761        // Urlencode
762        assert_eq!(VariableFunction::Urlencode.check_char(' '), Some("%20".to_string()));
763        assert_eq!(VariableFunction::Urlencode.check_char('!'), Some("%21".to_string()));
764        assert_eq!(VariableFunction::Urlencode.check_char('A'), None);
765        assert_eq!(VariableFunction::Urlencode.check_char('1'), None);
766        assert_eq!(VariableFunction::Urlencode.check_char('-'), Some("%2D".to_string()));
767        assert_eq!(VariableFunction::Urlencode.check_char('_'), Some("%5F".to_string()));
768    }
769
770    #[test]
771    fn test_is_secret_variable() {
772        // Test with asterisks
773        assert_eq!(is_secret_variable("*secret*"), Some("secret"));
774        assert_eq!(is_secret_variable("* another secret *"), Some(" another secret "));
775        assert_eq!(is_secret_variable("**"), Some(""));
776
777        // Test with braces
778        assert_eq!(is_secret_variable("{secret}"), Some("secret"));
779        assert_eq!(is_secret_variable("{ another secret }"), Some(" another secret "));
780        assert_eq!(is_secret_variable("{}"), Some(""));
781
782        // Test non-secret variables
783        assert_eq!(is_secret_variable("not-secret"), None);
784        assert_eq!(is_secret_variable("*not-secret"), None);
785        assert_eq!(is_secret_variable("not-secret*"), None);
786        assert_eq!(is_secret_variable("{not-secret"), None);
787        assert_eq!(is_secret_variable("not-secret}"), None);
788        assert_eq!(is_secret_variable(""), None);
789        assert_eq!(is_secret_variable("*"), None);
790        assert_eq!(is_secret_variable("{"), None);
791        assert_eq!(is_secret_variable("}*"), None);
792        assert_eq!(is_secret_variable("*{"), None);
793    }
794}