1use std::{cmp::Ordering, sync::atomic::Ordering as AtomicOrdering};
2
3use color_eyre::{Report, eyre::eyre};
4use rusqlite::{Row, fallible_iterator::FallibleIterator, ffi, types::Type};
5use sea_query::SqliteQueryBuilder;
6use tracing::instrument;
7use uuid::Uuid;
8
9use super::{RusqliteBinder, SqliteStorage, queries::*};
10use crate::{
11 config::SearchCommandTuning,
12 errors::{Result, UserFacingError},
13 model::{Command, SOURCE_TLDR, SearchCommandsFilter},
14};
15
16impl SqliteStorage {
17 #[instrument(skip_all)]
20 pub async fn setup_workspace_storage(&self) -> Result<()> {
21 tracing::trace!("Creating workspace-specific tables");
22 self.client
23 .conn_mut(|conn| {
24 let schemas: Vec<String> = conn
26 .prepare(
27 r"SELECT sql
28 FROM sqlite_master
29 WHERE (type = 'table' AND name = 'variable_completion')
30 OR (type = 'table' AND name = 'command')
31 OR (type = 'table' AND name LIKE 'command_%fts')
32 OR (type = 'trigger' AND name LIKE 'command_%_fts' AND tbl_name = 'command')",
33 )?
34 .query_map([], |row| row.get(0))?
35 .collect::<Result<Vec<String>, _>>()?;
36
37 let tx = conn.transaction()?;
38
39 for schema in schemas {
41 let temp_schema = schema
42 .replace("variable_completion", "workspace_variable_completion")
43 .replace("command", "workspace_command")
44 .replace("CREATE TABLE ", "CREATE TEMP TABLE ")
45 .replace("CREATE VIRTUAL TABLE ", "CREATE VIRTUAL TABLE temp.")
46 .replace("CREATE TRIGGER ", "CREATE TEMP TRIGGER ");
47 tracing::trace!("Executing:\n{temp_schema}");
48 tx.execute(&temp_schema, [])?;
49 }
50
51 tx.commit()?;
52 Ok(())
53 })
54 .await?;
55
56 self.workspace_tables_loaded.store(true, AtomicOrdering::SeqCst);
57
58 Ok(())
59 }
60
61 #[instrument(skip_all)]
63 pub async fn is_empty(&self) -> Result<bool> {
64 let workspace_tables_loaded = self.workspace_tables_loaded.load(AtomicOrdering::SeqCst);
65 self.client
66 .conn(move |conn| {
67 let query = if workspace_tables_loaded {
68 "SELECT NOT EXISTS (SELECT 1 FROM command UNION ALL SELECT 1 FROM workspace_command)"
69 } else {
70 "SELECT NOT EXISTS(SELECT 1 FROM command)"
71 };
72 tracing::trace!("Checking if storage is empty:\n{query}");
73 Ok(conn.query_row(query, [], |r| r.get(0))?)
74 })
75 .await
76 }
77
78 #[instrument(skip_all)]
80 pub async fn find_tags(
81 &self,
82 filter: SearchCommandsFilter,
83 tag_prefix: Option<String>,
84 tuning: &SearchCommandTuning,
85 ) -> Result<Vec<(String, u64, bool)>> {
86 let workspace_tables_loaded = self.workspace_tables_loaded.load(AtomicOrdering::SeqCst);
87 let query = query_find_tags(filter, tag_prefix, tuning, workspace_tables_loaded)?;
88 if tracing::enabled!(tracing::Level::TRACE) {
89 tracing::trace!("Querying tags:\n{}", query.to_string(SqliteQueryBuilder));
90 }
91 let (stmt, values) = query.build_rusqlite(SqliteQueryBuilder);
92 self.client
93 .conn(move |conn| {
94 conn.prepare(&stmt)?
95 .query(&*values.as_params())?
96 .and_then(|r| Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?)))
97 .collect()
98 })
99 .await
100 }
101
102 #[instrument(skip_all)]
107 pub async fn find_commands(
108 &self,
109 filter: SearchCommandsFilter,
110 working_path: impl Into<String>,
111 tuning: &SearchCommandTuning,
112 ) -> Result<(Vec<Command>, bool)> {
113 let workspace_tables_loaded = self.workspace_tables_loaded.load(AtomicOrdering::SeqCst);
114 let cleaned_filter = filter.cleaned();
115
116 let mut query_alias = None;
118 if let Some(ref term) = cleaned_filter.search_term
119 && cleaned_filter.category.is_none()
120 && cleaned_filter.source.is_none()
121 && cleaned_filter.tags.is_none()
122 {
123 let (query, params) = if workspace_tables_loaded {
125 (
126 format!(
127 r#"WITH commands_unified AS (
128 SELECT rowid, *, 0 AS is_workspace FROM command
129 UNION ALL
130 SELECT rowid, *, 1 AS is_workspace FROM workspace_command
131 ),
132 commands_ranked AS (
133 SELECT *, ROW_NUMBER() OVER (PARTITION BY alias ORDER BY is_workspace ASC) as _rank
134 FROM commands_unified
135 )
136 SELECT *
137 FROM commands_ranked
138 WHERE alias IS NOT NULL AND alias = ?1 AND _rank = 1
139 LIMIT {QUERY_LIMIT}"#
140 ),
141 (term.clone(),),
142 )
143 } else {
144 (
145 format!(
146 r#"SELECT c.rowid, c.*
147 FROM command c
148 WHERE c.alias IS NOT NULL AND c.alias = ?1
149 LIMIT {QUERY_LIMIT}"#
150 ),
151 (term.clone(),),
152 )
153 };
154 query_alias = Some((query, params));
155 }
156
157 let query = query_find_commands(cleaned_filter, working_path, tuning, workspace_tables_loaded)?;
159 let query_trace = if tracing::enabled!(tracing::Level::TRACE) {
160 query.to_string(SqliteQueryBuilder)
161 } else {
162 String::default()
163 };
164 let (stmt, values) = query.build_rusqlite(SqliteQueryBuilder);
165
166 let tuning = *tuning;
168 self.client
169 .conn(move |conn| {
170 if let Some((query_alias, a_params)) = query_alias {
172 tracing::trace!("Querying aliased commands:\n{query_alias}");
173 let rows = conn
175 .prepare(&query_alias)?
176 .query(a_params)?
177 .map(|r| Command::try_from(r))
178 .collect::<Vec<_>>()?;
179 if !rows.is_empty() {
181 return Ok((rows, true));
182 }
183 }
184 if tracing::enabled!(tracing::Level::TRACE) {
186 tracing::trace!("Querying commands:\n{query_trace}");
187 }
188 Ok((
189 rerank_query_results(
190 conn.prepare(&stmt)?
191 .query(&*values.as_params())?
192 .and_then(|r| QueryResultItem::try_from(r))
193 .collect::<Result<Vec<_>, _>>()?,
194 &tuning,
195 ),
196 false,
197 ))
198 })
199 .await
200 }
201
202 #[instrument(skip_all)]
204 pub async fn delete_tldr_commands(&self, category: Option<String>) -> Result<u64> {
205 self.client
206 .conn_mut(move |conn| {
207 let mut query = String::from("DELETE FROM command WHERE source = ?1");
208 let mut params: Vec<String> = vec![SOURCE_TLDR.to_owned()];
209 if let Some(cat) = category {
210 query.push_str(" AND category = ?2");
211 params.push(cat);
212 }
213 tracing::trace!("Deleting tldr commands:\n{query}");
214 let affected = conn.execute(&query, rusqlite::params_from_iter(params))?;
215 Ok(affected as u64)
216 })
217 .await
218 }
219
220 #[instrument(skip_all)]
224 pub async fn insert_command(&self, command: Command) -> Result<Command> {
225 self.client
226 .conn(move |conn| {
227 let query = r#"INSERT INTO command (
228 id,
229 category,
230 source,
231 alias,
232 cmd,
233 flat_cmd,
234 description,
235 flat_description,
236 tags,
237 created_at,
238 updated_at
239 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#;
240 tracing::trace!("Inserting a command:\n{query}");
241 let res = conn.execute(
242 query,
243 (
244 &command.id,
245 &command.category,
246 &command.source,
247 &command.alias,
248 &command.cmd,
249 &command.flat_cmd,
250 &command.description,
251 &command.flat_description,
252 serde_json::to_value(&command.tags)?,
253 &command.created_at,
254 &command.updated_at,
255 ),
256 );
257 match res {
258 Ok(_) => Ok(command),
259 Err(err) => {
260 let code = err.sqlite_error().map(|e| e.extended_code).unwrap_or_default();
261 if code == ffi::SQLITE_CONSTRAINT_UNIQUE || code == ffi::SQLITE_CONSTRAINT_PRIMARYKEY {
262 Err(UserFacingError::CommandAlreadyExists.into())
263 } else {
264 Err(Report::from(err).into())
265 }
266 }
267 }
268 })
269 .await
270 }
271
272 #[instrument(skip_all)]
276 pub async fn update_command(&self, command: Command) -> Result<Command> {
277 self.client
278 .conn(move |conn| {
279 let query = r#"UPDATE command SET
280 category = ?2,
281 source = ?3,
282 alias = ?4,
283 cmd = ?5,
284 flat_cmd = ?6,
285 description = ?7,
286 flat_description = ?8,
287 tags = ?9,
288 created_at = ?10,
289 updated_at = ?11
290 WHERE id = ?1"#;
291 tracing::trace!("Updating a command:\n{query}");
292 let res = conn.execute(
293 query,
294 (
295 &command.id,
296 &command.category,
297 &command.source,
298 &command.alias,
299 &command.cmd,
300 &command.flat_cmd,
301 &command.description,
302 &command.flat_description,
303 serde_json::to_value(&command.tags)?,
304 &command.created_at,
305 &command.updated_at,
306 ),
307 );
308 match res {
309 Ok(0) => Err(eyre!("Command not found: {}", command.id).into()),
310 Ok(_) => Ok(command),
311 Err(err) => {
312 let code = err.sqlite_error().map(|e| e.extended_code).unwrap_or_default();
313 if code == ffi::SQLITE_CONSTRAINT_UNIQUE {
314 Err(UserFacingError::CommandAlreadyExists.into())
315 } else {
316 Err(Report::from(err).into())
317 }
318 }
319 }
320 })
321 .await
322 }
323
324 #[instrument(skip_all)]
326 pub async fn increment_command_usage(
327 &self,
328 command_id: Uuid,
329 path: impl AsRef<str> + Send + 'static,
330 ) -> Result<i32> {
331 self.client
332 .conn_mut(move |conn| {
333 let query = r#"
334 INSERT INTO command_usage (command_id, path, usage_count)
335 VALUES (?1, ?2, 1)
336 ON CONFLICT(command_id, path) DO UPDATE SET
337 usage_count = usage_count + 1
338 RETURNING usage_count;"#;
339 tracing::trace!("Incrementing command usage:\n{query}");
340 Ok(conn.query_row(query, (&command_id, &path.as_ref()), |r| r.get(0))?)
341 })
342 .await
343 }
344
345 #[instrument(skip_all)]
349 pub async fn delete_command(&self, command_id: Uuid) -> Result<()> {
350 self.client
351 .conn(move |conn| {
352 let query = "DELETE FROM command WHERE id = ?1";
353 tracing::trace!("Deleting command:\n{query}");
354 let res = conn.execute(query, (&command_id,));
355 match res {
356 Ok(0) => Err(eyre!("Command not found: {command_id}").into()),
357 Ok(_) => Ok(()),
358 Err(err) => Err(Report::from(err).into()),
359 }
360 })
361 .await
362 }
363}
364
365fn rerank_query_results(items: Vec<QueryResultItem>, tuning: &SearchCommandTuning) -> Vec<Command> {
374 if items.is_empty() {
376 return Vec::new();
377 }
378 if items.len() == 1 {
379 return items.into_iter().map(|item| item.command).collect();
380 }
381
382 let (template_matches, mut other_items): (Vec<_>, Vec<_>) = items
385 .into_iter()
386 .partition(|item| item.text_score >= TEMPLATE_MATCH_RANK);
387 if !template_matches.is_empty() {
388 tracing::trace!("Found {} template matches", template_matches.len());
389 }
390
391 let mut final_commands: Vec<Command> = template_matches.into_iter().map(|item| item.command).collect();
393
394 if other_items.len() <= 1 {
396 final_commands.extend(other_items.into_iter().map(|item| item.command));
397 return final_commands;
398 }
399
400 let mut min_text = 0f64;
403 let mut min_path = 0f64;
404 let mut min_usage = 0f64;
405 let mut max_text = f64::NEG_INFINITY;
406 let mut max_path = f64::NEG_INFINITY;
407 let mut max_usage = f64::NEG_INFINITY;
408 for item in &other_items {
409 min_text = min_text.min(item.text_score);
410 max_text = max_text.max(item.text_score);
411 min_path = min_path.min(item.path_score);
412 max_path = max_path.max(item.path_score);
413 min_usage = min_usage.min(item.usage_score);
414 max_usage = max_usage.max(item.usage_score);
415 }
416
417 let range_text = (max_text > min_text).then_some(max_text - min_text);
419 let range_path = (max_path > min_path).then_some(max_path - min_path);
420 let range_usage = (max_usage > min_usage).then_some(max_usage - min_usage);
421
422 other_items.sort_by(|a, b| {
424 match b.is_workspace_command.cmp(&a.is_workspace_command) {
426 Ordering::Equal => {
427 let calculate_score = |item: &QueryResultItem| -> f64 {
429 let norm_text = range_text.map_or(0.5, |range| (item.text_score - min_text) / range);
432 let norm_path = range_path.map_or(0.5, |range| (item.path_score - min_path) / range);
433 let norm_usage = range_usage.map_or(0.5, |range| (item.usage_score - min_usage) / range);
434
435 (norm_text * tuning.text.points as f64)
437 + (norm_path * tuning.path.points as f64)
438 + (norm_usage * tuning.usage.points as f64)
439 };
440
441 let final_score_a = calculate_score(a);
442 let final_score_b = calculate_score(b);
443
444 final_score_b.partial_cmp(&final_score_a).unwrap_or(Ordering::Equal)
446 }
447 other => other,
449 }
450 });
451
452 final_commands.extend(other_items.into_iter().map(|item| item.command));
454 final_commands
455}
456
457impl<'a> TryFrom<&'a Row<'a>> for Command {
458 type Error = rusqlite::Error;
459
460 fn try_from(row: &'a Row<'a>) -> Result<Self, Self::Error> {
461 Ok(Self {
462 id: row.get(1)?,
464 category: row.get(2)?,
465 source: row.get(3)?,
466 alias: row.get(4)?,
467 cmd: row.get(5)?,
468 flat_cmd: row.get(6)?,
469 description: row.get(7)?,
470 flat_description: row.get(8)?,
471 tags: serde_json::from_value(row.get::<_, serde_json::Value>(9)?)
472 .map_err(|e| rusqlite::Error::FromSqlConversionFailure(9, Type::Text, Box::new(e)))?,
473 created_at: row.get(10)?,
474 updated_at: row.get(11)?,
475 is_destructive: false,
476 })
477 }
478}
479
480struct QueryResultItem {
482 command: Command,
484 is_workspace_command: bool,
486 usage_score: f64,
488 path_score: f64,
490 text_score: f64,
492}
493
494impl<'a> TryFrom<&'a Row<'a>> for QueryResultItem {
495 type Error = rusqlite::Error;
496
497 fn try_from(row: &'a Row<'a>) -> Result<Self, Self::Error> {
498 Ok(Self {
499 command: Command::try_from(row)?,
500 is_workspace_command: row.get(12)?,
501 usage_score: row.get(13)?,
502 path_score: row.get(14)?,
503 text_score: row.get(15)?,
504 })
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use pretty_assertions::assert_eq;
511 use strum::IntoEnumIterator;
512 use tokio_stream::iter;
513 use uuid::Uuid;
514
515 use super::*;
516 use crate::{
517 errors::AppError,
518 model::{CATEGORY_USER, ImportExportItem, SOURCE_IMPORT, SOURCE_USER, SearchMode},
519 };
520
521 const PROJ_A_PATH: &str = "/home/user/project-a";
522 const PROJ_A_API_PATH: &str = "/home/user/project-a/api";
523 const PROJ_B_PATH: &str = "/home/user/project-b";
524 const UNRELATED_PATH: &str = "/var/log";
525
526 #[tokio::test]
527 async fn test_setup_workspace_storage() {
528 let storage = SqliteStorage::new_in_memory().await.unwrap();
529 storage.check_sqlite_version().await;
530 let res = storage.setup_workspace_storage().await;
531 assert!(res.is_ok(), "Expected workspace storage setup to succeed: {res:?}");
532 }
533
534 #[tokio::test]
535 async fn test_is_empty() {
536 let storage = SqliteStorage::new_in_memory().await.unwrap();
537 assert!(storage.is_empty().await.unwrap(), "Expected empty storage initially");
538
539 let cmd = Command {
540 id: Uuid::now_v7(),
541 cmd: "test_cmd".to_string(),
542 ..Default::default()
543 };
544 storage.insert_command(cmd).await.unwrap();
545
546 assert!(!storage.is_empty().await.unwrap(), "Expected non-empty after insert");
547 }
548
549 #[tokio::test]
550 async fn test_is_empty_with_workspace() {
551 let storage = SqliteStorage::new_in_memory().await.unwrap();
552 storage.setup_workspace_storage().await.unwrap();
553 assert!(storage.is_empty().await.unwrap(), "Expected empty storage initially");
554
555 let cmd = Command {
556 id: Uuid::now_v7(),
557 cmd: "test_cmd".to_string(),
558 ..Default::default()
559 };
560 storage.insert_command(cmd).await.unwrap();
561
562 assert!(!storage.is_empty().await.unwrap(), "Expected non-empty after insert");
563 }
564
565 #[tokio::test]
566 async fn test_find_tags_no_filters() -> Result<()> {
567 let storage = setup_ranking_storage().await;
568
569 let result = storage
570 .find_tags(SearchCommandsFilter::default(), None, &SearchCommandTuning::default())
571 .await?;
572
573 let expected = vec![
574 ("#git".to_string(), 5, false),
575 ("#build".to_string(), 2, false),
576 ("#commit".to_string(), 2, false),
577 ("#docker".to_string(), 2, false),
578 ("#list".to_string(), 2, false),
579 ("#k8s".to_string(), 1, false),
580 ("#npm".to_string(), 1, false),
581 ("#pod".to_string(), 1, false),
582 ("#push".to_string(), 1, false),
583 ("#unix".to_string(), 1, false),
584 ];
585
586 assert_eq!(result.len(), 10, "Expected 10 unique tags");
587 assert_eq!(result, expected, "Tags list or order mismatch");
588
589 Ok(())
590 }
591
592 #[tokio::test]
593 async fn test_find_tags_filter_by_tags_only() -> Result<()> {
594 let storage = setup_ranking_storage().await;
595
596 let filter1 = SearchCommandsFilter {
597 tags: Some(vec!["#git".to_string()]),
598 ..Default::default()
599 };
600 let result1 = storage
601 .find_tags(filter1, None, &SearchCommandTuning::default())
602 .await?;
603 let expected1 = vec![("#commit".to_string(), 2, false), ("#push".to_string(), 1, false)];
604 assert_eq!(result1.len(), 2,);
605 assert_eq!(result1, expected1);
606
607 let filter2 = SearchCommandsFilter {
608 tags: Some(vec!["#docker".to_string(), "#list".to_string()]),
609 ..Default::default()
610 };
611 let result2 = storage
612 .find_tags(filter2, None, &SearchCommandTuning::default())
613 .await?;
614 assert!(result2.is_empty());
615
616 let filter3 = SearchCommandsFilter {
617 tags: Some(vec!["#list".to_string()]),
618 ..Default::default()
619 };
620 let result3 = storage
621 .find_tags(filter3, None, &SearchCommandTuning::default())
622 .await?;
623 let expected3 = vec![("#docker".to_string(), 1, false), ("#unix".to_string(), 1, false)];
624 assert_eq!(result3.len(), 2);
625 assert_eq!(result3, expected3);
626
627 Ok(())
628 }
629
630 #[tokio::test]
631 async fn test_find_tags_filter_by_prefix_only() -> Result<()> {
632 let storage = setup_ranking_storage().await;
633
634 let result = storage
635 .find_tags(
636 SearchCommandsFilter::default(),
637 Some("#comm".to_string()),
638 &SearchCommandTuning::default(),
639 )
640 .await?;
641 let expected = vec![("#commit".to_string(), 2, false)];
642 assert_eq!(result.len(), 1);
643 assert_eq!(result, expected);
644
645 Ok(())
646 }
647
648 #[tokio::test]
649 async fn test_find_tags_filter_by_tags_and_prefix() -> Result<()> {
650 let storage = setup_ranking_storage().await;
651
652 let filter1 = SearchCommandsFilter {
653 tags: Some(vec!["#git".to_string()]),
654 ..Default::default()
655 };
656 let result1 = storage
657 .find_tags(filter1, Some("#comm".to_string()), &SearchCommandTuning::default())
658 .await?;
659 let expected1 = vec![("#commit".to_string(), 2, false)];
660 assert_eq!(result1.len(), 1);
661 assert_eq!(result1, expected1);
662
663 let filter2 = SearchCommandsFilter {
664 tags: Some(vec!["#git".to_string()]),
665 ..Default::default()
666 };
667 let result2 = storage
668 .find_tags(filter2, Some("#push".to_string()), &SearchCommandTuning::default())
669 .await?;
670 let expected2 = vec![("#push".to_string(), 1, true)];
671 assert_eq!(result2.len(), 1);
672 assert_eq!(result2, expected2);
673
674 Ok(())
675 }
676
677 #[tokio::test]
678 async fn test_find_commands_no_filter() {
679 let storage = setup_ranking_storage().await;
680 let filter = SearchCommandsFilter::default();
681 let (commands, _) = storage
682 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
683 .await
684 .unwrap();
685 assert_eq!(commands.len(), 10, "Expected all sample commands");
686 }
687
688 #[tokio::test]
689 async fn test_find_commands_filter_by_category() {
690 let storage = setup_ranking_storage().await;
691 let filter = SearchCommandsFilter {
692 category: Some(vec!["git".to_string()]),
693 ..Default::default()
694 };
695 let (commands, _) = storage
696 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
697 .await
698 .unwrap();
699 assert_eq!(commands.len(), 2);
700 assert!(commands.iter().all(|c| c.category == "git"));
701
702 let filter_no_match = SearchCommandsFilter {
703 category: Some(vec!["nonexistent".to_string()]),
704 ..Default::default()
705 };
706 let (commands_no_match, _) = storage
707 .find_commands(filter_no_match, "/some/path", &SearchCommandTuning::default())
708 .await
709 .unwrap();
710 assert!(commands_no_match.is_empty());
711 }
712
713 #[tokio::test]
714 async fn test_find_commands_filter_by_source() {
715 let storage = setup_ranking_storage().await;
716 let filter = SearchCommandsFilter {
717 source: Some(SOURCE_TLDR.to_string()),
718 ..Default::default()
719 };
720 let (commands, _) = storage
721 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
722 .await
723 .unwrap();
724 assert_eq!(commands.len(), 3);
725 assert!(commands.iter().all(|c| c.source == SOURCE_TLDR));
726 }
727
728 #[tokio::test]
729 async fn test_find_commands_filter_by_tags() {
730 let storage = setup_ranking_storage().await;
731 let filter_single_tag = SearchCommandsFilter {
732 tags: Some(vec!["#git".to_string()]),
733 ..Default::default()
734 };
735 let (commands_single_tag, _) = storage
736 .find_commands(filter_single_tag, "/some/path", &SearchCommandTuning::default())
737 .await
738 .unwrap();
739 assert_eq!(commands_single_tag.len(), 5);
740
741 let filter_multiple_tags = SearchCommandsFilter {
742 tags: Some(vec!["#docker".to_string(), "#list".to_string()]),
743 ..Default::default()
744 };
745 let (commands_multiple_tags, _) = storage
746 .find_commands(filter_multiple_tags, "/some/path", &SearchCommandTuning::default())
747 .await
748 .unwrap();
749 assert_eq!(commands_multiple_tags.len(), 1);
750
751 let filter_empty_tags = SearchCommandsFilter {
752 tags: Some(vec![]),
753 ..Default::default()
754 };
755 let (commands_empty_tags, _) = storage
756 .find_commands(filter_empty_tags, "/some/path", &SearchCommandTuning::default())
757 .await
758 .unwrap();
759 assert_eq!(commands_empty_tags.len(), 10);
760 }
761
762 #[tokio::test]
763 async fn test_find_commands_alias_precedence() {
764 let storage = setup_ranking_storage().await;
765 storage
766 .setup_command(
767 Command::new(CATEGORY_USER, SOURCE_USER, "gc command interfering"),
768 [("/some/path", 100)],
769 )
770 .await;
771
772 for mode in SearchMode::iter() {
773 let filter = SearchCommandsFilter {
774 search_term: Some("gc".to_string()),
775 search_mode: mode,
776 ..Default::default()
777 };
778 let (commands, alias_match) = storage
779 .find_commands(filter, "", &SearchCommandTuning::default())
780 .await
781 .unwrap();
782 assert!(alias_match, "Expected alias match for mode {mode:?}");
783 assert_eq!(commands.len(), 1, "Expected only alias match for mode {mode:?}");
784 assert_eq!(
785 commands[0].cmd, "git commit -m",
786 "Expected correct alias command for mode {mode:?}"
787 );
788 }
789 }
790
791 #[tokio::test]
792 async fn test_find_commands_alias_shortcut_respects_filters() -> Result<()> {
793 let storage = setup_ranking_storage().await;
794 let user_only_filter = SearchCommandsFilter {
795 category: Some(vec![CATEGORY_USER.to_string()]),
796 search_term: Some("gc".to_string()),
797 ..Default::default()
798 };
799 let (user_commands, alias_match) = storage
800 .find_commands(user_only_filter, "/some/path", &SearchCommandTuning::default())
801 .await?;
802 assert!(!alias_match);
803 assert!(user_commands.iter().all(|command| command.category == CATEGORY_USER));
804
805 let tag_filter = SearchCommandsFilter {
806 tags: Some(vec!["#missing".to_string()]),
807 search_term: Some("gc".to_string()),
808 ..Default::default()
809 };
810 let (tagged_commands, alias_match) = storage
811 .find_commands(tag_filter, "/some/path", &SearchCommandTuning::default())
812 .await?;
813 assert!(!alias_match);
814 assert!(tagged_commands.is_empty());
815 Ok(())
816 }
817
818 #[tokio::test]
819 async fn test_find_commands_search_mode_exact() {
820 let storage = setup_ranking_storage().await;
821 storage.setup_workspace_storage().await.unwrap();
822 let filter_token_match = SearchCommandsFilter {
823 search_term: Some("commit".to_string()),
824 search_mode: SearchMode::Exact,
825 ..Default::default()
826 };
827 let (commands_token_match, _) = storage
828 .find_commands(filter_token_match, "/some/path", &SearchCommandTuning::default())
829 .await
830 .unwrap();
831 assert_eq!(commands_token_match.len(), 2);
832 assert_eq!(commands_token_match[0].cmd, "git commit -m");
833 assert_eq!(commands_token_match[1].cmd, "git commit -m '{{message}}'");
834
835 let filter_no_match = SearchCommandsFilter {
836 search_term: Some("nonexistentterm".to_string()),
837 search_mode: SearchMode::Exact,
838 ..Default::default()
839 };
840 let (commands_no_match, _) = storage
841 .find_commands(filter_no_match, "/some/path", &SearchCommandTuning::default())
842 .await
843 .unwrap();
844 assert!(commands_no_match.is_empty());
845 }
846
847 #[tokio::test]
848 async fn test_find_commands_search_mode_relaxed() {
849 let storage = setup_ranking_storage().await;
850 storage.setup_workspace_storage().await.unwrap();
851 let filter = SearchCommandsFilter {
852 search_term: Some("docker list".to_string()),
853 search_mode: SearchMode::Relaxed,
854 ..Default::default()
855 };
856 let (commands, _) = storage
857 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
858 .await
859 .unwrap();
860 assert_eq!(commands.len(), 2);
861 assert!(commands.iter().any(|c| c.cmd == "docker ps -a"));
862 assert!(commands.iter().any(|c| c.cmd == "ls -lha"));
863 }
864
865 #[tokio::test]
866 async fn test_find_commands_search_mode_regex() {
867 let storage = setup_ranking_storage().await;
868 storage.setup_workspace_storage().await.unwrap();
869 let filter = SearchCommandsFilter {
870 search_term: Some(r"git\s.*it".to_string()),
871 search_mode: SearchMode::Regex,
872 ..Default::default()
873 };
874 let (commands, _) = storage
875 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
876 .await
877 .unwrap();
878 assert_eq!(commands.len(), 2);
879 assert_eq!(commands[0].cmd, "git commit -m '{{message}}'");
880 assert_eq!(commands[1].cmd, "git commit -m");
881
882 let filter_invalid = SearchCommandsFilter {
883 search_term: Some("[[invalid_regex".to_string()),
884 search_mode: SearchMode::Regex,
885 ..Default::default()
886 };
887 assert!(matches!(
888 storage
889 .find_commands(filter_invalid, "/some/path", &SearchCommandTuning::default())
890 .await,
891 Err(AppError::UserFacing(UserFacingError::InvalidRegex))
892 ));
893 }
894
895 #[tokio::test]
896 async fn test_find_commands_search_mode_fuzzy() {
897 let storage = setup_ranking_storage().await;
898 storage.setup_workspace_storage().await.unwrap();
899 let filter = SearchCommandsFilter {
900 search_term: Some("gtcomit".to_string()),
901 search_mode: SearchMode::Fuzzy,
902 ..Default::default()
903 };
904 let (commands, _) = storage
905 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
906 .await
907 .unwrap();
908 assert_eq!(commands.len(), 2);
909 assert_eq!(commands[0].cmd, "git commit -m '{{message}}'");
910 assert_eq!(commands[1].cmd, "git commit -m");
911
912 let filter_empty_fuzzy = SearchCommandsFilter {
913 search_term: Some("'' | ^".to_string()),
914 search_mode: SearchMode::Fuzzy,
915 ..Default::default()
916 };
917 assert!(matches!(
918 storage
919 .find_commands(filter_empty_fuzzy, "/some/path", &SearchCommandTuning::default())
920 .await,
921 Err(AppError::UserFacing(UserFacingError::InvalidFuzzy))
922 ));
923 }
924
925 #[tokio::test]
926 async fn test_find_commands_search_mode_auto() {
927 let storage = setup_ranking_storage().await;
928 let default_tuning = SearchCommandTuning::default();
929
930 let run_search = |term: &'static str, path: &'static str| {
932 let storage = storage.clone();
933 async move {
934 let filter = SearchCommandsFilter {
935 search_term: Some(term.to_string()),
936 search_mode: SearchMode::Auto,
937 ..Default::default()
938 };
939 storage.find_commands(filter, path, &default_tuning).await.unwrap()
940 }
941 };
942
943 let (commands, _) = run_search("list containers", UNRELATED_PATH).await;
945 assert!(!commands.is_empty(), "Expected results for 'list containers'");
946 assert_eq!(
947 commands[0].cmd, "docker ps -a",
948 "Expected 'docker ps -a' to be the top result for 'list containers'"
949 );
950
951 let (commands, _) = run_search("git commit", PROJ_A_PATH).await;
953 assert!(commands.len() >= 2, "Expected at least two results for 'git commit'");
954 assert_eq!(
955 commands[0].cmd, "git commit -m",
956 "Expected 'git commit -m' to be the top result for 'git commit' due to usage"
957 );
958 assert_eq!(
959 commands[1].cmd, "git commit -m '{{message}}'",
960 "Expected template command to be second for 'git commit'"
961 );
962
963 let (commands, _) = run_search("git commit -m 'my new feature'", PROJ_A_PATH).await;
965 assert!(!commands.is_empty(), "Expected results for template match");
966 assert_eq!(
967 commands[0].cmd, "git commit -m '{{message}}'",
968 "Expected template command to be the top result for a matching search term"
969 );
970
971 let (commands, _) = run_search("build", PROJ_A_API_PATH).await;
973 assert!(!commands.is_empty(), "Expected results for 'build'");
974 assert_eq!(
975 commands[0].cmd, "npm run build:prod",
976 "Expected 'npm run build:prod' to be top result for 'build' in its project path"
977 );
978
979 let (commands, _) = run_search("gt sta", PROJ_A_PATH).await;
981 assert!(!commands.is_empty(), "Expected results for fuzzy search 'gt sta'");
982 assert_eq!(
983 commands[0].cmd, "git status",
984 "Expected 'git status' as top result for fuzzy search 'gt sta'"
985 );
986
987 let (commands, _) = run_search("get pod monitoring", UNRELATED_PATH).await;
989 assert!(!commands.is_empty(), "Expected results for 'get pod monitoring'");
990 assert_eq!(
991 commands[0].cmd, "kubectl get pod -n monitoring my-specific-pod-12345",
992 "Expected specific 'kubectl' command to be found"
993 );
994
995 let (commands, _) = run_search("status", PROJ_A_API_PATH).await;
997 assert!(!commands.is_empty(), "Expected results for 'status'");
998 assert_eq!(
999 commands[0].cmd, "git status",
1000 "Expected 'git status' to be top due to high usage in parent path"
1001 );
1002 }
1003
1004 #[tokio::test]
1005 async fn test_find_commands_search_mode_auto_hastag_only() {
1006 let storage = setup_ranking_storage().await;
1007
1008 let filter = SearchCommandsFilter {
1011 search_term: Some("#".to_string()),
1012 search_mode: SearchMode::Auto,
1013 ..Default::default()
1014 };
1015
1016 let res = storage
1017 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
1018 .await;
1019 assert!(res.is_ok(), "Expected a success response, got: {res:?}")
1020 }
1021
1022 #[tokio::test]
1023 async fn test_find_commands_including_workspace() {
1024 let storage = setup_ranking_storage().await;
1025
1026 storage.setup_workspace_storage().await.unwrap();
1027 let commands_to_import = vec![
1028 ImportExportItem::Command(Command {
1029 id: Uuid::now_v7(),
1030 cmd: "cmd1".to_string(),
1031 ..Default::default()
1032 }),
1033 ImportExportItem::Command(Command {
1034 id: Uuid::now_v7(),
1035 cmd: "cmd2".to_string(),
1036 ..Default::default()
1037 }),
1038 ];
1039 let stream = iter(commands_to_import.clone().into_iter().map(Ok));
1040 storage.import_items(stream, false, true).await.unwrap();
1041
1042 let (commands, _) = storage
1043 .find_commands(
1044 SearchCommandsFilter::default(),
1045 "/some/path",
1046 &SearchCommandTuning::default(),
1047 )
1048 .await
1049 .unwrap();
1050 assert_eq!(commands.len(), 12, "Expected 12 commands including workspace");
1051 }
1052
1053 #[tokio::test]
1054 async fn test_find_commands_with_text_including_workspace() {
1055 let storage = setup_ranking_storage().await;
1056
1057 storage.setup_workspace_storage().await.unwrap();
1058 let commands_to_import = vec![ImportExportItem::Command(Command {
1059 id: Uuid::now_v7(),
1060 cmd: "git checkout -b feature/{{name:kebab}}".to_string(),
1061 ..Default::default()
1062 })];
1063 let stream = iter(commands_to_import.clone().into_iter().map(Ok));
1064 storage.import_items(stream, false, true).await.unwrap();
1065
1066 let filter = SearchCommandsFilter {
1067 search_term: Some("git".to_string()),
1068 ..Default::default()
1069 };
1070
1071 let (commands, _) = storage
1072 .find_commands(filter, "/some/path", &SearchCommandTuning::default())
1073 .await
1074 .unwrap();
1075 assert_eq!(commands.len(), 6, "Expected 6 git commands including workspace");
1076 assert!(
1077 commands
1078 .iter()
1079 .any(|c| c.cmd == "git checkout -b feature/{{name:kebab}}")
1080 );
1081 }
1082
1083 #[tokio::test]
1084 async fn test_delete_tldr_commands() {
1085 let storage = SqliteStorage::new_in_memory().await.unwrap();
1086
1087 let tldr_cmd1 = Command {
1089 id: Uuid::now_v7(),
1090 category: "git".to_string(),
1091 source: SOURCE_TLDR.to_string(),
1092 cmd: "git status".to_string(),
1093 ..Default::default()
1094 };
1095 let tldr_cmd2 = Command {
1096 id: Uuid::now_v7(),
1097 category: "docker".to_string(),
1098 source: SOURCE_TLDR.to_string(),
1099 cmd: "docker ps".to_string(),
1100 ..Default::default()
1101 };
1102 let user_cmd = Command {
1103 id: Uuid::now_v7(),
1104 category: "git".to_string(),
1105 source: SOURCE_USER.to_string(),
1106 cmd: "git log".to_string(),
1107 ..Default::default()
1108 };
1109
1110 storage.insert_command(tldr_cmd1.clone()).await.unwrap();
1111 storage.insert_command(tldr_cmd2.clone()).await.unwrap();
1112 storage.insert_command(user_cmd.clone()).await.unwrap();
1113
1114 let removed = storage.delete_tldr_commands(None).await.unwrap();
1116 assert_eq!(removed, 2, "Should remove both tldr commands");
1117
1118 let (remaining, _) = storage
1119 .find_commands(SearchCommandsFilter::default(), "", &SearchCommandTuning::default())
1120 .await
1121 .unwrap();
1122 assert_eq!(remaining.len(), 1, "Only user command should remain");
1123 assert_eq!(remaining[0].cmd, user_cmd.cmd);
1124
1125 storage.insert_command(tldr_cmd1.clone()).await.unwrap();
1127 storage.insert_command(tldr_cmd2.clone()).await.unwrap();
1128
1129 let removed_git = storage.delete_tldr_commands(Some("git".to_string())).await.unwrap();
1131 assert_eq!(removed_git, 1, "Should remove one tldr command in 'git' category");
1132
1133 let (remaining, _) = storage
1134 .find_commands(SearchCommandsFilter::default(), "", &SearchCommandTuning::default())
1135 .await
1136 .unwrap();
1137 let remaining_cmds: Vec<_> = remaining.iter().map(|c| &c.cmd).collect();
1138 assert!(remaining_cmds.contains(&&tldr_cmd2.cmd));
1139 assert!(remaining_cmds.contains(&&user_cmd.cmd));
1140 assert!(!remaining_cmds.contains(&&tldr_cmd1.cmd));
1141 }
1142
1143 #[tokio::test]
1144 async fn test_insert_command() {
1145 let storage = SqliteStorage::new_in_memory().await.unwrap();
1146
1147 let mut cmd = Command {
1148 id: Uuid::now_v7(),
1149 category: "test".to_string(),
1150 cmd: "test_cmd".to_string(),
1151 description: Some("test desc".to_string()),
1152 tags: Some(vec!["tag1".to_string()]),
1153 ..Default::default()
1154 };
1155
1156 let mut inserted = storage.insert_command(cmd.clone()).await.unwrap();
1157 assert_eq!(inserted.cmd, cmd.cmd);
1158
1159 inserted.cmd = "other_cmd".to_string();
1161 match storage.insert_command(inserted).await {
1162 Err(AppError::UserFacing(UserFacingError::CommandAlreadyExists)) => (),
1163 _ => panic!("Expected CommandAlreadyExists error on duplicate id"),
1164 }
1165
1166 cmd.id = Uuid::now_v7();
1168 match storage.insert_command(cmd).await {
1169 Err(AppError::UserFacing(UserFacingError::CommandAlreadyExists)) => (),
1170 _ => panic!("Expected CommandAlreadyExists error on duplicate cmd"),
1171 }
1172 }
1173
1174 #[tokio::test]
1175 async fn test_update_command() {
1176 let storage = SqliteStorage::new_in_memory().await.unwrap();
1177
1178 let cmd = Command {
1179 id: Uuid::now_v7(),
1180 cmd: "original".to_string(),
1181 description: Some("desc".to_string()),
1182 ..Default::default()
1183 };
1184
1185 storage.insert_command(cmd.clone()).await.unwrap();
1186
1187 let mut updated = cmd.clone();
1188 updated.cmd = "updated".to_string();
1189 updated.description = Some("new desc".to_string());
1190
1191 let result = storage.update_command(updated.clone()).await.unwrap();
1192 assert_eq!(result.cmd, "updated");
1193 assert_eq!(result.description, Some("new desc".to_string()));
1194
1195 let mut non_existent = cmd;
1197 non_existent.id = Uuid::now_v7();
1198 match storage.update_command(non_existent).await {
1199 Err(_) => (),
1200 _ => panic!("Expected error when updating non-existent command"),
1201 }
1202
1203 let another_cmd = Command {
1205 id: Uuid::now_v7(),
1206 cmd: "another".to_string(),
1207 ..Default::default()
1208 };
1209 let mut result = storage.insert_command(another_cmd.clone()).await.unwrap();
1210 result.cmd = "updated".to_string();
1211 match storage.update_command(result).await {
1212 Err(AppError::UserFacing(UserFacingError::CommandAlreadyExists)) => (),
1213 _ => panic!("Expected CommandAlreadyExists error when updating to existing cmd"),
1214 }
1215 }
1216
1217 #[tokio::test]
1218 async fn test_increment_command_usage() {
1219 let storage = SqliteStorage::new_in_memory().await.unwrap();
1220
1221 let command = storage
1223 .setup_command(
1224 Command::new(CATEGORY_USER, SOURCE_USER, "gc command interfering"),
1225 [("/some/path", 100)],
1226 )
1227 .await;
1228
1229 let count = storage.increment_command_usage(command.id, "/path").await.unwrap();
1231 assert_eq!(count, 1);
1232
1233 let count = storage.increment_command_usage(command.id, "/some/path").await.unwrap();
1235 assert_eq!(count, 101);
1236 }
1237
1238 #[tokio::test]
1239 async fn test_delete_command() {
1240 let storage = SqliteStorage::new_in_memory().await.unwrap();
1241
1242 let cmd = Command {
1243 id: Uuid::now_v7(),
1244 cmd: "to_delete".to_string(),
1245 ..Default::default()
1246 };
1247
1248 let cmd = storage.insert_command(cmd).await.unwrap();
1249 let res = storage.delete_command(cmd.id).await;
1250 assert!(res.is_ok());
1251
1252 match storage.delete_command(cmd.id).await {
1254 Err(_) => (),
1255 _ => panic!("Expected error when deleting non-existent command"),
1256 }
1257 }
1258
1259 async fn setup_ranking_storage() -> SqliteStorage {
1261 let storage = SqliteStorage::new_in_memory().await.unwrap();
1262 storage
1263 .setup_command(
1264 Command::new(
1265 CATEGORY_USER,
1266 SOURCE_USER,
1267 "kubectl get pod -n monitoring my-specific-pod-12345",
1268 )
1269 .with_description(Some(
1270 "Get a very specific pod by its full name in the monitoring namespace".to_string(),
1271 ))
1272 .with_tags(Some(vec!["#k8s".to_string(), "#pod".to_string()])),
1273 [("/other/path", 1)],
1274 )
1275 .await;
1276 storage
1277 .setup_command(
1278 Command::new(CATEGORY_USER, SOURCE_USER, "git status")
1279 .with_description(Some("Check the status of the git repository".to_string()))
1280 .with_tags(Some(vec!["#git".to_string()])),
1281 [(PROJ_A_PATH, 50), (PROJ_B_PATH, 50), (UNRELATED_PATH, 100)],
1282 )
1283 .await;
1284 storage
1285 .setup_command(
1286 Command::new(CATEGORY_USER, SOURCE_USER, "npm run build:prod")
1287 .with_description(Some("Build the project for production".to_string()))
1288 .with_tags(Some(vec!["#npm".to_string(), "#build".to_string()])),
1289 [(PROJ_A_API_PATH, 25)],
1290 )
1291 .await;
1292 storage
1293 .setup_command(
1294 Command::new(CATEGORY_USER, SOURCE_USER, "container-image-build.sh")
1295 .with_description(Some("A generic script to build a container image".to_string()))
1296 .with_tags(Some(vec!["#docker".to_string(), "#build".to_string()])),
1297 [(UNRELATED_PATH, 35)],
1298 )
1299 .await;
1300 storage
1301 .setup_command(
1302 Command::new(CATEGORY_USER, SOURCE_USER, "git commit -m '{{message}}'")
1303 .with_description(Some("Commit with a message".to_string()))
1304 .with_tags(Some(vec!["#git".to_string(), "#commit".to_string()])),
1305 [(PROJ_A_PATH, 10), (PROJ_B_PATH, 10)],
1306 )
1307 .await;
1308 storage
1309 .setup_command(
1310 Command::new(CATEGORY_USER, SOURCE_USER, "git checkout main")
1311 .with_alias(Some("gco".to_string()))
1312 .with_description(Some("Checkout the main branch".to_string()))
1313 .with_tags(Some(vec!["#git".to_string()])),
1314 [(PROJ_A_PATH, 30), (PROJ_B_PATH, 30)],
1315 )
1316 .await;
1317 storage
1318 .setup_command(
1319 Command::new("git", SOURCE_TLDR, "git commit -m")
1320 .with_alias(Some("gc".to_string()))
1321 .with_description(Some("Commit changes".to_string()))
1322 .with_tags(Some(vec!["#git".to_string(), "#commit".to_string()])),
1323 [(PROJ_A_PATH, 15)],
1324 )
1325 .await;
1326 storage
1327 .setup_command(
1328 Command::new("docker", SOURCE_TLDR, "docker ps -a")
1329 .with_description(Some("List all containers".to_string()))
1330 .with_tags(Some(vec!["#docker".to_string(), "#list".to_string()])),
1331 [(PROJ_A_PATH, 5), (PROJ_B_PATH, 5)],
1332 )
1333 .await;
1334 storage
1335 .setup_command(
1336 Command::new("git", SOURCE_TLDR, "git push")
1337 .with_description(Some("Push changes".to_string()))
1338 .with_tags(Some(vec!["#git".to_string(), "#push".to_string()])),
1339 [(PROJ_A_PATH, 20), (PROJ_B_PATH, 20)],
1340 )
1341 .await;
1342 storage
1343 .setup_command(
1344 Command::new(CATEGORY_USER, SOURCE_IMPORT, "ls -lha")
1345 .with_description(Some("List files".to_string()))
1346 .with_tags(Some(vec!["#unix".to_string(), "#list".to_string()])),
1347 [(PROJ_A_PATH, 100), (PROJ_B_PATH, 100), (UNRELATED_PATH, 100)],
1348 )
1349 .await;
1350
1351 storage
1352 }
1353
1354 impl SqliteStorage {
1355 async fn check_sqlite_version(&self) {
1357 let version: String = self
1358 .client
1359 .conn_mut(|conn| {
1360 conn.query_row("SELECT sqlite_version()", [], |row| row.get(0))
1361 .map_err(Into::into)
1362 })
1363 .await
1364 .unwrap();
1365 println!("Running with SQLite version: {version}");
1366 }
1367
1368 async fn setup_command(
1371 &self,
1372 command: Command,
1373 usage: impl IntoIterator<Item = (&str, i32)> + Send + 'static,
1374 ) -> Command {
1375 let command = self.insert_command(command).await.unwrap();
1376 self.client
1377 .conn_mut(move |conn| {
1378 for (path, usage_count) in usage {
1379 conn.execute(
1380 r#"
1381 INSERT INTO command_usage (command_id, path, usage_count)
1382 VALUES (?1, ?2, ?3)
1383 ON CONFLICT(command_id, path) DO UPDATE SET
1384 usage_count = excluded.usage_count"#,
1385 (&command.id, path, usage_count),
1386 )?;
1387 }
1388 Ok(command)
1389 })
1390 .await
1391 .unwrap()
1392 }
1393 }
1394}