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