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