1use std::{
4 collections::BTreeMap,
5 env,
6 error::Error,
7 ffi::{OsStr, OsString},
8 fmt, fs, io,
9 path::{Path, PathBuf},
10 process::{self, Command},
11 sync::atomic::{AtomicU64, Ordering},
12};
13
14use mant_ast::{TldrCacheAction, TldrCacheUpdate};
15
16use crate::{
17 executable::{environment_value, find_executable},
18 source::CommandOutput,
19};
20
21use super::cache::{HostPlatform, TldrCacheError, get_tldr_cache_dir};
22
23const DEFAULT_REPOSITORY: &str = "https://github.com/tldr-pages/tldr.git";
24static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
25
26#[derive(Debug)]
28pub enum TldrUpdateError {
29 Cache(TldrCacheError),
30 NoUpdater,
31 InvalidCheckout(PathBuf),
32 CommandUnavailable {
33 program: PathBuf,
34 source: io::Error,
35 },
36 CommandFailed {
37 command: String,
38 exit_code: i32,
39 detail: Option<String>,
40 },
41 FileOperation {
42 action: &'static str,
43 path: PathBuf,
44 source: io::Error,
45 },
46}
47
48impl fmt::Display for TldrUpdateError {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 Self::Cache(error) => error.fmt(formatter),
52 Self::NoUpdater => {
53 formatter.write_str("cannot update tldr pages: install a 'tldr' client or git")
54 }
55 Self::InvalidCheckout(path) => write!(
56 formatter,
57 "{} exists but is not a tldr git checkout",
58 path.display()
59 ),
60 Self::CommandUnavailable { program, source } => {
61 write!(formatter, "cannot run {}: {source}", program.display())
62 }
63 Self::CommandFailed {
64 command,
65 exit_code,
66 detail,
67 } => {
68 if let Some(detail) = detail {
69 formatter.write_str(detail)
70 } else {
71 write!(formatter, "{command} failed with code {exit_code}")
72 }
73 }
74 Self::FileOperation {
75 action,
76 path,
77 source,
78 } => write!(formatter, "cannot {action} {}: {source}", path.display()),
79 }
80 }
81}
82
83impl Error for TldrUpdateError {
84 fn source(&self) -> Option<&(dyn Error + 'static)> {
85 match self {
86 Self::Cache(error) => Some(error),
87 Self::CommandUnavailable { source, .. } | Self::FileOperation { source, .. } => {
88 Some(source)
89 }
90 Self::NoUpdater | Self::InvalidCheckout(_) | Self::CommandFailed { .. } => None,
91 }
92 }
93}
94
95impl From<TldrCacheError> for TldrUpdateError {
96 fn from(error: TldrCacheError) -> Self {
97 Self::Cache(error)
98 }
99}
100
101pub fn update_tldr_cache() -> Result<TldrCacheUpdate, TldrUpdateError> {
108 let environment = env::vars().collect::<BTreeMap<_, _>>();
109 update_tldr_cache_with(
110 &environment,
111 HostPlatform::current()?,
112 DEFAULT_REPOSITORY,
113 &SystemUpdateHost,
114 )
115}
116
117trait TldrUpdateHost {
118 fn find_executable(
119 &self,
120 name: &str,
121 environment: &BTreeMap<String, String>,
122 ) -> Option<PathBuf>;
123 fn exists(&self, path: &Path) -> bool;
124 fn create_dir_all(&self, path: &Path) -> io::Result<()>;
125 fn make_temp_dir(&self, prefix: &Path) -> io::Result<PathBuf>;
126 fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
127 fn remove_dir_all(&self, path: &Path) -> io::Result<()>;
128 fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput>;
129}
130
131struct SystemUpdateHost;
132
133impl TldrUpdateHost for SystemUpdateHost {
134 fn find_executable(
135 &self,
136 name: &str,
137 environment: &BTreeMap<String, String>,
138 ) -> Option<PathBuf> {
139 find_executable(name, environment)
140 }
141
142 fn exists(&self, path: &Path) -> bool {
143 path.exists()
144 }
145
146 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
147 fs::create_dir_all(path)
148 }
149
150 fn make_temp_dir(&self, prefix: &Path) -> io::Result<PathBuf> {
151 let parent = prefix.parent().unwrap_or_else(|| Path::new("."));
152 let name = prefix
153 .file_name()
154 .unwrap_or_else(|| OsStr::new("tldr-pages.tmp-"))
155 .to_string_lossy();
156 for _ in 0..100 {
157 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
158 let candidate = parent.join(format!("{name}{}-{sequence}", process::id()));
159 match fs::create_dir(&candidate) {
160 Ok(()) => return Ok(candidate),
161 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
162 Err(error) => return Err(error),
163 }
164 }
165 Err(io::Error::new(
166 io::ErrorKind::AlreadyExists,
167 "could not allocate a unique temporary tldr directory",
168 ))
169 }
170
171 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
172 fs::rename(from, to)
173 }
174
175 fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
176 fs::remove_dir_all(path)
177 }
178
179 fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput> {
180 let output = platform_command(program, arguments).output()?;
181 Ok(CommandOutput {
182 stdout: output.stdout,
183 stderr: output.stderr,
184 exit_code: output.status.code().unwrap_or(-1),
185 })
186 }
187}
188
189fn platform_command(program: &OsStr, arguments: &[OsString]) -> Command {
190 #[cfg(windows)]
191 {
192 let extension = Path::new(program)
193 .extension()
194 .and_then(OsStr::to_str)
195 .unwrap_or_default();
196 if extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") {
197 let mut command =
198 Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
199 command.arg("/D").arg("/C").arg(program).args(arguments);
200 return command;
201 }
202 }
203 let mut command = Command::new(program);
204 command.args(arguments);
205 command
206}
207
208fn update_tldr_cache_with(
209 environment: &BTreeMap<String, String>,
210 platform: HostPlatform,
211 repository: &str,
212 host: &dyn TldrUpdateHost,
213) -> Result<TldrCacheUpdate, TldrUpdateError> {
214 if environment_value(environment, "MANT_TLDR_DIR").is_none()
215 && let Some(client) = host.find_executable("tldr", environment)
216 {
217 let output = run_checked(host, &client, &[OsString::from("--update")])?;
218 let rendered_output = combined_output(&output);
219 return Ok(TldrCacheUpdate {
220 action: TldrCacheAction::Updated,
221 cache_dir: None,
222 client: Some(client.to_string_lossy().into_owned()),
223 output: (!rendered_output.is_empty()).then_some(rendered_output),
224 revision: None,
225 });
226 }
227
228 let git = host
229 .find_executable("git", environment)
230 .ok_or(TldrUpdateError::NoUpdater)?;
231 let target = get_tldr_cache_dir(environment, platform)?;
232 let action = if host.exists(&target) {
233 if !host.exists(&target.join(".git")) {
234 return Err(TldrUpdateError::InvalidCheckout(target));
235 }
236 run_checked(
237 host,
238 &git,
239 &[
240 OsString::from("-C"),
241 target.as_os_str().to_owned(),
242 OsString::from("pull"),
243 OsString::from("--ff-only"),
244 ],
245 )?;
246 TldrCacheAction::Updated
247 } else {
248 clone_cache(host, &git, repository, &target)?;
249 TldrCacheAction::Cloned
250 };
251
252 let revision = host
253 .run(
254 git.as_os_str(),
255 &[
256 OsString::from("-C"),
257 target.as_os_str().to_owned(),
258 OsString::from("rev-parse"),
259 OsString::from("--short"),
260 OsString::from("HEAD"),
261 ],
262 )
263 .ok()
264 .filter(|output| output.exit_code == 0)
265 .and_then(|output| first_nonempty_line(&output.stdout));
266
267 Ok(TldrCacheUpdate {
268 action,
269 cache_dir: Some(target.to_string_lossy().into_owned()),
270 client: None,
271 output: None,
272 revision,
273 })
274}
275
276fn clone_cache(
277 host: &dyn TldrUpdateHost,
278 git: &Path,
279 repository: &str,
280 target: &Path,
281) -> Result<(), TldrUpdateError> {
282 let parent = target.parent().unwrap_or_else(|| Path::new("."));
283 host.create_dir_all(parent)
284 .map_err(|source| TldrUpdateError::FileOperation {
285 action: "create directory",
286 path: parent.to_owned(),
287 source,
288 })?;
289 let prefix = parent.join(format!(
290 "{}.tmp-",
291 target
292 .file_name()
293 .unwrap_or_else(|| OsStr::new("tldr-pages"))
294 .to_string_lossy()
295 ));
296 let temporary =
297 host.make_temp_dir(&prefix)
298 .map_err(|source| TldrUpdateError::FileOperation {
299 action: "create temporary directory",
300 path: prefix,
301 source,
302 })?;
303 let clone_result = run_checked(
304 host,
305 git,
306 &[
307 OsString::from("clone"),
308 OsString::from("--depth=1"),
309 OsString::from("--single-branch"),
310 OsString::from("--branch"),
311 OsString::from("main"),
312 OsString::from(repository),
313 temporary.as_os_str().to_owned(),
314 ],
315 )
316 .and_then(|_| {
317 host.rename(&temporary, target)
318 .map_err(|source| TldrUpdateError::FileOperation {
319 action: "move completed tldr checkout to",
320 path: target.to_owned(),
321 source,
322 })
323 });
324 if let Err(error) = clone_result {
325 let _ = host.remove_dir_all(&temporary);
326 return Err(error);
327 }
328 Ok(())
329}
330
331fn run_checked(
332 host: &dyn TldrUpdateHost,
333 program: &Path,
334 arguments: &[OsString],
335) -> Result<CommandOutput, TldrUpdateError> {
336 let output = host.run(program.as_os_str(), arguments).map_err(|source| {
337 TldrUpdateError::CommandUnavailable {
338 program: program.to_owned(),
339 source,
340 }
341 })?;
342 if output.exit_code == 0 {
343 return Ok(output);
344 }
345 let mut command = vec![program.to_string_lossy().into_owned()];
346 command.extend(
347 arguments
348 .iter()
349 .map(|argument| argument.to_string_lossy().into_owned()),
350 );
351 Err(TldrUpdateError::CommandFailed {
352 command: command.join(" "),
353 exit_code: output.exit_code,
354 detail: first_nonempty_line(&output.stderr),
355 })
356}
357
358fn combined_output(output: &CommandOutput) -> String {
359 [output.stdout.as_slice(), output.stderr.as_slice()]
360 .into_iter()
361 .filter_map(first_nonempty_text)
362 .collect::<Vec<_>>()
363 .join("\n")
364}
365
366fn first_nonempty_text(output: &[u8]) -> Option<String> {
367 let value = String::from_utf8_lossy(output).trim().to_owned();
368 (!value.is_empty()).then_some(value)
369}
370
371fn first_nonempty_line(output: &[u8]) -> Option<String> {
372 String::from_utf8_lossy(output)
373 .lines()
374 .map(str::trim)
375 .find(|line| !line.is_empty())
376 .map(ToOwned::to_owned)
377}
378
379#[cfg(test)]
380mod tests {
381 use std::{
382 collections::{BTreeMap, HashMap, HashSet, VecDeque},
383 ffi::{OsStr, OsString},
384 io,
385 path::{Path, PathBuf},
386 sync::Mutex,
387 };
388
389 use mant_ast::{TldrCacheAction, TldrCacheUpdate};
390
391 use crate::source::CommandOutput;
392
393 use super::{HostPlatform, TldrUpdateError, TldrUpdateHost, update_tldr_cache_with};
394
395 type Call = (PathBuf, Vec<OsString>);
396
397 struct StubHost {
398 executables: HashMap<String, PathBuf>,
399 existing: HashSet<PathBuf>,
400 outputs: Mutex<VecDeque<io::Result<CommandOutput>>>,
401 calls: Mutex<Vec<Call>>,
402 created: Mutex<Vec<PathBuf>>,
403 temporary: PathBuf,
404 renames: Mutex<Vec<(PathBuf, PathBuf)>>,
405 removals: Mutex<Vec<PathBuf>>,
406 cleanup_error: bool,
407 }
408
409 impl StubHost {
410 fn new(outputs: Vec<CommandOutput>) -> Self {
411 Self {
412 executables: HashMap::new(),
413 existing: HashSet::new(),
414 outputs: Mutex::new(outputs.into_iter().map(Ok).collect()),
415 calls: Mutex::new(Vec::new()),
416 created: Mutex::new(Vec::new()),
417 temporary: PathBuf::from("/cache/mant/tldr-pages.tmp-1"),
418 renames: Mutex::new(Vec::new()),
419 removals: Mutex::new(Vec::new()),
420 cleanup_error: false,
421 }
422 }
423 }
424
425 impl TldrUpdateHost for StubHost {
426 fn find_executable(
427 &self,
428 name: &str,
429 _environment: &BTreeMap<String, String>,
430 ) -> Option<PathBuf> {
431 self.executables.get(name).cloned()
432 }
433
434 fn exists(&self, path: &Path) -> bool {
435 self.existing.contains(path)
436 }
437
438 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
439 self.created
440 .lock()
441 .expect("created paths lock")
442 .push(path.to_owned());
443 Ok(())
444 }
445
446 fn make_temp_dir(&self, _prefix: &Path) -> io::Result<PathBuf> {
447 Ok(self.temporary.clone())
448 }
449
450 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
451 self.renames
452 .lock()
453 .expect("rename calls lock")
454 .push((from.to_owned(), to.to_owned()));
455 Ok(())
456 }
457
458 fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
459 self.removals
460 .lock()
461 .expect("removal calls lock")
462 .push(path.to_owned());
463 if self.cleanup_error {
464 Err(io::Error::other("cleanup failed"))
465 } else {
466 Ok(())
467 }
468 }
469
470 fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput> {
471 self.calls
472 .lock()
473 .expect("command calls lock")
474 .push((PathBuf::from(program), arguments.to_vec()));
475 self.outputs
476 .lock()
477 .expect("command outputs lock")
478 .pop_front()
479 .unwrap_or_else(|| Ok(CommandOutput::default()))
480 }
481 }
482
483 fn environment(values: &[(&str, &str)]) -> BTreeMap<String, String> {
484 values
485 .iter()
486 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
487 .collect()
488 }
489
490 fn success(stdout: &str) -> CommandOutput {
491 CommandOutput {
492 stdout: stdout.as_bytes().to_vec(),
493 stderr: Vec::new(),
494 exit_code: 0,
495 }
496 }
497
498 #[test]
499 fn installed_client_owns_its_update() {
500 let mut host = StubHost::new(vec![success("Updated cache for language en\n")]);
501 host.executables
502 .insert("tldr".to_owned(), PathBuf::from("/usr/bin/tldr"));
503
504 let result = update_tldr_cache_with(
505 &environment(&[("HOME", "/home/test")]),
506 HostPlatform::Linux,
507 "unused",
508 &host,
509 )
510 .expect("client update");
511
512 assert_eq!(
513 result,
514 TldrCacheUpdate {
515 action: TldrCacheAction::Updated,
516 cache_dir: None,
517 client: Some("/usr/bin/tldr".to_owned()),
518 output: Some("Updated cache for language en".to_owned()),
519 revision: None,
520 }
521 );
522 assert_eq!(
523 *host.calls.lock().expect("calls lock"),
524 [(
525 PathBuf::from("/usr/bin/tldr"),
526 vec![OsString::from("--update")]
527 )]
528 );
529 }
530
531 #[test]
532 fn installed_client_failure_uses_its_diagnostic() {
533 let mut host = StubHost::new(vec![CommandOutput {
534 stdout: Vec::new(),
535 stderr: b"Unable to update cache\n".to_vec(),
536 exit_code: 1,
537 }]);
538 host.executables
539 .insert("tldr".to_owned(), PathBuf::from("/usr/bin/tldr"));
540
541 let error = update_tldr_cache_with(
542 &environment(&[("HOME", "/home/test")]),
543 HostPlatform::Linux,
544 "unused",
545 &host,
546 )
547 .expect_err("client update must fail");
548
549 assert_eq!(error.to_string(), "Unable to update cache");
550 }
551
552 #[test]
553 fn clones_transactionally_then_reports_revision() {
554 let mut host = StubHost::new(vec![success(""), success("abc123\n")]);
555 host.executables
556 .insert("git".to_owned(), PathBuf::from("/usr/bin/git"));
557
558 let result = update_tldr_cache_with(
559 &environment(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]),
560 HostPlatform::Linux,
561 "https://example.test/tldr.git",
562 &host,
563 )
564 .expect("clone cache");
565
566 let expected_cache = PathBuf::from("/cache").join("mant").join("tldr-pages");
567 assert_eq!(result.action, TldrCacheAction::Cloned);
568 assert_eq!(
569 result.cache_dir.as_deref().map(Path::new),
570 Some(expected_cache.as_path())
571 );
572 assert_eq!(result.revision.as_deref(), Some("abc123"));
573 assert_eq!(
574 *host.created.lock().expect("created lock"),
575 [PathBuf::from("/cache/mant")]
576 );
577 assert_eq!(
578 *host.renames.lock().expect("renames lock"),
579 [(
580 PathBuf::from("/cache/mant/tldr-pages.tmp-1"),
581 PathBuf::from("/cache/mant/tldr-pages")
582 )]
583 );
584 let calls = host.calls.lock().expect("calls lock");
585 assert_eq!(calls[0].1[0], "clone");
586 assert_eq!(calls[0].1[5], "https://example.test/tldr.git");
587 }
588
589 #[test]
590 fn explicit_checkout_updates_without_using_installed_client() {
591 let target = PathBuf::from("/custom/tldr");
592 let mut host = StubHost::new(vec![success(""), success("def456\n")]);
593 host.executables
594 .insert("tldr".to_owned(), PathBuf::from("/usr/bin/tldr"));
595 host.executables
596 .insert("git".to_owned(), PathBuf::from("/usr/bin/git"));
597 host.existing.extend([target.clone(), target.join(".git")]);
598
599 let result = update_tldr_cache_with(
600 &environment(&[("HOME", "/home/test"), ("MANT_TLDR_DIR", "/custom/tldr")]),
601 HostPlatform::Linux,
602 "unused",
603 &host,
604 )
605 .expect("pull cache");
606
607 assert_eq!(result.action, TldrCacheAction::Updated);
608 let calls = host.calls.lock().expect("calls lock");
609 assert_eq!(
610 calls[0].1,
611 ["-C", "/custom/tldr", "pull", "--ff-only"].map(OsString::from)
612 );
613 }
614
615 #[test]
616 fn preserves_clone_failure_even_when_cleanup_fails() {
617 let mut host = StubHost::new(vec![CommandOutput {
618 stdout: Vec::new(),
619 stderr: b"network unavailable\n".to_vec(),
620 exit_code: 128,
621 }]);
622 host.executables
623 .insert("git".to_owned(), PathBuf::from("/usr/bin/git"));
624 host.cleanup_error = true;
625
626 let error = update_tldr_cache_with(
627 &environment(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]),
628 HostPlatform::Linux,
629 "https://example.test/tldr.git",
630 &host,
631 )
632 .expect_err("clone must fail");
633
634 assert!(matches!(error, TldrUpdateError::CommandFailed { .. }));
635 assert_eq!(error.to_string(), "network unavailable");
636 assert_eq!(
637 *host.removals.lock().expect("removals lock"),
638 [PathBuf::from("/cache/mant/tldr-pages.tmp-1")]
639 );
640 }
641
642 #[test]
643 fn rejects_an_existing_non_checkout_before_running_git() {
644 let target = PathBuf::from("/custom/tldr");
645 let mut host = StubHost::new(Vec::new());
646 host.executables
647 .insert("git".to_owned(), PathBuf::from("/usr/bin/git"));
648 host.existing.insert(target);
649
650 let error = update_tldr_cache_with(
651 &environment(&[("MANT_TLDR_DIR", "/custom/tldr")]),
652 HostPlatform::Linux,
653 "unused",
654 &host,
655 )
656 .expect_err("non-checkout must fail");
657
658 assert_eq!(
659 error.to_string(),
660 "/custom/tldr exists but is not a tldr git checkout"
661 );
662 assert!(host.calls.lock().expect("calls lock").is_empty());
663 }
664}