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
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")]
43pub enum SearchMode {
45 #[default]
47 Auto,
48 Fuzzy,
50 Regex,
52 Exact,
54 Relaxed,
59}
60
61#[derive(Default, Clone)]
63#[cfg_attr(test, derive(Debug))]
64pub struct SearchCommandsFilter {
65 pub category: Option<Vec<String>>,
67 pub source: Option<String>,
69 pub tags: Option<Vec<String>>,
71 pub search_mode: SearchMode,
73 pub search_term: Option<String>,
78}
79impl SearchCommandsFilter {
80 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 pub id: Uuid,
128 pub category: String,
130 pub source: String,
132 pub alias: Option<String>,
134 pub cmd: String,
136 pub flat_cmd: String,
138 pub description: Option<String>,
140 pub flat_description: Option<String>,
142 pub tags: Option<Vec<String>>,
144 pub created_at: DateTime<Utc>,
146 pub updated_at: Option<DateTime<Utc>>,
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 }
167 }
168
169 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 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 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 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 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 pub fn is_destructive(&self) -> bool {
205 Self::is_destructive_command(&self.cmd)
206 }
207
208 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 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 (None, None) => return writeln!(f, "#\n{cmd}"),
224 (Some(d), Some(a)) => {
226 if d.contains('\n') {
227 writeln!(f, "# [alias:{a}]")?;
229 for line in d.lines() {
230 writeln!(f, "# {line}")?;
231 }
232 } else {
233 writeln!(f, "# [alias:{a}] {d}")?;
235 }
236 }
237 (Some(d), None) => {
239 for line in d.lines() {
240 writeln!(f, "# {line}")?;
241 }
242 }
243 (None, Some(a)) => {
245 writeln!(f, "# [alias:{a}]")?;
246 }
247 };
248
249 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}