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