intelli_shell/service/
mod.rs1use 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
15pub(super) const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
17
18pub(super) const FETCH_INTERVAL: chrono::Duration = chrono::Duration::hours(16);
20
21pub(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#[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 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 #[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 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 self.storage.setup_workspace_storage().await?;
118
119 for (workspace_file, tag_name) in workspace_files {
121 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 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
147fn find_workspace_files() -> Vec<(PathBuf, Option<String>)> {
160 let mut result = Vec::new();
161 let mut seen_paths = HashSet::new();
162
163 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 break;
180 }
181
182 current = parent.parent();
183 }
184
185 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 #[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
215fn 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 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 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 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}