Skip to main content

intelli_shell/model/
command.rs

1use std::{
2    collections::HashSet,
3    fmt::{self, Display},
4};
5
6use chrono::{DateTime, Utc};
7use clap::ValueEnum;
8use enum_cycling::EnumCycle;
9use regex::Regex;
10use serde::Deserialize;
11use uuid::Uuid;
12
13use crate::utils::{extract_tags_from_description, flatten_str, remove_newlines};
14
15/// Category for user defined commands
16pub const CATEGORY_USER: &str = "user";
17
18/// Category for workspace defined commands
19pub const CATEGORY_WORKSPACE: &str = "workspace";
20
21/// Source for user defined commands
22pub const SOURCE_USER: &str = "user";
23
24/// Source for ai suggested commands
25pub const SOURCE_AI: &str = "ai";
26
27/// Source for tldr fetched commands
28pub const SOURCE_TLDR: &str = "tldr";
29
30/// Source for imported commands
31pub const SOURCE_IMPORT: &str = "import";
32
33/// Source for workspace-level commands
34pub const SOURCE_WORKSPACE: &str = "workspace";
35
36const DESTRUCTIVE_COMMANDS: &[&str] = &["rm", "rmdir", "del", "erase", "rd", "remove-item"];
37const PRIVILEGE_WRAPPERS: &[&str] = &["sudo", "doas"];
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, ValueEnum, EnumCycle, strum::Display)]
40#[cfg_attr(test, derive(strum::EnumIter))]
41#[serde(rename_all = "snake_case")]
42#[strum(serialize_all = "snake_case")]
43/// Determines the strategy used for searching commands
44pub enum SearchMode {
45    /// An internal algorithm will be used to understand human search patterns and decide the best search strategy
46    #[default]
47    Auto,
48    /// Employs a set of predefined rules to perform a fuzzy search
49    Fuzzy,
50    /// Treats the input query as a regular expression, allowing for complex pattern matching
51    Regex,
52    /// Return commands that precisely match the entire input query only
53    Exact,
54    /// Attempts to find the maximum number of potentially relevant commands.
55    ///
56    /// It uses a broader set of matching criteria and may include partial matches, matches within descriptions, or
57    /// commands that share keywords.
58    Relaxed,
59}
60
61/// Represents the filtering criteria for searching for commands
62#[derive(Default, Clone)]
63#[cfg_attr(test, derive(Debug))]
64pub struct SearchCommandsFilter {
65    /// Filter commands by a specific category (`user`, `workspace` or tldr's category)
66    pub category: Option<Vec<String>>,
67    /// Filter commands by their original source (`user`, `ai`, `tldr`, `import`, `workspace`)
68    pub source: Option<String>,
69    /// Filter commands by a list of tags, only commands matching all of the provided tags will be included
70    pub tags: Option<Vec<String>>,
71    /// Specifies the search strategy to be used for matching the `search_term`
72    pub search_mode: SearchMode,
73    /// The actual term or query string to search for.
74    ///
75    /// This term will be matched against command names, aliases, or descriptions according to the specified
76    /// `search_mode`.
77    pub search_term: Option<String>,
78}
79impl SearchCommandsFilter {
80    /// Returns a cleaned version of self, trimming and removing empty or duplicated filters
81    pub fn cleaned(self) -> Self {
82        let SearchCommandsFilter {
83            category,
84            source,
85            tags,
86            search_mode,
87            search_term,
88        } = self;
89        Self {
90            category: category
91                .map(|v| {
92                    let mut final_vec: Vec<String> = Vec::with_capacity(v.len());
93                    let mut seen: HashSet<&str> = HashSet::with_capacity(v.len());
94                    for t in &v {
95                        let t = t.trim();
96                        if !t.is_empty() && seen.insert(t) {
97                            final_vec.push(t.to_string());
98                        }
99                    }
100                    final_vec
101                })
102                .filter(|t| !t.is_empty()),
103            source: source.map(|t| t.trim().to_string()).filter(|s| !s.is_empty()),
104            tags: tags
105                .map(|v| {
106                    let mut final_vec: Vec<String> = Vec::with_capacity(v.len());
107                    let mut seen: HashSet<&str> = HashSet::with_capacity(v.len());
108                    for t in &v {
109                        let t = t.trim();
110                        if !t.is_empty() && seen.insert(t) {
111                            final_vec.push(t.to_string());
112                        }
113                    }
114                    final_vec
115                })
116                .filter(|t| !t.is_empty()),
117            search_mode,
118            search_term: search_term.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
119        }
120    }
121}
122
123#[derive(Clone)]
124#[cfg_attr(test, derive(Default, Debug))]
125pub struct Command {
126    /// Unique identifier for the command
127    pub id: Uuid,
128    /// Category of the command (`user`, `workspace` or tldr's category)
129    pub category: String,
130    /// Category of the command (`user`, `ai`, `tldr`, `import`, `workspace`)
131    pub source: String,
132    /// Optional alias for easier recall
133    pub alias: Option<String>,
134    /// The actual command string, potentially with `{{placeholders}}`
135    pub cmd: String,
136    /// Flattened version of `cmd`
137    pub flat_cmd: String,
138    /// Optional user-provided description
139    pub description: Option<String>,
140    /// Flattened version of `description`
141    pub flat_description: Option<String>,
142    /// Tags associated with the command (including the hashtag `#`)
143    pub tags: Option<Vec<String>>,
144    /// The date and time when the command was created
145    pub created_at: DateTime<Utc>,
146    /// The date and time when the command was last updated
147    pub updated_at: Option<DateTime<Utc>>,
148}
149
150impl Command {
151    /// Creates a new command, with zero usage
152    pub fn new(category: impl Into<String>, source: impl Into<String>, cmd: impl Into<String>) -> Self {
153        let cmd = remove_newlines(cmd.into());
154        Self {
155            id: Uuid::now_v7(),
156            category: category.into(),
157            source: source.into(),
158            alias: None,
159            flat_cmd: flatten_str(&cmd),
160            cmd,
161            description: None,
162            flat_description: None,
163            tags: None,
164            created_at: Utc::now(),
165            updated_at: None,
166        }
167    }
168
169    /// Updates the alias of the command
170    pub fn with_alias(mut self, alias: Option<String>) -> Self {
171        self.alias = alias.filter(|a| !a.trim().is_empty());
172        self
173    }
174
175    /// Updates the cmd of the command
176    pub fn with_cmd(mut self, cmd: String) -> Self {
177        self.flat_cmd = flatten_str(&cmd);
178        self.cmd = cmd;
179        self
180    }
181
182    /// Updates the description (and tags) of the command
183    pub fn with_description(mut self, description: Option<String>) -> Self {
184        let description = description.filter(|d| !d.trim().is_empty());
185        self.tags = extract_tags_from_description(description.as_deref());
186        self.flat_description = description.as_ref().map(flatten_str);
187        self.description = description;
188        self
189    }
190
191    #[cfg(test)]
192    /// Updates the tags of the command
193    pub fn with_tags(mut self, tags: Option<Vec<String>>) -> Self {
194        self.tags = tags.filter(|t| !t.is_empty());
195        self
196    }
197
198    /// Checks whether a command matches a regex filter
199    pub fn matches(&self, regex: &Regex) -> bool {
200        regex.is_match(&self.cmd) || self.description.as_ref().is_some_and(|d| regex.is_match(d))
201    }
202
203    /// Checks whether the command string contains a destructive shell action.
204    pub fn is_destructive(&self) -> bool {
205        Self::is_destructive_command(&self.cmd)
206    }
207
208    /// Checks whether a command string contains a destructive shell action.
209    pub fn is_destructive_command(command: &str) -> bool {
210        split_shell_segments(command).into_iter().any(is_destructive_segment)
211    }
212}
213
214impl Display for Command {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        // Get the description and alias, treating empty strings or None as absent
217        let cmd = &self.cmd;
218        let desc = self.description.as_deref().filter(|s| !s.is_empty());
219        let alias = self.alias.as_deref();
220
221        match (desc, alias) {
222            // If there's no description or alias, output an empty comment and the command
223            (None, None) => return writeln!(f, "#\n{cmd}"),
224            // Both description and alias exist
225            (Some(d), Some(a)) => {
226                if d.contains('\n') {
227                    // For multi-line descriptions, place the alias on its own line for clarity
228                    writeln!(f, "# [alias:{a}]")?;
229                    for line in d.lines() {
230                        writeln!(f, "# {line}")?;
231                    }
232                } else {
233                    // For single-line descriptions, combine them on one line
234                    writeln!(f, "# [alias:{a}] {d}")?;
235                }
236            }
237            // Only a description exists
238            (Some(d), None) => {
239                for line in d.lines() {
240                    writeln!(f, "# {line}")?;
241                }
242            }
243            // Only an alias exists
244            (None, Some(a)) => {
245                writeln!(f, "# [alias:{a}]")?;
246            }
247        };
248
249        // Finally, write the command itself
250        writeln!(f, "{cmd}")
251    }
252}
253
254fn is_destructive_segment(segment: &str) -> bool {
255    let mut words = ShellWordIter::new(segment);
256
257    for word in words.by_ref() {
258        if is_env_assignment(word) || is_privilege_wrapper(word) {
259            continue;
260        }
261
262        return is_destructive_verb(word) || is_destructive_subcommand(word, &mut words);
263    }
264
265    false
266}
267
268fn is_destructive_verb(word: &str) -> bool {
269    DESTRUCTIVE_COMMANDS.iter().any(|verb| word.eq_ignore_ascii_case(verb))
270}
271
272fn is_privilege_wrapper(word: &str) -> bool {
273    PRIVILEGE_WRAPPERS.iter().any(|wrapper| word.eq_ignore_ascii_case(wrapper))
274}
275
276fn is_destructive_subcommand(command: &str, remaining_words: &mut ShellWordIter<'_>) -> bool {
277    if !command.eq_ignore_ascii_case("git") {
278        return false;
279    }
280
281    remaining_words
282        .next()
283        .is_some_and(is_destructive_verb)
284}
285
286fn is_env_assignment(word: &str) -> bool {
287    let Some((name, _)) = word.split_once('=') else {
288        return false;
289    };
290
291    let mut chars = name.chars();
292    let Some(first) = chars.next() else {
293        return false;
294    };
295
296    (first.is_ascii_alphabetic() || first == '_')
297        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
298}
299
300fn split_shell_segments(command: &str) -> Vec<&str> {
301    let bytes = command.as_bytes();
302    let mut segments = Vec::new();
303    let mut start = 0;
304    let mut index = 0;
305    let mut quote: Option<u8> = None;
306    let mut escaped = false;
307
308    while index < bytes.len() {
309        let byte = bytes[index];
310
311        if escaped {
312            escaped = false;
313            index += 1;
314            continue;
315        }
316
317        if let Some(active_quote) = quote {
318            if byte == b'\\' && active_quote == b'"' {
319                escaped = true;
320            } else if byte == active_quote {
321                quote = None;
322            }
323            index += 1;
324            continue;
325        }
326
327        match byte {
328            b'\\' => {
329                escaped = true;
330                index += 1;
331            }
332            b'\'' | b'"' => {
333                quote = Some(byte);
334                index += 1;
335            }
336            b';' | b'\n' => {
337                segments.push(&command[start..index]);
338                start = index + 1;
339                index += 1;
340            }
341            b'&' if bytes.get(index + 1) == Some(&b'&') => {
342                segments.push(&command[start..index]);
343                start = index + 2;
344                index += 2;
345            }
346            b'|' if bytes.get(index + 1) == Some(&b'|') => {
347                segments.push(&command[start..index]);
348                start = index + 2;
349                index += 2;
350            }
351            b'|' => {
352                segments.push(&command[start..index]);
353                start = index + 1;
354                index += 1;
355            }
356            _ => index += 1,
357        }
358    }
359
360    segments.push(&command[start..]);
361    segments
362}
363
364struct ShellWordIter<'a> {
365    segment: &'a str,
366    cursor: usize,
367}
368
369impl<'a> ShellWordIter<'a> {
370    fn new(segment: &'a str) -> Self {
371        Self { segment, cursor: 0 }
372    }
373}
374
375impl<'a> Iterator for ShellWordIter<'a> {
376    type Item = &'a str;
377
378    fn next(&mut self) -> Option<Self::Item> {
379        let bytes = self.segment.as_bytes();
380
381        while let Some(byte) = bytes.get(self.cursor) {
382            if byte.is_ascii_whitespace() {
383                self.cursor += 1;
384            } else {
385                break;
386            }
387        }
388
389        if self.cursor >= bytes.len() {
390            return None;
391        }
392
393        let start = self.cursor;
394        let mut index = self.cursor;
395        let mut quote: Option<u8> = None;
396        let mut escaped = false;
397
398        while index < bytes.len() {
399            let byte = bytes[index];
400
401            if escaped {
402                escaped = false;
403                index += 1;
404                continue;
405            }
406
407            if let Some(active_quote) = quote {
408                if byte == b'\\' && active_quote == b'"' {
409                    escaped = true;
410                } else if byte == active_quote {
411                    quote = None;
412                }
413                index += 1;
414                continue;
415            }
416
417            match byte {
418                b'\\' => {
419                    escaped = true;
420                    index += 1;
421                }
422                b'\'' | b'"' => {
423                    quote = Some(byte);
424                    index += 1;
425                }
426                _ if byte.is_ascii_whitespace() => break,
427                _ => index += 1,
428            }
429        }
430
431        self.cursor = index;
432        Some(&self.segment[start..index])
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::{CATEGORY_USER, Command, SOURCE_USER};
439
440    #[test]
441    fn test_is_destructive_command_positive_cases() {
442        for command in [
443            "rm file",
444            "sudo rm -rf /tmp/x",
445            "VAR=1 rm file",
446            "echo ok && rm file",
447            "git rm file",
448            "Remove-Item foo",
449            "del foo",
450        ] {
451            assert!(Command::is_destructive_command(command), "expected destructive: {command}");
452        }
453    }
454
455    #[test]
456    fn test_is_destructive_command_negative_cases() {
457        for command in [
458            "docker run --rm image",
459            "echo rm file",
460            "printf 'rm file'",
461            "git status",
462            "rmdir_backup",
463            "trash-put foo",
464        ] {
465            assert!(
466                !Command::is_destructive_command(command),
467                "expected non-destructive: {command}"
468            );
469        }
470    }
471
472    #[test]
473    fn test_command_is_destructive_uses_command_text() {
474        let command = Command::new(CATEGORY_USER, SOURCE_USER, "doas erase temp.txt");
475        assert!(command.is_destructive());
476    }
477}