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
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, ValueEnum, EnumCycle, strum::Display)]
38#[cfg_attr(test, derive(strum::EnumIter))]
39#[serde(rename_all = "snake_case")]
40#[strum(serialize_all = "snake_case")]
41/// Determines the strategy used for searching commands
42pub enum SearchMode {
43    /// An internal algorithm will be used to understand human search patterns and decide the best search strategy
44    #[default]
45    Auto,
46    /// Employs a set of predefined rules to perform a fuzzy search
47    Fuzzy,
48    /// Treats the input query as a regular expression, allowing for complex pattern matching
49    Regex,
50    /// Return commands that precisely match the entire input query only
51    Exact,
52    /// Attempts to find the maximum number of potentially relevant commands.
53    ///
54    /// It uses a broader set of matching criteria and may include partial matches, matches within descriptions, or
55    /// commands that share keywords.
56    Relaxed,
57}
58
59/// Represents the filtering criteria for searching for commands
60#[derive(Default, Clone)]
61#[cfg_attr(test, derive(Debug))]
62pub struct SearchCommandsFilter {
63    /// Filter commands by a specific category (`user`, `workspace` or tldr's category)
64    pub category: Option<Vec<String>>,
65    /// Filter commands by their original source (`user`, `ai`, `tldr`, `import`, `workspace`)
66    pub source: Option<String>,
67    /// Filter commands by a list of tags, only commands matching all of the provided tags will be included
68    pub tags: Option<Vec<String>>,
69    /// Specifies the search strategy to be used for matching the `search_term`
70    pub search_mode: SearchMode,
71    /// The actual term or query string to search for.
72    ///
73    /// This term will be matched against command names, aliases, or descriptions according to the specified
74    /// `search_mode`.
75    pub search_term: Option<String>,
76}
77impl SearchCommandsFilter {
78    /// Returns a cleaned version of self, trimming and removing empty or duplicated filters
79    pub fn cleaned(self) -> Self {
80        let SearchCommandsFilter {
81            category,
82            source,
83            tags,
84            search_mode,
85            search_term,
86        } = self;
87        Self {
88            category: category
89                .map(|v| {
90                    let mut final_vec: Vec<String> = Vec::with_capacity(v.len());
91                    let mut seen: HashSet<&str> = HashSet::with_capacity(v.len());
92                    for t in &v {
93                        let t = t.trim();
94                        if !t.is_empty() && seen.insert(t) {
95                            final_vec.push(t.to_string());
96                        }
97                    }
98                    final_vec
99                })
100                .filter(|t| !t.is_empty()),
101            source: source.map(|t| t.trim().to_string()).filter(|s| !s.is_empty()),
102            tags: tags
103                .map(|v| {
104                    let mut final_vec: Vec<String> = Vec::with_capacity(v.len());
105                    let mut seen: HashSet<&str> = HashSet::with_capacity(v.len());
106                    for t in &v {
107                        let t = t.trim();
108                        if !t.is_empty() && seen.insert(t) {
109                            final_vec.push(t.to_string());
110                        }
111                    }
112                    final_vec
113                })
114                .filter(|t| !t.is_empty()),
115            search_mode,
116            search_term: search_term.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
117        }
118    }
119}
120
121#[derive(Clone)]
122#[cfg_attr(test, derive(Default, Debug))]
123pub struct Command {
124    /// Unique identifier for the command
125    pub id: Uuid,
126    /// Category of the command (`user`, `workspace` or tldr's category)
127    pub category: String,
128    /// Category of the command (`user`, `ai`, `tldr`, `import`, `workspace`)
129    pub source: String,
130    /// Optional alias for easier recall
131    pub alias: Option<String>,
132    /// The actual command string, potentially with `{{placeholders}}`
133    pub cmd: String,
134    /// Flattened version of `cmd`
135    pub flat_cmd: String,
136    /// Optional user-provided description
137    pub description: Option<String>,
138    /// Flattened version of `description`
139    pub flat_description: Option<String>,
140    /// Tags associated with the command (including the hashtag `#`)
141    pub tags: Option<Vec<String>>,
142    /// The date and time when the command was created
143    pub created_at: DateTime<Utc>,
144    /// The date and time when the command was last updated
145    pub updated_at: Option<DateTime<Utc>>,
146    /// Whether the command is destructive (computed at runtime, not persisted)
147    pub is_destructive: bool,
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            is_destructive: false,
167        }
168    }
169
170    /// Updates the alias of the command
171    pub fn with_alias(mut self, alias: Option<String>) -> Self {
172        self.alias = alias.filter(|a| !a.trim().is_empty());
173        self
174    }
175
176    /// Updates the cmd of the command
177    pub fn with_cmd(mut self, cmd: String) -> Self {
178        self.flat_cmd = flatten_str(&cmd);
179        self.cmd = cmd;
180        self
181    }
182
183    /// Updates the description (and tags) of the command
184    pub fn with_description(mut self, description: Option<String>) -> Self {
185        let description = description.filter(|d| !d.trim().is_empty());
186        self.tags = extract_tags_from_description(description.as_deref());
187        self.flat_description = description.as_ref().map(flatten_str);
188        self.description = description;
189        self
190    }
191
192    #[cfg(test)]
193    /// Updates the tags of the command
194    pub fn with_tags(mut self, tags: Option<Vec<String>>) -> Self {
195        self.tags = tags.filter(|t| !t.is_empty());
196        self
197    }
198
199    /// Computes and updates the `is_destructive` field using the provided destructive regex patterns
200    pub fn update_is_destructive(&mut self, patterns: &[crate::config::RegexWrapper]) {
201        let tags = self.tags.as_deref().unwrap_or(&[]);
202        self.is_destructive = crate::utils::is_destructive(&self.cmd, tags, patterns);
203    }
204
205    /// Checks whether a command matches a regex filter
206    pub fn matches(&self, regex: &Regex) -> bool {
207        regex.is_match(&self.cmd) || self.description.as_ref().is_some_and(|d| regex.is_match(d))
208    }
209}
210
211impl Display for Command {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        // Get the description and alias, treating empty strings or None as absent
214        let cmd = &self.cmd;
215        let desc = self.description.as_deref().filter(|s| !s.is_empty());
216        let alias = self.alias.as_deref();
217
218        match (desc, alias) {
219            // If there's no description or alias, output an empty comment and the command
220            (None, None) => return writeln!(f, "#\n{cmd}"),
221            // Both description and alias exist
222            (Some(d), Some(a)) => {
223                if d.contains('\n') {
224                    // For multi-line descriptions, place the alias on its own line for clarity
225                    writeln!(f, "# [alias:{a}]")?;
226                    for line in d.lines() {
227                        writeln!(f, "# {line}")?;
228                    }
229                } else {
230                    // For single-line descriptions, combine them on one line
231                    writeln!(f, "# [alias:{a}] {d}")?;
232                }
233            }
234            // Only a description exists
235            (Some(d), None) => {
236                for line in d.lines() {
237                    writeln!(f, "# {line}")?;
238                }
239            }
240            // Only an alias exists
241            (None, Some(a)) => {
242                writeln!(f, "# [alias:{a}]")?;
243            }
244        };
245
246        // Finally, write the command itself
247        writeln!(f, "{cmd}")
248    }
249}