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    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/// Failure to refresh an installed client or `ManT`'s private checkout.
27#[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
101/// Refresh tldr through an installed client or `ManT`'s private Git checkout.
102///
103/// # Errors
104///
105/// Returns [`TldrUpdateError`] when no updater is installed, a subprocess
106/// fails, or the private cache cannot be changed transactionally.
107pub 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}