Skip to main content

mant_engine/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_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/// Failure to refresh an installed client or `ManT`'s private checkout.
31#[derive(Debug)]
32pub enum TldrUpdateError {
33    /// Cache path discovery failed.
34    Cache(TldrCacheError),
35    /// Neither a supported tldr client nor Git was available.
36    NoUpdater,
37    /// An existing private cache was not the expected Git checkout.
38    InvalidCheckout(PathBuf),
39    /// A selected update executable could not be started.
40    CommandUnavailable {
41        /// Executable path.
42        program: PathBuf,
43        /// Underlying process-spawn failure.
44        source: io::Error,
45    },
46    /// An update command returned an unsuccessful exit status.
47    CommandFailed {
48        /// Human-readable command label.
49        command: String,
50        /// Process exit code.
51        exit_code: i32,
52        /// Useful stderr/stdout detail, when available.
53        detail: Option<String>,
54    },
55    /// A transactional cache filesystem operation failed.
56    FileOperation {
57        /// Operation being attempted.
58        action: &'static str,
59        /// Path associated with the operation.
60        path: PathBuf,
61        /// Underlying filesystem failure.
62        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
119/// Refresh tldr through an installed client or `ManT`'s private Git checkout.
120///
121/// # Errors
122///
123/// Returns [`TldrUpdateError`] when no updater is installed, a subprocess
124/// fails, or the private cache cannot be changed transactionally.
125pub 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}