intelli_shell/service/
command.rs

1use std::{env, path::PathBuf};
2
3use color_eyre::Result;
4use tokio::fs::File;
5use tracing::instrument;
6use uuid::Uuid;
7
8use super::IntelliShellService;
9use crate::{
10    errors::{InsertError, SearchError, UpdateError},
11    model::{CATEGORY_USER, CATEGORY_WORKSPACE, Command, SOURCE_WORKSPACE, SearchCommandsFilter, SearchMode},
12    service::import_export::parse_commands,
13    utils::{extract_tags_and_cleaned_text, extract_tags_with_editing_and_cleaned_text, get_working_dir},
14};
15
16/// A Tag consist on the text, the amount of times it has been used and whether it was an exact match from the query
17type Tag = (String, u64, bool);
18
19impl IntelliShellService {
20    /// Loads workspace commands from the `.intellishell` file in the current working directory setting up the temporary
21    /// tables in the database if they don't exist.
22    ///
23    /// Returns the number of commands loaded.
24    #[instrument(skip_all)]
25    pub async fn load_workspace_commands(&self) -> Result<u64> {
26        if env::var("INTELLI_SKIP_WORKSPACE")
27            .map(|v| v != "1" && v.to_lowercase() != "true")
28            .unwrap_or(true)
29            && let Some(workspace_commands) = find_workspace_commands_file()
30        {
31            tracing::debug!("Found workspace commands at {}", workspace_commands.display());
32
33            // Set up the temporary tables in the database
34            self.storage.setup_workspace_storage().await?;
35
36            // Parse the commands from the file
37            let file = File::open(&workspace_commands).await?;
38            let commands_stream = parse_commands(file, vec!["#workspace".into()], CATEGORY_WORKSPACE, SOURCE_WORKSPACE);
39
40            // Import commands into the temp tables
41            let (loaded, _) = self.storage.import_commands(commands_stream, None, false, true).await?;
42
43            tracing::info!(
44                "Loaded {loaded} workspace commands from {}",
45                workspace_commands.display()
46            );
47
48            Ok(loaded)
49        } else {
50            Ok(0)
51        }
52    }
53
54    /// Returns whether the commands storage is empty
55    #[instrument(skip_all)]
56    pub async fn is_storage_empty(&self) -> Result<bool> {
57        self.storage.is_empty().await
58    }
59
60    /// Bookmarks a new command
61    #[instrument(skip_all)]
62    pub async fn insert_command(&self, command: Command) -> Result<Command, InsertError> {
63        // Validate
64        if command.cmd.is_empty() {
65            return Err(InsertError::Invalid("Command cannot be empty"));
66        }
67
68        // Insert it
69        tracing::info!("Bookmarking command: {command}");
70        self.storage.insert_command(command).await
71    }
72
73    /// Updates an existing command
74    #[instrument(skip_all)]
75    pub async fn update_command(&self, command: Command) -> Result<Command, UpdateError> {
76        // Validate
77        if command.cmd.is_empty() {
78            return Err(UpdateError::Invalid("Command cannot be empty"));
79        }
80
81        // Update it
82        tracing::info!("Updating command '{}': {}", command.id, command.cmd);
83        self.storage.update_command(command).await
84    }
85
86    /// Increases the usage of a command, returning the new usage count
87    #[instrument(skip_all)]
88    pub async fn increment_command_usage(&self, command_id: Uuid) -> Result<i32, UpdateError> {
89        tracing::info!("Increasing usage for command '{command_id}'");
90        self.storage
91            .increment_command_usage(command_id, get_working_dir())
92            .await
93    }
94
95    /// Deletes an existing command
96    #[instrument(skip_all)]
97    pub async fn delete_command(&self, id: Uuid) -> Result<()> {
98        tracing::info!("Deleting command: {}", id);
99        self.storage.delete_command(id).await
100    }
101
102    /// Searches for tags based on a query string
103    #[instrument(skip_all)]
104    pub async fn search_tags(
105        &self,
106        mode: SearchMode,
107        user_only: bool,
108        query: &str,
109        cursor_pos: usize,
110    ) -> Result<Option<Vec<Tag>>, SearchError> {
111        let Some((editing_tag, other_tags, cleaned_text)) =
112            extract_tags_with_editing_and_cleaned_text(query, cursor_pos)
113        else {
114            return Ok(None);
115        };
116
117        tracing::info!(
118            "Searching for tags{} [{mode:?}]: {query}",
119            if user_only { " (user only)" } else { "" }
120        );
121        tracing::trace!("Editing: {editing_tag} Other: {other_tags:?}");
122
123        let filter = SearchCommandsFilter {
124            category: user_only.then(|| vec![CATEGORY_USER.to_string()]),
125            source: None,
126            tags: Some(other_tags),
127            search_mode: mode,
128            search_term: Some(cleaned_text),
129        };
130
131        Ok(Some(
132            self.storage
133                .find_tags(filter, Some(editing_tag), &self.tuning.commands)
134                .await?,
135        ))
136    }
137
138    /// Searches for commands based on a query string, returning both the command and whether it was an alias match
139    #[instrument(skip_all)]
140    pub async fn search_commands(
141        &self,
142        mode: SearchMode,
143        user_only: bool,
144        query: &str,
145    ) -> Result<(Vec<Command>, bool), SearchError> {
146        tracing::info!(
147            "Searching for commands{} [{mode:?}]: {query}",
148            if user_only { " (user only)" } else { "" }
149        );
150
151        let query = query.trim();
152        let filter = if query.is_empty() {
153            // If there are no query, just display user commands
154            SearchCommandsFilter {
155                category: Some(if user_only {
156                    vec![CATEGORY_USER.to_string()]
157                } else {
158                    vec![CATEGORY_USER.to_string(), CATEGORY_WORKSPACE.to_string()]
159                }),
160                search_mode: mode,
161                ..Default::default()
162            }
163        } else {
164            // Else, parse user query into tags and search term
165            let (tags, search_term) = match extract_tags_and_cleaned_text(query) {
166                Some((tags, cleaned_query)) => (Some(tags), Some(cleaned_query)),
167                None => (None, Some(query.to_string())),
168            };
169
170            // Build the filter
171            SearchCommandsFilter {
172                category: user_only.then(|| vec![CATEGORY_USER.to_string()]),
173                source: None,
174                tags,
175                search_mode: mode,
176                search_term,
177            }
178        };
179
180        // Query the storage
181        self.storage
182            .find_commands(filter, get_working_dir(), &self.tuning.commands)
183            .await
184    }
185}
186
187/// Searches upwards from the current working dir for a `.intellishell` file.
188///
189/// The search stops if a `.git` directory or the filesystem root is found.
190fn find_workspace_commands_file() -> Option<PathBuf> {
191    let working_dir = PathBuf::from(get_working_dir());
192    let mut current = Some(working_dir.as_path());
193    while let Some(parent) = current {
194        let candidate = parent.join(".intellishell");
195        if candidate.is_file() {
196            return Some(candidate);
197        }
198
199        if parent.join(".git").is_dir() {
200            // Workspace boundary found
201            return None;
202        }
203
204        current = parent.parent();
205    }
206    None
207}