intelli_shell/service/
mod.rs

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