intelli_shell/service/
tldr.rs1use color_eyre::{
2 Report,
3 eyre::{Context, OptionExt, eyre},
4};
5use futures_util::{FutureExt, StreamExt, TryStreamExt, stream};
6use git2::{
7 FetchOptions, ProxyOptions, RemoteCallbacks, Repository,
8 build::{CheckoutBuilder, RepoBuilder},
9};
10use tokio::{fs::File, sync::mpsc};
11use tokio_util::sync::CancellationToken;
12use tracing::instrument;
13use walkdir::WalkDir;
14
15use super::{IntelliShellService, import::parse_import_items};
16use crate::{
17 errors::{Result, UserFacingError},
18 model::{ImportStats, SOURCE_TLDR, TldrConnectionMode},
19};
20
21#[derive(Debug)]
23pub enum TldrFetchProgress {
24 Repository(RepoStatus),
26 LocatingFiles,
28 FilesLocated(u64),
30 ProcessingStart(u64),
32 ProcessingFile(String),
34 FileProcessed(String),
36}
37
38#[derive(Debug)]
40pub enum RepoStatus {
41 Cloning,
43 DoneCloning,
45 Fetching,
47 UpToDate,
49 Updating,
51 DoneUpdating,
53}
54
55impl IntelliShellService {
56 #[instrument(skip_all)]
60 pub async fn clear_tldr_commands(&self, category: Option<String>) -> Result<u64> {
61 self.storage.delete_tldr_commands(category).await
62 }
63
64 #[instrument(skip_all)]
66 pub async fn fetch_tldr_commands(
67 &self,
68 category: Option<String>,
69 connection_mode: TldrConnectionMode,
70 commands: Vec<String>,
71 progress: mpsc::Sender<TldrFetchProgress>,
72 cancellation_token: CancellationToken,
73 ) -> Result<ImportStats> {
74 if cancellation_token.is_cancelled() {
76 tracing::info!("TLDR fetch cancelled before starting");
77 return Err(UserFacingError::Cancelled.into());
78 }
79
80 self.setup_tldr_repo(connection_mode, progress.clone(), cancellation_token.clone())
82 .await?;
83
84 let categories = if let Some(cat) = category {
86 vec![cat]
87 } else {
88 vec![
89 "common".to_owned(),
90 #[cfg(target_os = "windows")]
91 "windows".to_owned(),
92 #[cfg(target_os = "android")]
93 "android".to_owned(),
94 #[cfg(target_os = "macos")]
95 "osx".to_owned(),
96 #[cfg(target_os = "freebsd")]
97 "freebsd".to_owned(),
98 #[cfg(target_os = "openbsd")]
99 "openbsd".to_owned(),
100 #[cfg(target_os = "netbsd")]
101 "netbsd".to_owned(),
102 #[cfg(any(
103 target_os = "linux",
104 target_os = "freebsd",
105 target_os = "openbsd",
106 target_os = "netbsd",
107 target_os = "dragonfly",
108 ))]
109 "linux".to_owned(),
110 ]
111 };
112
113 let pages_path = self.tldr_repo_path.join("pages");
115
116 tracing::info!("Locating files for categories: {}", categories.join(", "));
117 progress.send(TldrFetchProgress::LocatingFiles).await.ok();
118
119 let mut command_files = Vec::new();
121 let mut iter = WalkDir::new(&pages_path).max_depth(2).into_iter();
122 while let Some(result) = iter.next() {
123 if cancellation_token.is_cancelled() {
125 tracing::info!("TLDR fetch cancelled during file discovery");
126 return Err(UserFacingError::Cancelled.into());
127 }
128
129 let entry = result.wrap_err("Couldn't read tldr repository files")?;
130 let path = entry.path();
131
132 if path == pages_path {
134 continue;
135 }
136
137 let file_name = entry.file_name().to_str().ok_or_eyre("Non valid file name")?;
139 if entry.file_type().is_dir() {
140 if !categories.iter().any(|c| c == file_name) {
141 tracing::trace!("Skipped directory: {file_name}");
142 iter.skip_current_dir();
143 continue;
144 } else {
145 continue;
147 }
148 }
149
150 let Some(file_name_no_ext) = file_name.strip_suffix(".md") else {
152 tracing::warn!("Unexpected file found: {}", path.display());
153 continue;
154 };
155
156 if !commands.is_empty() {
158 if !commands.iter().any(|c| c == file_name_no_ext) {
159 continue;
160 } else {
161 tracing::trace!("Included command: {file_name_no_ext}");
162 }
163 }
164
165 let category = path
167 .parent()
168 .and_then(|p| p.file_name())
169 .and_then(|p| p.to_str())
170 .ok_or_eyre("Couldn't read tldr category")?
171 .to_owned();
172
173 command_files.push((path.to_path_buf(), category, file_name_no_ext.to_owned()));
175 }
176
177 progress
178 .send(TldrFetchProgress::FilesLocated(command_files.len() as u64))
179 .await
180 .ok();
181
182 tracing::info!("Found {} files to be processed", command_files.len());
183
184 progress
185 .send(TldrFetchProgress::ProcessingStart(command_files.len() as u64))
186 .await
187 .ok();
188
189 let items_stream = stream::iter(command_files)
191 .map(move |(path, category, command)| {
192 let progress = progress.clone();
193 async move {
194 progress
195 .send(TldrFetchProgress::ProcessingFile(command.clone()))
196 .await
197 .ok();
198
199 let file = File::open(&path)
201 .await
202 .wrap_err_with(|| format!("Failed to open tldr file: {}", path.display()))?;
203 let stream = parse_import_items(file, vec![], category, SOURCE_TLDR);
204
205 progress.send(TldrFetchProgress::FileProcessed(command)).await.ok();
206 Ok::<_, Report>(stream)
207 }
208 })
209 .buffered(5)
210 .try_flatten();
211
212 let stats = self
214 .storage
215 .import_items(
216 items_stream.take_until(cancellation_token.clone().cancelled_owned().fuse()),
217 true,
218 false,
219 )
220 .await?;
221
222 if cancellation_token.is_cancelled() {
224 tracing::info!("TLDR fetch cancelled during command processing");
225 return Err(UserFacingError::Cancelled.into());
226 }
227
228 Ok(stats)
229 }
230
231 #[instrument(skip_all)]
232 async fn setup_tldr_repo(
233 &self,
234 connection_mode: TldrConnectionMode,
235 progress: mpsc::Sender<TldrFetchProgress>,
236 cancellation_token: CancellationToken,
237 ) -> Result<bool> {
238 const BRANCH: &str = "main";
239 const HTTPS_URL: &str = "https://github.com/tldr-pages/tldr.git";
240 const SSH_URL: &str = "git@github.com:tldr-pages/tldr.git";
241
242 let tldr_repo_path = self.tldr_repo_path.clone();
243
244 tokio::task::spawn_blocking(move || {
245 let send_progress = |status| {
247 progress.blocking_send(TldrFetchProgress::Repository(status)).ok();
249 };
250
251 let repo = if tldr_repo_path.exists() {
253 Some(Repository::open(&tldr_repo_path).wrap_err("Failed to open existing tldr repository")?)
254 } else {
255 None
256 };
257
258 let repo_url = match connection_mode {
263 TldrConnectionMode::Https => HTTPS_URL.to_owned(),
264 TldrConnectionMode::Ssh => SSH_URL.to_owned(),
265 TldrConnectionMode::Auto => repo
266 .as_ref()
267 .and_then(|repo| repo.find_remote("origin").ok())
268 .and_then(|remote| remote.url().map(ToOwned::to_owned))
269 .unwrap_or_else(|| HTTPS_URL.to_owned()),
270 };
271 let mut callbacks = RemoteCallbacks::new();
273 callbacks.transfer_progress(move |_| !cancellation_token.is_cancelled());
274 let mut agent_offered = false;
282 callbacks.credentials(move |_url, username_from_url, allowed_types| {
283 let username = username_from_url.unwrap_or("git");
284 if allowed_types.contains(git2::CredentialType::USERNAME) {
285 git2::Cred::username(username)
286 } else if allowed_types.contains(git2::CredentialType::SSH_KEY) && !agent_offered {
287 agent_offered = true;
288 git2::Cred::ssh_key_from_agent(username)
289 } else {
290 Err(git2::Error::from_str(
291 "SSH authentication failed: no usable identity found in the SSH agent",
292 ))
293 }
294 });
295 let mut proxy_opts = ProxyOptions::new();
297 proxy_opts.auto();
298 let mut fetch_options = FetchOptions::new();
299 fetch_options.proxy_options(proxy_opts);
300 fetch_options.remote_callbacks(callbacks);
301 fetch_options.depth(1);
302 if let Some(repo) = repo {
304 tracing::info!("Fetching latest tldr changes ...");
305 send_progress(RepoStatus::Fetching);
306
307 let current_url = repo.find_remote("origin")?.url().map(|s| s.to_owned());
310 if current_url.as_deref() != Some(repo_url.as_str()) {
311 repo.remote_set_url("origin", &repo_url)
312 .wrap_err("Failed to update remote URL")?;
313 }
314
315 let mut remote = repo.find_remote("origin")?;
317
318 let refspec = format!("refs/heads/{BRANCH}:refs/remotes/origin/{BRANCH}");
320 if let Err(err) = remote.fetch(&[refspec], Some(&mut fetch_options), None) {
321 if err.code() == git2::ErrorCode::User && err.class() == git2::ErrorClass::Callback {
323 return Err(UserFacingError::Cancelled.into());
324 }
325 return Err(Report::from(err).wrap_err("Failed to fetch from tldr remote").into());
326 }
327
328 let fetch_head = repo.find_reference("FETCH_HEAD")?;
330 let fetch_commit_oid = fetch_head
331 .target()
332 .ok_or_else(|| eyre!("FETCH_HEAD is not a direct reference"))?;
333
334 let local_ref_name = format!("refs/heads/{BRANCH}");
336 let local_commit_oid = repo.find_reference(&local_ref_name)?.target();
337
338 if Some(fetch_commit_oid) == local_commit_oid {
340 tracing::info!("Repository is already up-to-date");
341 send_progress(RepoStatus::UpToDate);
342 return Ok(false);
343 }
344
345 tracing::info!("Updating to the latest version ...");
346 send_progress(RepoStatus::Updating);
347
348 let mut local_ref = repo.find_reference(&local_ref_name)?;
350 let msg = format!("Resetting to latest commit {fetch_commit_oid}");
352 local_ref.set_target(fetch_commit_oid, &msg)?;
353
354 repo.set_head(&local_ref_name)?;
356
357 let mut checkout_builder = CheckoutBuilder::new();
359 checkout_builder.force();
360 repo.checkout_head(Some(&mut checkout_builder))?;
361
362 tracing::info!("Repository successfully updated");
363 send_progress(RepoStatus::DoneUpdating);
364 Ok(true)
365 } else {
366 tracing::info!("Performing a shallow clone of '{repo_url}' ...");
367 send_progress(RepoStatus::Cloning);
368
369 if let Err(err) = RepoBuilder::new()
371 .branch(BRANCH)
372 .fetch_options(fetch_options)
373 .clone(&repo_url, &tldr_repo_path)
374 {
375 if err.code() == git2::ErrorCode::User && err.class() == git2::ErrorClass::Callback {
376 return Err(UserFacingError::Cancelled.into());
377 }
378 return Err(Report::from(err).wrap_err("Failed to clone tldr repository").into());
379 }
380
381 tracing::info!("Repository successfully cloned");
382 send_progress(RepoStatus::DoneCloning);
383 Ok(true)
384 }
385 })
386 .await
387 .wrap_err("tldr repository task failed")?
388 }
389}