Skip to main content

intelli_shell/service/
mod.rs

1use std::{
2    collections::HashSet,
3    env,
4    path::{Path, PathBuf},
5    sync::{Arc, LazyLock, Mutex},
6    time::Duration,
7};
8
9use directories::BaseDirs;
10use semver::Version;
11use tokio::fs::File;
12use tracing::instrument;
13use walkdir::WalkDir;
14
15/// The timeout for GitHub related requests
16pub(super) const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
17
18/// The interval for fetching new versions and releases
19pub(super) const FETCH_INTERVAL: chrono::Duration = chrono::Duration::hours(16);
20
21/// The current version of the application
22pub(crate) static CURRENT_VERSION: LazyLock<Version> =
23    LazyLock::new(|| Version::parse(env!("CARGO_PKG_VERSION")).expect("valid version"));
24
25use crate::{
26    config::{AiConfig, SearchTuning},
27    errors::Result,
28    model::{CATEGORY_WORKSPACE, SOURCE_WORKSPACE},
29    service::import::parse_import_items,
30    storage::SqliteStorage,
31    utils::get_working_dir,
32};
33
34mod ai;
35mod command;
36mod completion;
37mod export;
38mod import;
39mod release;
40mod variable;
41mod version;
42
43#[cfg(feature = "tldr")]
44mod tldr;
45
46pub use ai::AiFixProgress;
47pub use completion::{FORBIDDEN_COMPLETION_ROOT_CMD_CHARS, FORBIDDEN_COMPLETION_VARIABLE_CHARS};
48#[cfg(feature = "tldr")]
49pub use tldr::{RepoStatus, TldrFetchProgress};
50
51/// Service for managing user commands in IntelliShell
52#[derive(Clone)]
53pub struct IntelliShellService {
54    check_updates: bool,
55    storage: SqliteStorage,
56    tuning: SearchTuning,
57    ai: AiConfig,
58    #[cfg(feature = "tldr")]
59    tldr_repo_path: PathBuf,
60    version_check_state: Arc<Mutex<version::VersionCheckState>>,
61}
62
63impl IntelliShellService {
64    /// Creates a new instance of `IntelliShellService`
65    pub fn new(
66        storage: SqliteStorage,
67        tuning: SearchTuning,
68        ai: AiConfig,
69        data_dir: impl AsRef<Path>,
70        check_updates: bool,
71    ) -> Self {
72        Self {
73            check_updates,
74            storage,
75            tuning,
76            ai,
77            #[cfg(feature = "tldr")]
78            tldr_repo_path: data_dir.as_ref().join("tldr"),
79            version_check_state: Arc::new(Mutex::new(version::VersionCheckState::NotStarted)),
80        }
81    }
82
83    #[cfg(debug_assertions)]
84    pub async fn query(&self, sql: String) -> crate::errors::Result<String> {
85        self.storage.query(sql).await
86    }
87
88    /// Loads workspace commands and completions from `.intellishell` files using a built-in search hierarchy.
89    ///
90    /// Search order:
91    /// 1. Local workspace: searches upward from current directory until `.git` or filesystem root
92    /// 2. Home directory: `~/.intellishell` (file or directory)
93    /// 3. System-wide: `/etc/.intellishell` (Unix) or `C:\ProgramData\.intellishell` (Windows)
94    ///
95    /// Each location can be either a file or directory. Directories are recursively searched for all files.
96    /// Sets up temporary tables in the database if they don't exist.
97    ///
98    /// Returns whether any workspace file was processed
99    #[instrument(skip_all)]
100    pub async fn load_workspace_items(&self) -> Result<bool> {
101        if !env::var("INTELLI_SKIP_WORKSPACE")
102            .map(|v| v != "1" && v.to_lowercase() != "true")
103            .unwrap_or(true)
104        {
105            tracing::info!("Skipping workspace load due to INTELLI_SKIP_WORKSPACE");
106            return Ok(false);
107        }
108
109        // Collect all workspace files
110        let workspace_files = find_workspace_files();
111        if workspace_files.is_empty() {
112            tracing::debug!("No workspace files were found");
113            return Ok(false);
114        }
115
116        // Set up the temporary tables in the database
117        self.storage.setup_workspace_storage().await?;
118
119        // For each workspace file
120        for (workspace_file, tag_name) in workspace_files {
121            // Parse the items from the file
122            let file = File::open(&workspace_file).await?;
123            let tag = format!("#{}", tag_name.as_deref().unwrap_or("workspace"));
124            let items_stream = parse_import_items(file, vec![tag], CATEGORY_WORKSPACE, SOURCE_WORKSPACE);
125
126            // Import items into the temp tables
127            match self.storage.import_items(items_stream, false, true).await {
128                Ok(stats) => {
129                    tracing::info!(
130                        "Loaded {} commands and {} completions from workspace file {}",
131                        stats.commands_imported,
132                        stats.completions_imported,
133                        workspace_file.display()
134                    );
135                }
136                Err(err) => {
137                    tracing::error!("Failed to load workspace file {}", workspace_file.display());
138                    return Err(err);
139                }
140            }
141        }
142
143        Ok(true)
144    }
145}
146
147/// Searches for `.intellishell` files using a built-in hierarchy.
148///
149/// Search order:
150/// 1. Local workspace: searches upward from current directory until `.git` or filesystem root
151/// 2. Home directory: `~/.intellishell` (file or directory)
152/// 3. System-wide: `/etc/.intellishell` (Unix) or `C:\ProgramData\.intellishell` (Windows)
153///
154/// Each location can be either a file or directory:
155/// - File: loaded with parent folder name as tag
156/// - Directory: all files inside are loaded recursively with file name as tag
157///
158/// Returns a vector of tuples (file_path, tag) for all found files.
159fn find_workspace_files() -> Vec<(PathBuf, Option<String>)> {
160    let mut result = Vec::new();
161    let mut seen_paths = HashSet::new();
162
163    // 1. Search upwards from current directory
164    let working_dir = PathBuf::from(get_working_dir());
165    let mut current = Some(working_dir.as_path());
166    tracing::debug!(
167        "Searching for workspace .intellishell file or folder from working dir: {}",
168        working_dir.display()
169    );
170    while let Some(parent) = current {
171        let candidate = parent.join(".intellishell");
172        if candidate.exists() {
173            collect_intellishell_files_from_location(&candidate, &mut seen_paths, &mut result);
174            break;
175        }
176
177        if parent.join(".git").is_dir() {
178            // Workspace boundary found
179            break;
180        }
181
182        current = parent.parent();
183    }
184
185    // 2. Search in home directory
186    if let Some(base_dirs) = BaseDirs::new() {
187        let home_dir = base_dirs.home_dir();
188        tracing::debug!(
189            "Searching for .intellishell file or folder in home dir: {}",
190            home_dir.display()
191        );
192        let home_candidate = home_dir.join(".intellishell");
193        if home_candidate.exists() {
194            collect_intellishell_files_from_location(&home_candidate, &mut seen_paths, &mut result);
195        }
196    }
197
198    // 3. Search in system-wide location
199    #[cfg(target_os = "windows")]
200    let system_candidate = PathBuf::from(r"C:\ProgramData\.intellishell");
201    #[cfg(not(target_os = "windows"))]
202    let system_candidate = PathBuf::from("/etc/.intellishell");
203
204    tracing::debug!(
205        "Searching for .intellishell file or folder system-wide: {}",
206        system_candidate.display()
207    );
208    if system_candidate.exists() {
209        collect_intellishell_files_from_location(&system_candidate, &mut seen_paths, &mut result);
210    }
211
212    result
213}
214
215/// Collects `.intellishell` files from a given path, handling both single files and directories.
216///
217/// - If the path is a file, it's added directly. The tag is the parent folder's name.
218/// - If the path is a directory, this function recursively finds all non-hidden files within it. The tag for each file
219///   is its own filename stem.
220///
221/// Duplicates are skipped based on the `seen_paths` set.
222fn collect_intellishell_files_from_location(
223    path: &Path,
224    seen_paths: &mut HashSet<PathBuf>,
225    result: &mut Vec<(PathBuf, Option<String>)>,
226) {
227    if path.is_file() {
228        // Handle the case where `.intellishell` is a single file.
229        if seen_paths.insert(path.to_path_buf()) {
230            let folder_name = path
231                .parent()
232                .and_then(|p| p.file_name())
233                .and_then(|n| n.to_str())
234                .map(String::from);
235            result.push((path.to_path_buf(), folder_name));
236        } else {
237            tracing::trace!("Skipping duplicate workspace file: {}", path.display());
238        }
239    } else if path.is_dir() {
240        // Use `walkdir` to recursively iterate through the directory.
241        // `min_depth(1)` skips the root directory itself.
242        for entry in WalkDir::new(path).min_depth(1).into_iter().filter_map(|e| e.ok()) {
243            let entry_path = entry.path();
244            let file_name = entry.file_name().to_string_lossy();
245            // Process the entry if it's a file and not a hidden file
246            if entry_path.is_file() && !file_name.starts_with('.') {
247                if seen_paths.insert(entry_path.to_path_buf()) {
248                    let tag = entry_path.file_stem().and_then(|n| n.to_str()).map(String::from);
249                    result.push((entry_path.to_path_buf(), tag));
250                } else {
251                    tracing::trace!("Skipping duplicate workspace file: {}", entry_path.display());
252                }
253            }
254        }
255    }
256}