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
15pub const CATEGORY_USER: &str = "user";
17
18pub const CATEGORY_WORKSPACE: &str = "workspace";
20
21pub const SOURCE_USER: &str = "user";
23
24pub const SOURCE_AI: &str = "ai";
26
27pub const SOURCE_TLDR: &str = "tldr";
29
30pub const SOURCE_IMPORT: &str = "import";
32
33pub 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")]
41pub enum SearchMode {
43 #[default]
45 Auto,
46 Fuzzy,
48 Regex,
50 Exact,
52 Relaxed,
57}
58
59#[derive(Default, Clone)]
61#[cfg_attr(test, derive(Debug))]
62pub struct SearchCommandsFilter {
63 pub category: Option<Vec<String>>,
65 pub source: Option<String>,
67 pub tags: Option<Vec<String>>,
69 pub search_mode: SearchMode,
71 pub search_term: Option<String>,
76}
77impl SearchCommandsFilter {
78 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 pub id: Uuid,
126 pub category: String,
128 pub source: String,
130 pub alias: Option<String>,
132 pub cmd: String,
134 pub flat_cmd: String,
136 pub description: Option<String>,
138 pub flat_description: Option<String>,
140 pub tags: Option<Vec<String>>,
142 pub created_at: DateTime<Utc>,
144 pub updated_at: Option<DateTime<Utc>>,
146 pub is_destructive: bool,
148}
149
150impl Command {
151 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 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 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 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 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 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 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 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 (None, None) => return writeln!(f, "#\n{cmd}"),
221 (Some(d), Some(a)) => {
223 if d.contains('\n') {
224 writeln!(f, "# [alias:{a}]")?;
226 for line in d.lines() {
227 writeln!(f, "# {line}")?;
228 }
229 } else {
230 writeln!(f, "# [alias:{a}] {d}")?;
232 }
233 }
234 (Some(d), None) => {
236 for line in d.lines() {
237 writeln!(f, "# {line}")?;
238 }
239 }
240 (None, Some(a)) => {
242 writeln!(f, "# [alias:{a}]")?;
243 }
244 };
245
246 writeln!(f, "{cmd}")
248 }
249}