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#[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().ok().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().ok().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 = local_branch_target(&repo, &local_ref_name)?;
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 msg = format!("Resetting to latest commit {fetch_commit_oid}");
351 update_local_branch(&repo, &local_ref_name, fetch_commit_oid, &msg)?;
352
353 repo.set_head(&local_ref_name)?;
355
356 let mut checkout_builder = CheckoutBuilder::new();
358 checkout_builder.force();
359 repo.checkout_head(Some(&mut checkout_builder))?;
360
361 tracing::info!("Repository successfully updated");
362 send_progress(RepoStatus::DoneUpdating);
363 Ok(true)
364 } else {
365 tracing::info!("Performing a shallow clone of '{repo_url}' ...");
366 send_progress(RepoStatus::Cloning);
367
368 if let Err(err) = RepoBuilder::new()
370 .branch(BRANCH)
371 .fetch_options(fetch_options)
372 .clone(&repo_url, &tldr_repo_path)
373 {
374 if err.code() == git2::ErrorCode::User && err.class() == git2::ErrorClass::Callback {
375 return Err(UserFacingError::Cancelled.into());
376 }
377 return Err(Report::from(err).wrap_err("Failed to clone tldr repository").into());
378 }
379
380 tracing::info!("Repository successfully cloned");
381 send_progress(RepoStatus::DoneCloning);
382 Ok(true)
383 }
384 })
385 .await
386 .wrap_err("tldr repository task failed")?
387 }
388}
389
390fn update_local_branch(
391 repo: &Repository,
392 local_ref_name: &str,
393 commit_oid: git2::Oid,
394 message: &str,
395) -> std::result::Result<(), git2::Error> {
396 repo.reference(local_ref_name, commit_oid, true, message)?;
397 Ok(())
398}
399
400fn local_branch_target(repo: &Repository, local_ref_name: &str) -> std::result::Result<Option<git2::Oid>, git2::Error> {
401 match repo.find_reference(local_ref_name) {
402 Ok(local_ref) => Ok(local_ref.target()),
403 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
404 Err(err) => Err(err),
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use std::{error::Error, fs};
411
412 use git2::Repository;
413 use uuid::Uuid;
414
415 use super::{local_branch_target, update_local_branch};
416
417 #[test]
418 fn setup_can_create_a_missing_local_main_branch() -> Result<(), Box<dyn Error>> {
419 let path = std::env::temp_dir().join(format!("intelli-shell-tldr-{}", Uuid::now_v7()));
420 let repo = Repository::init(&path)?;
421 let blob_oid = repo.blob(b"tldr test")?;
422 let mut tree_builder = repo.treebuilder(None)?;
423 tree_builder.insert("test.md", blob_oid, 0o100644)?;
424 let tree_oid = tree_builder.write()?;
425 drop(tree_builder);
426 let tree = repo.find_tree(tree_oid)?;
427 let signature = git2::Signature::now("Test", "test@example.invalid")?;
428 let commit_oid = repo.commit(None, &signature, &signature, "test commit", &tree, &[])?;
429 drop(tree);
430 let branch_name = "refs/heads/main";
431
432 assert_eq!(local_branch_target(&repo, branch_name)?, None);
433
434 update_local_branch(&repo, branch_name, commit_oid, "test branch creation")?;
435 assert_eq!(local_branch_target(&repo, branch_name)?, Some(commit_oid));
436 repo.set_head(branch_name)?;
437 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
438 assert_eq!(fs::read(path.join("test.md"))?, b"tldr test");
439
440 drop(repo);
441 fs::remove_dir_all(path)?;
442 Ok(())
443 }
444}