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