Skip to main content

mant_core/tldr/
update.rs

1//! Performs the explicit, transactional tldr cache update operation.
2
3use 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/// Failure to refresh an installed client or `ManT`'s private checkout.
25#[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
99/// Refresh tldr through an installed client or `ManT`'s private Git checkout.
100///
101/// # Errors
102///
103/// Returns [`TldrUpdateError`] when no updater is installed, a subprocess
104/// fails, or the private cache cannot be changed transactionally.
105pub 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}