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(debug_assertions, 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(debug_assertions, derive(Debug))]
122#[cfg_attr(test, derive(Default))]
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}
147
148impl Command {
149    /// Creates a new command, with zero usage
150    pub fn new(category: impl Into<String>, source: impl Into<String>, cmd: impl Into<String>) -> Self {
151        let cmd = remove_newlines(cmd.into());
152        Self {
153            id: Uuid::now_v7(),
154            category: category.into(),
155            source: source.into(),
156            alias: None,
157            flat_cmd: flatten_str(&cmd),
158            cmd,
159            description: None,
160            flat_description: None,
161            tags: None,
162            created_at: Utc::now(),
163            updated_at: None,
164        }
165    }
166
167    /// Updates the alias of the command
168    pub fn with_alias(mut self, alias: Option<String>) -> Self {
169        self.alias = alias.filter(|a| !a.trim().is_empty());
170        self
171    }
172
173    /// Updates the cmd of the command
174    pub fn with_cmd(mut self, cmd: String) -> Self {
175        self.flat_cmd = flatten_str(&cmd);
176        self.cmd = cmd;
177        self
178    }
179
180    /// Updates the description (and tags) of the command
181    pub fn with_description(mut self, description: Option<String>) -> Self {
182        let description = description.filter(|d| !d.trim().is_empty());
183        self.tags = extract_tags_from_description(description.as_deref());
184        self.flat_description = description.as_ref().map(flatten_str);
185        self.description = description;
186        self
187    }
188
189    #[cfg(test)]
190    /// Updates the tags of the command
191    pub fn with_tags(mut self, tags: Option<Vec<String>>) -> Self {
192        self.tags = tags.filter(|t| !t.is_empty());
193        self
194    }
195
196    /// Checks whether a command matches a regex filter
197    pub fn matches(&self, regex: &Regex) -> bool {
198        regex.is_match(&self.cmd) || self.description.as_ref().is_some_and(|d| regex.is_match(d))
199    }
200}
201
202impl Display for Command {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        // Get the description and alias, treating empty strings or None as absent
205        let cmd = &self.cmd;
206        let desc = self.description.as_deref().filter(|s| !s.is_empty());
207        let alias = self.alias.as_deref();
208
209        match (desc, alias) {
210            // If there's no description or alias, output an empty comment and the command
211            (None, None) => return writeln!(f, "#\n{cmd}"),
212            // Both description and alias exist
213            (Some(d), Some(a)) => {
214                if d.contains('\n') {
215                    // For multi-line descriptions, place the alias on its own line for clarity
216                    writeln!(f, "# [alias:{a}]")?;
217                    for line in d.lines() {
218                        writeln!(f, "# {line}")?;
219                    }
220                } else {
221                    // For single-line descriptions, combine them on one line
222                    writeln!(f, "# [alias:{a}] {d}")?;
223                }
224            }
225            // Only a description exists
226            (Some(d), None) => {
227                for line in d.lines() {
228                    writeln!(f, "# {line}")?;
229                }
230            }
231            // Only an alias exists
232            (None, Some(a)) => {
233                writeln!(f, "# [alias:{a}]")?;
234            }
235        };
236
237        // Finally, write the command itself
238        writeln!(f, "{cmd}")
239    }
240}