Skip to main content

intelli_shell/storage/
command.rs

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    /// Creates temporary tables for workspace-specific commands and completions for the current session by reflecting
18    /// the schema of the main tables.
19    #[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                // Fetch the schema for the main tables and triggers
25                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                // Modify and execute each schema statement to create temporary versions
40                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    /// Determines if the storage is empty, i.e., if there are no commands in the database
62    #[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    /// Retrieves all tags from the database along with their usage statistics and if it's an exact match for the prefix
79    #[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    /// Finds and retrieves commands from the database.
103    ///
104    /// When a search term is present and no category, source, or tag filters are active, an exact alias match is
105    /// returned exclusively. Filtered searches use the filter-aware query instead.
106    #[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        // When there's a search term
117        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            // Try to find a command matching the alias exactly
124            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        // Build the commands query when no alias is matched
158        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        // Execute the queries
167        let tuning = *tuning;
168        self.client
169            .conn(move |conn| {
170                // If there's a query to find the command by alias
171                if let Some((query_alias, a_params)) = query_alias {
172                    tracing::trace!("Querying aliased commands:\n{query_alias}");
173                    // Run the query
174                    let rows = conn
175                        .prepare(&query_alias)?
176                        .query(a_params)?
177                        .map(|r| Command::try_from(r))
178                        .collect::<Vec<_>>()?;
179                    // Return the rows if there's a match
180                    if !rows.is_empty() {
181                        return Ok((rows, true));
182                    }
183                }
184                // Otherwise, run the regular search query and re-rank results
185                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    /// Removes tldr commands
203    #[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    /// Inserts a new command into the database.
221    ///
222    /// If a command with the same `id` or `cmd` already exists in the database, an error will be returned.
223    #[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    /// Updates an existing command in the database.
273    ///
274    /// If the command to be updated does not exist, an error will be returned.
275    #[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    /// Increments the usage of a command
325    #[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    /// Deletes an existing command from the database.
346    ///
347    /// If the command to be deleted does not exist, an error will be returned.
348    #[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
365/// Re-ranks a vector of [`QueryResultItem`] based on a combined score and command type.
366///
367/// The ranking priority is as follows:
368/// 1. Template matches (highest priority)
369/// 2. Workspace-specific commands
370/// 3. Other commands
371///
372/// Within categories 2 and 3, items are sorted based on a combined score of normalized text, path, and usage scores.
373fn rerank_query_results(items: Vec<QueryResultItem>, tuning: &SearchCommandTuning) -> Vec<Command> {
374    // Handle empty or single-item input
375    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    // 1. Partition results into template matches and all others
383    // Template matches have a fixed high rank and are handled separately to ensure they are always first
384    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    // Convert template matches to Command structs
392    let mut final_commands: Vec<Command> = template_matches.into_iter().map(|item| item.command).collect();
393
394    // If there are no other items, or only one, no complex normalization is needed
395    if other_items.len() <= 1 {
396        final_commands.extend(other_items.into_iter().map(|item| item.command));
397        return final_commands;
398    }
399
400    // Find min / max for all three scores
401    // Minimums are set to zero to avoid penalizing when all results are high-scoring
402    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    // Calculate score ranges for normalization
418    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    // Sort items based on the combined normalized score
423    other_items.sort_by(|a, b| {
424        // Primary sort key: Workspace commands first (descending order for bool)
425        match b.is_workspace_command.cmp(&a.is_workspace_command) {
426            Ordering::Equal => {
427                // Secondary sort key: Calculated score. Only compute if primary keys are equal.
428                let calculate_score = |item: &QueryResultItem| -> f64 {
429                    // Normalize each score to a 0.0 ~ 1.0 range
430                    // If the range is 0, the score is neutral (0.5)
431                    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                    // Apply points from tuning configuration
436                    (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                // Sort by final_score in descending order (higher score is better)
445                final_score_b.partial_cmp(&final_score_a).unwrap_or(Ordering::Equal)
446            }
447            // If items are in different categories (workspace vs. other), use the primary ordering
448            other => other,
449        }
450    });
451
452    // Append the sorted "other" items to the high-priority template commands
453    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            // rowid is skipped
463            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
480/// Struct representing a command query result item when using FTS ranking
481struct QueryResultItem {
482    /// The command associated with this result item
483    command: Command,
484    /// Whether this command is included in the workspace commands file
485    is_workspace_command: bool,
486    /// Score for the command global usage
487    usage_score: f64,
488    /// Score for the command path usage relevance
489    path_score: f64,
490    /// Score for the text relevance
491    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        // Helper closure for running a search and making assertions
931        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        // Scenario 1: Basic text and description search
944        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        // Scenario 2: Prefix and usage search
952        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        // Scenario 3: Template matching
964        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        // Scenario 4: Path relevance
972        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        // Scenario 5: Fuzzy search fallback
980        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        // Scenario 6: Specific description search with low usage
988        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        // Scenario 7: High usage in parent path
996        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        // This test forces fts2 and fts3 to be omited and the final query to contain fts1 only
1009        // If the query planner tries to inline it, it would fail because bm25 functión can't be used on that context
1010        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        // Insert some tldr and non-tldr commands
1088        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        // Delete all tldr commands
1115        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        // Re-insert tldr commands for category-specific removal
1126        storage.insert_command(tldr_cmd1.clone()).await.unwrap();
1127        storage.insert_command(tldr_cmd2.clone()).await.unwrap();
1128
1129        // Remove only tldr commands in 'git' category
1130        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        // Test duplicate id insert fails
1160        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        // Test duplicate cmd insert fails
1167        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        // Test update non-existent fails
1196        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        // Test update to existing cmd fails
1204        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        // Setup the command
1222        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        // Insert
1230        let count = storage.increment_command_usage(command.id, "/path").await.unwrap();
1231        assert_eq!(count, 1);
1232
1233        // Update
1234        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        // Test delete non-existent fails
1253        match storage.delete_command(cmd.id).await {
1254            Err(_) => (),
1255            _ => panic!("Expected error when deleting non-existent command"),
1256        }
1257    }
1258
1259    /// Helper to setup a storage instance with a comprehensive suite of commands for testing all scenarios.
1260    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        /// A helper function to validate the SQLite version
1356        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        /// A helper function to make setting up test data cleaner.
1369        /// It inserts a command and then increments its usage.
1370        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}