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