intelli_shell/service/
tldr.rs1use color_eyre::{
2 Report,
3 eyre::{Context, OptionExt, eyre},
4};
5use futures_util::{StreamExt, TryStreamExt, stream};
6use git2::{
7 FetchOptions, ProxyOptions, Repository,
8 build::{CheckoutBuilder, RepoBuilder},
9};
10use tokio::{fs::File, sync::mpsc};
11use tracing::instrument;
12use walkdir::WalkDir;
13
14use super::{IntelliShellService, import::parse_import_items};
15use crate::{
16 errors::Result,
17 model::{ImportStats, SOURCE_TLDR},
18};
19
20#[derive(Debug)]
22pub enum TldrFetchProgress {
23 Repository(RepoStatus),
25 LocatingFiles,
27 FilesLocated(u64),
29 ProcessingStart(u64),
31 ProcessingFile(String),
33 FileProcessed(String),
35}
36
37#[derive(Debug)]
39pub enum RepoStatus {
40 Cloning,
42 DoneCloning,
44 Fetching,
46 UpToDate,
48 Updating,
50 DoneUpdating,
52}
53
54impl IntelliShellService {
55 #[instrument(skip_all)]
59 pub async fn clear_tldr_commands(&self, category: Option<String>) -> Result<u64> {
60 self.storage.delete_tldr_commands(category).await
61 }
62
63 #[instrument(skip_all)]
65 pub async fn fetch_tldr_commands(
66 &self,
67 category: Option<String>,
68 commands: Vec<String>,
69 progress: mpsc::Sender<TldrFetchProgress>,
70 ) -> Result<ImportStats> {
71 self.setup_tldr_repo(progress.clone()).await?;
73
74 let categories = if let Some(cat) = category {
76 vec![cat]
77 } else {
78 vec![
79 "common".to_owned(),
80 #[cfg(target_os = "windows")]
81 "windows".to_owned(),
82 #[cfg(target_os = "android")]
83 "android".to_owned(),
84 #[cfg(target_os = "macos")]
85 "osx".to_owned(),
86 #[cfg(target_os = "freebsd")]
87 "freebsd".to_owned(),
88 #[cfg(target_os = "openbsd")]
89 "openbsd".to_owned(),
90 #[cfg(target_os = "netbsd")]
91 "netbsd".to_owned(),
92 #[cfg(any(
93 target_os = "linux",
94 target_os = "freebsd",
95 target_os = "openbsd",
96 target_os = "netbsd",
97 target_os = "dragonfly",
98 ))]
99 "linux".to_owned(),
100 ]
101 };
102
103 let pages_path = self.tldr_repo_path.join("pages");
105
106 tracing::info!("Locating files for categories: {}", categories.join(", "));
107 progress.send(TldrFetchProgress::LocatingFiles).await.ok();
108
109 let mut command_files = Vec::new();
111 let mut iter = WalkDir::new(&pages_path).max_depth(2).into_iter();
112 while let Some(result) = iter.next() {
113 let entry = result.wrap_err("Couldn't read tldr repository files")?;
114 let path = entry.path();
115
116 if path == pages_path {
118 continue;
119 }
120
121 let file_name = entry.file_name().to_str().ok_or_eyre("Non valid file name")?;
123 if entry.file_type().is_dir() {
124 if !categories.iter().any(|c| c == file_name) {
125 tracing::trace!("Skipped directory: {file_name}");
126 iter.skip_current_dir();
127 continue;
128 } else {
129 continue;
131 }
132 }
133
134 let Some(file_name_no_ext) = file_name.strip_suffix(".md") else {
136 tracing::warn!("Unexpected file found: {}", path.display());
137 continue;
138 };
139
140 if !commands.is_empty() {
142 if !commands.iter().any(|c| c == file_name_no_ext) {
143 continue;
144 } else {
145 tracing::trace!("Included command: {file_name_no_ext}");
146 }
147 }
148
149 let category = path
151 .parent()
152 .and_then(|p| p.file_name())
153 .and_then(|p| p.to_str())
154 .ok_or_eyre("Couldn't read tldr category")?
155 .to_owned();
156
157 command_files.push((path.to_path_buf(), category, file_name_no_ext.to_owned()));
159 }
160
161 progress
162 .send(TldrFetchProgress::FilesLocated(command_files.len() as u64))
163 .await
164 .ok();
165
166 tracing::info!("Found {} files to be processed", command_files.len());
167
168 progress
169 .send(TldrFetchProgress::ProcessingStart(command_files.len() as u64))
170 .await
171 .ok();
172
173 let items_stream = stream::iter(command_files)
175 .map(move |(path, category, command)| {
176 let progress = progress.clone();
177 async move {
178 progress
179 .send(TldrFetchProgress::ProcessingFile(command.clone()))
180 .await
181 .ok();
182
183 let file = File::open(&path)
185 .await
186 .wrap_err_with(|| format!("Failed to open tldr file: {}", path.display()))?;
187 let stream = parse_import_items(file, vec![], category, SOURCE_TLDR);
188
189 progress.send(TldrFetchProgress::FileProcessed(command)).await.ok();
190 Ok::<_, Report>(stream)
191 }
192 })
193 .buffered(5)
194 .try_flatten();
195
196 self.storage.import_items(items_stream, true, false).await
198 }
199
200 #[instrument(skip_all)]
201 async fn setup_tldr_repo(&self, progress: mpsc::Sender<TldrFetchProgress>) -> Result<bool> {
202 const BRANCH: &str = "main";
203 const REPO_URL: &str = "https://github.com/tldr-pages/tldr.git";
204
205 let tldr_repo_path = self.tldr_repo_path.clone();
206
207 tokio::task::spawn_blocking(move || {
208 let send_progress = |status| {
210 progress.blocking_send(TldrFetchProgress::Repository(status)).ok();
212 };
213 let mut proxy_opts = ProxyOptions::new();
215 proxy_opts.auto();
216 let mut fetch_options = FetchOptions::new();
217 fetch_options.proxy_options(proxy_opts);
218 fetch_options.depth(1);
219 if tldr_repo_path.exists() {
221 tracing::info!("Fetching latest tldr changes ...");
222 send_progress(RepoStatus::Fetching);
223
224 let repo = Repository::open(&tldr_repo_path).wrap_err("Failed to open existing tldr repository")?;
226
227 let mut remote = repo.find_remote("origin")?;
229
230 let refspec = format!("refs/heads/{BRANCH}:refs/remotes/origin/{BRANCH}");
232 remote
233 .fetch(&[refspec], Some(&mut fetch_options), None)
234 .wrap_err("Failed to fetch from tldr remote")?;
235
236 let fetch_head = repo.find_reference("FETCH_HEAD")?;
238 let fetch_commit_oid = fetch_head
239 .target()
240 .ok_or_else(|| eyre!("FETCH_HEAD is not a direct reference"))?;
241
242 let local_ref_name = format!("refs/heads/{BRANCH}");
244 let local_commit_oid = repo.find_reference(&local_ref_name)?.target();
245
246 if Some(fetch_commit_oid) == local_commit_oid {
248 tracing::info!("Repository is already up-to-date");
249 send_progress(RepoStatus::UpToDate);
250 return Ok(false);
251 }
252
253 tracing::info!("Updating to the latest version ...");
254 send_progress(RepoStatus::Updating);
255
256 let mut local_ref = repo.find_reference(&local_ref_name)?;
258 let msg = format!("Resetting to latest commit {fetch_commit_oid}");
260 local_ref.set_target(fetch_commit_oid, &msg)?;
261
262 repo.set_head(&local_ref_name)?;
264
265 let mut checkout_builder = CheckoutBuilder::new();
267 checkout_builder.force();
268 repo.checkout_head(Some(&mut checkout_builder))?;
269
270 tracing::info!("Repository successfully updated");
271 send_progress(RepoStatus::DoneUpdating);
272 Ok(true)
273 } else {
274 tracing::info!("Performing a shallow clone of '{REPO_URL}' ...");
275 send_progress(RepoStatus::Cloning);
276
277 RepoBuilder::new()
279 .branch(BRANCH)
280 .fetch_options(fetch_options)
281 .clone(REPO_URL, &tldr_repo_path)
282 .wrap_err("Failed to clone tldr repository")?;
283
284 tracing::info!("Repository successfully cloned");
285 send_progress(RepoStatus::DoneCloning);
286 Ok(true)
287 }
288 })
289 .await
290 .wrap_err("tldr repository task failed")?
291 }
292}