Skip to main content

intelli_shell/service/
tldr.rs

1use 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/// Progress events for the `tldr fetch` operation
22#[derive(Debug)]
23pub enum TldrFetchProgress {
24    /// Indicates the status of the tldr git repository
25    Repository(RepoStatus),
26    /// Indicates that the tldr command files are being located
27    LocatingFiles,
28    /// Indicates that the tldr command files have been located
29    FilesLocated(u64),
30    /// Indicates the start of the file processing stage
31    ProcessingStart(u64),
32    /// Indicates that a single file is being processed
33    ProcessingFile(String),
34    /// Indicates that a single file has been processed
35    FileProcessed(String),
36}
37
38/// The status of the tldr git repository
39#[derive(Debug)]
40pub enum RepoStatus {
41    /// Cloning the repository for the first time
42    Cloning,
43    /// The repository has been successfully cloned
44    DoneCloning,
45    /// Fetching latest changes
46    Fetching,
47    /// The repository is already up-to-date
48    UpToDate,
49    /// Updating the local repository
50    Updating,
51    /// The repository has been successfully updated
52    DoneUpdating,
53}
54
55impl IntelliShellService {
56    /// Removes tldr commands matching the given criteria.
57    ///
58    /// Returns the number of commands removed
59    #[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    /// Fetches and imports tldr commands matching the given criteria
65    #[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        // Check for cancellation at the beginning
75        if cancellation_token.is_cancelled() {
76            tracing::info!("TLDR fetch cancelled before starting");
77            return Err(UserFacingError::Cancelled.into());
78        }
79
80        // Setup repository
81        self.setup_tldr_repo(connection_mode, progress.clone(), cancellation_token.clone())
82            .await?;
83
84        // Determine which categories to import
85        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        // Construct the path to the tldr pages directory
114        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        // Iterate over directory entries
120        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            // Check for cancellation within the file discovery loop
124            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            // Skip base path
133            if path == pages_path {
134                continue;
135            }
136
137            // Skip non-included categories
138            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                    // The directory entry itself must be skipped as well, we only care about files
146                    continue;
147                }
148            }
149
150            // We only care about markdown files
151            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            // Skip non-included commands
157            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            // Retrieve the category
166            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            // Include the command file
174            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        // Create a stream that reads and parses each command file concurrently
190        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                    // Open and parse the file
200                    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        // Import items while the token is not cancelled
213        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        // After processing, check if cancellation was the reason the stream ended
223        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            // Helper to send progress to the channel
246            let send_progress = |status| {
247                // Use blocking_send as we are in a sync context
248                progress.blocking_send(TldrFetchProgress::Repository(status)).ok();
249            };
250
251            // Open the repository upfront when it already exists, so the connection mode can be resolved from it
252            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            // Resolve the effective repository URL from the requested connection mode:
259            // - `Https`/`Ssh` force the matching transport
260            // - `Auto` reuses the transport of an existing clone (this also honors git `insteadOf` rewrites), falling
261            //   back to HTTPS for a fresh clone
262            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            // Setup git callbacks to enable cancellation
272            let mut callbacks = RemoteCallbacks::new();
273            callbacks.transfer_progress(move |_| !cancellation_token.is_cancelled());
274            // Always register the SSH credentials callback: libgit2 only invokes it when the resolved transport
275            // is SSH, which can happen via an explicit `ssh` mode, a reused SSH remote, or a git `insteadOf`
276            // rewrite of an HTTPS URL (so sniffing the configured scheme isn't reliable). SSH auth is negotiated
277            // in steps: libgit2 first asks for the username (when it isn't part of the URL), then for the
278            // credentials, so the returned credential type must match the requested `allowed_types`, otherwise
279            // git2 passes through and libgit2 reports it as a missing callback. The agent is only offered once to
280            // avoid looping forever when authentication fails.
281            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            // Setup git fetch options for a swallow copy with auto proxy config
296            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            // Fetch latest repo changes or clone it if it doesn't exist yet
303            if let Some(repo) = repo {
304                tracing::info!("Fetching latest tldr changes ...");
305                send_progress(RepoStatus::Fetching);
306
307                // Update 'origin' URL if it doesn't match `repo_url` (no-op in `Auto` mode, which already
308                // derives the URL from the existing remote)
309                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                // Get the 'origin' remote
316                let mut remote = repo.find_remote("origin")?;
317
318                // Fetch the latest changes from the remote 'main' branch
319                let refspec = format!("refs/heads/{BRANCH}:refs/remotes/origin/{BRANCH}");
320                if let Err(err) = remote.fetch(&[refspec], Some(&mut fetch_options), None) {
321                    // Check if the error was a user-initiated cancellation
322                    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                // Get the commit OID from the fetched data (FETCH_HEAD)
329                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                // Get the OID of the current commit on the local branch
335                let local_ref_name = format!("refs/heads/{BRANCH}");
336                let local_commit_oid = repo.find_reference(&local_ref_name)?.target();
337
338                // If the commit OIDs are the same, the repo is already up-to-date
339                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                // Find the local branch reference
349                let mut local_ref = repo.find_reference(&local_ref_name)?;
350                // Update the local branch to point directly to the newly fetched commit
351                let msg = format!("Resetting to latest commit {fetch_commit_oid}");
352                local_ref.set_target(fetch_commit_oid, &msg)?;
353
354                // Point HEAD to the updated local branch
355                repo.set_head(&local_ref_name)?;
356
357                // Checkout the new HEAD to update the files in the working directory
358                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                // Clone the repository
370                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}