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