├── .gitignore ├── .gitattributes ├── tests ├── data │ ├── option-placeholder.md │ ├── line-wrapping.toml │ ├── line-wrapping.md │ ├── line-wrapping-render │ ├── page-compact-render │ ├── page.md │ └── page-render └── tests.rs ├── rustfmt.toml ├── .github ├── dependabot.yml └── workflows │ ├── gh-pages.yml │ ├── ci.yml │ └── build-release.yml ├── .devcontainer └── devcontainer.json ├── LICENSE ├── completions ├── tldr.bash ├── tldr.fish └── _tldr ├── Cargo.toml ├── src ├── error.rs ├── args.rs ├── main.rs ├── util.rs ├── config.rs ├── output.rs └── cache.rs ├── tldr.1 ├── README.md └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /tests/data/option-placeholder.md: -------------------------------------------------------------------------------- 1 | `foo {{[-s|--long]}}` 2 | -------------------------------------------------------------------------------- /tests/data/line-wrapping.toml: -------------------------------------------------------------------------------- 1 | [output] 2 | line_length = 20 3 | compact = true 4 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # This file is intentionally empty to override any other settings and use the defaults. 2 | -------------------------------------------------------------------------------- /tests/data/line-wrapping.md: -------------------------------------------------------------------------------- 1 | # test 2 | 3 | > This line should get split, because it's too long. 4 | 5 | - This line should get split, because it's too long: 6 | 7 | `long examples should not {{get}} split` 8 | -------------------------------------------------------------------------------- /tests/data/line-wrapping-render: -------------------------------------------------------------------------------- 1 | test 2 | This line should 3 | get split, because 4 | it's too long. 5 | This line should 6 | get split, because 7 | it's too long: 8 | long examples should not get split 9 | -------------------------------------------------------------------------------- /tests/data/page-compact-render: -------------------------------------------------------------------------------- 1 | test page 2 | This is a test page. 3 | More information: https://example.org. 4 | This is a description of a command example: 5 | command --opt1 --opt2 placeholder 6 | Another one: 7 | command --opt1 placeholder1 placeholder2 ... 8 | -------------------------------------------------------------------------------- /tests/data/page.md: -------------------------------------------------------------------------------- 1 | # test page 2 | 3 | > This is a test page. 4 | > More information: . 5 | 6 | - This is a description of a `command` example: 7 | 8 | `command --opt1 --opt2 {{placeholder}}` 9 | 10 | - Another one: 11 | 12 | `command --opt1 {{placeholder1 placeholder2 ...}}` 13 | -------------------------------------------------------------------------------- /tests/data/page-render: -------------------------------------------------------------------------------- 1 | 2 | test page 3 | 4 | This is a test page. 5 | More information: https://example.org. 6 | 7 | This is a description of a command example: 8 | 9 | command --opt1 --opt2 placeholder 10 | 11 | Another one: 12 | 13 | command --opt1 placeholder1 placeholder2 ... 14 | 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | deps: 9 | patterns: ["*"] 10 | update-types: ["minor", "patch"] 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tlrc", 3 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 4 | "features": { 5 | "ghcr.io/devcontainers/features/rust:1": {} 6 | }, 7 | "privileged": false, 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "GitHub.vscode-pull-request-github", 12 | "github.vscode-github-actions", 13 | "DavidAnson.vscode-markdownlint" 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy the manpage to GitHub Pages 2 | 3 | on: 4 | push: 5 | tags: v*.*.* 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 16 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | deploy: 23 | name: Deploy 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: acuteenvy/deploy-manpage-to-pages@main 30 | with: 31 | manpage: tldr.1 32 | title: tlrc manual 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 acuteenvy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /completions/tldr.bash: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | 3 | _tldr() { 4 | local cur="${COMP_WORDS[COMP_CWORD]}" 5 | local prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | 7 | local opts="-u -l -a -i -r -p -L -o -c -R -q -v -h \ 8 | --update --list --list-all --list-platforms --list-languages \ 9 | --info --render --clean-cache --gen-config --config-path --platform \ 10 | --language --short-options --long-options --offline --compact \ 11 | --no-compact --raw --no-raw --quiet --verbose --color --config --version --help" 12 | 13 | if [[ $cur == -* ]]; then 14 | mapfile -t COMPREPLY < <(compgen -W "$opts" -- "$cur") 15 | return 0 16 | fi 17 | 18 | case $prev in 19 | -r|--render|--config) 20 | mapfile -t COMPREPLY < <(compgen -f -- "$cur");; 21 | --color) 22 | mapfile -t COMPREPLY < <(compgen -W "auto always never" -- "$cur");; 23 | -p|--platform) 24 | mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-platforms 2> /dev/null)" -- "$cur");; 25 | -L|--language) 26 | mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-languages 2> /dev/null)" -- "$cur");; 27 | *) 28 | mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-all 2> /dev/null)" -- "$cur");; 29 | esac 30 | } 31 | 32 | complete -o bashdefault -F _tldr tldr 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | RUST_BACKTRACE: full 14 | 15 | jobs: 16 | rustfmt: 17 | name: Rustfmt 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout the repository 22 | uses: actions/checkout@v6 23 | - name: Update Rust 24 | run: rustup update stable 25 | - name: Run rustfmt 26 | run: cargo fmt --check 27 | 28 | clippy: 29 | name: Clippy 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: Checkout the repository 34 | uses: actions/checkout@v6 35 | - name: Update Rust 36 | run: rustup update stable 37 | - name: Run clippy 38 | run: cargo clippy -- --deny warnings 39 | 40 | tests: 41 | name: Build and test 42 | runs-on: ${{ matrix.os }} 43 | 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest, windows-latest, macos-latest, ubuntu-24.04-arm, windows-11-arm] 47 | # Stable and MSRV 48 | rust_version: [stable, 1.83] 49 | 50 | steps: 51 | - name: Checkout the repository 52 | uses: actions/checkout@v6 53 | - name: Update Rust 54 | run: rustup update "${{ matrix.rust_version }}" && rustup default "${{ matrix.rust_version }}" 55 | - name: Build 56 | run: cargo build --verbose 57 | - name: Run tests 58 | run: cargo test --verbose 59 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tlrc" 3 | version = "1.12.0" 4 | description = "Official tldr client written in Rust" 5 | authors = ["Lena Pastwa"] 6 | categories = ["command-line-utilities"] 7 | keywords = ["tldr"] 8 | repository = "https://github.com/tldr-pages/tlrc" 9 | documentation = "https://tldr.sh/tlrc" 10 | license = "MIT" 11 | edition = "2021" 12 | rust-version = "1.83" 13 | 14 | [[bin]] 15 | name = "tldr" 16 | path = "src/main.rs" 17 | 18 | [features] 19 | default = ["socks-proxy"] 20 | socks-proxy = ["ureq/socks-proxy"] 21 | 22 | [dependencies] 23 | clap = { version = "4.5.51", features = ["derive", "wrap_help"] } 24 | dirs = "6.0.0" 25 | log = "0.4.27" 26 | once_cell = "1.21.3" 27 | ring = "0.17.14" 28 | serde = { version = "1.0.219", features = ["derive"] } 29 | terminal_size = "0.4.3" 30 | toml = "0.9.8" 31 | unicode-width = { version = "0.2.2", default-features = false } 32 | ureq = { version = "3.1.0", default-features = false, features = ["rustls", "platform-verifier"] } 33 | yansi = "1.0.1" 34 | zip = { version = "6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } 35 | 36 | [dev-dependencies] 37 | assert_cmd = "2.1.1" 38 | tempfile = "3.21.0" 39 | 40 | [lints.clippy] 41 | all = "warn" 42 | pedantic = "warn" 43 | style = "warn" 44 | module_name_repetitions = { level = "allow", priority = 1 } 45 | struct_excessive_bools = { level = "allow", priority = 1 } 46 | unnecessary_debug_formatting = { level = "allow", priority = 1 } 47 | 48 | [profile.release] 49 | lto = true 50 | strip = true 51 | codegen-units = 1 52 | panic = "abort" 53 | opt-level = 3 54 | 55 | [package.metadata.deb] 56 | section = "utils" 57 | priority = "optional" 58 | conflicts = ["tldr", "tldr-py"] 59 | extended-description = "" 60 | assets = [ 61 | ["target/release/tldr", "usr/bin/", "755"], 62 | ["tldr.1", "usr/share/man/man1/", "644"], 63 | ["completions/tldr.bash", "usr/share/bash-completion/completions/tldr", "644"], 64 | ["completions/_tldr", "usr/share/zsh/vendor-completions/", "644"], 65 | ["completions/tldr.fish", "usr/share/fish/vendor_completions.d/", "644"], 66 | ["LICENSE", "usr/share/licenses/tlrc", "644"] 67 | ] 68 | -------------------------------------------------------------------------------- /completions/tldr.fish: -------------------------------------------------------------------------------- 1 | complete -c tldr -s r -l render -d "Render the specified markdown file" -r 2 | complete -c tldr -s p -l platform -d "Specify the platform to use (linux, osx, windows, etc.)" -x -a \ 3 | "(tldr --offline --list-platforms 2> /dev/null)" 4 | complete -c tldr -s L -l language -d "Specify the languages to use" -x -a \ 5 | "(tldr --offline --list-languages 2> /dev/null)" 6 | complete -c tldr -l short-options -d "Display short options wherever possible (e.g '-s')" 7 | complete -c tldr -l long-options -d "Display long options wherever possible (e.g '--long')" 8 | complete -c tldr -l color -d "Specify when to enable color" -x -a " 9 | auto\t'Display color if standard output is a terminal and NO_COLOR is not set' 10 | always\t'Always display color' 11 | never\t'Never display color' 12 | " 13 | complete -c tldr -l config -d "Specify an alternative path to the config file" -r 14 | complete -c tldr -s u -l update -d "Update the cache" 15 | complete -c tldr -s l -l list -d "List all pages in the current platform" 16 | complete -c tldr -s a -l list-all -d "List all pages" 17 | complete -c tldr -s a -l list-platforms -d "List available platforms" 18 | complete -c tldr -s a -l list-languages -d "List installed languages" 19 | complete -c tldr -s i -l info -d "Show cache information (path, age, installed languages and the number of pages)" 20 | complete -c tldr -l clean-cache -d "Interactively delete contents of the cache directory" 21 | complete -c tldr -l gen-config -d "Print the default config" 22 | complete -c tldr -l config-path -d "Print the default config path and create the config directory" 23 | complete -c tldr -s o -l offline -d "Do not update the cache, even if it is stale" 24 | complete -c tldr -s c -l compact -d "Strip empty lines from output" 25 | complete -c tldr -l no-compact -d "Do not strip empty lines from output (overrides --compact)" 26 | complete -c tldr -s R -l raw -d "Print pages in raw markdown instead of rendering them" 27 | complete -c tldr -l no-raw -d "Render pages instead of printing raw file contents (overrides --raw)" 28 | complete -c tldr -s q -l quiet -d "Suppress status messages and warnings" 29 | complete -c tldr -l verbose -d "Be more verbose (can be specified twice)" 30 | complete -c tldr -s v -l version -d "Print version" 31 | complete -c tldr -s h -l help -d "Print help" 32 | complete -c tldr -f -a "(tldr --offline --list-all 2> /dev/null)" 33 | -------------------------------------------------------------------------------- /completions/_tldr: -------------------------------------------------------------------------------- 1 | #compdef tldr 2 | 3 | _pages() { 4 | local -a pages=(${(uonzf)"$(tldr --offline --list-all 2> /dev/null)"//:/\\:}) 5 | _describe "PAGE" pages 6 | } 7 | 8 | _languages() { 9 | local -a languages=(${(uonzf)"$(tldr --offline --list-languages 2> /dev/null)"//:/\\:}) 10 | _describe "LANGUAGE_CODE" languages 11 | } 12 | 13 | _platforms() { 14 | local -a platforms=(${(uonzf)"$(tldr --offline --list-platforms 2> /dev/null)"//:/\\:}) 15 | _describe "PLATFORM" platforms 16 | } 17 | 18 | _tldr() { 19 | _arguments -s -S \ 20 | {-u,--update}"[Update the cache]" \ 21 | {-l,--list}"[List all pages in the current platform]" \ 22 | {-a,--list-all}"[List all pages]" \ 23 | --list-platforms"[List available platforms]" \ 24 | --list-languages"[List installed languages]" \ 25 | {-i,--info}"[Show cache information (path, age, installed languages and the number of pages)]" \ 26 | {-r,--render}"[Render the specified markdown file]:FILE:_files" \ 27 | --clean-cache"[Interactively delete contents of the cache directory]" \ 28 | --gen-config"[Print the default config]" \ 29 | --config-path"[Print the default config path and create the config directory]" \ 30 | {-p,--platform}"[Specify the platform to use (linux, osx, windows, etc.)]:PLATFORM:_platforms" \ 31 | {-L,--language}"[Specify the languages to use]:LANGUAGE_CODE:_languages" \ 32 | --short-options"[Display short options wherever possible (e.g '-s')]" \ 33 | --long-options"[Display long options wherever possible (e.g '--long')]" \ 34 | {-o,--offline}"[Do not update the cache, even if it is stale]" \ 35 | {-c,--compact}"[Strip empty lines from output]" \ 36 | --no-compact"[Do not strip empty lines from output (overrides --compact)]" \ 37 | {-R,--raw}"[Print pages in raw markdown instead of rendering them]" \ 38 | --no-raw"[Render pages instead of printing raw file contents (overrides --raw)]" \ 39 | {-q,--quiet}"[Suppress status messages and warnings]" \ 40 | --verbose"[Be more verbose (can be specified twice)]" \ 41 | --color"[Specify when to enable color]:WHEN:(auto always never)" \ 42 | --config"[Specify an alternative path to the config file]:FILE:_files" \ 43 | {-v,--version}"[Print version]" \ 44 | {-h,--help}"[Print help]" \ 45 | '*:PAGE:_pages' 46 | } 47 | 48 | _tldr 49 | -------------------------------------------------------------------------------- /tests/tests.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::process::Command; 3 | 4 | use assert_cmd::prelude::*; 5 | 6 | const TEST_PAGE: &str = "tests/data/page.md"; 7 | const TEST_PAGE_OPTION_PLACEHOLDERS: &str = "tests/data/option-placeholder.md"; 8 | const TEST_PAGE_LINE_WRAPPING: &str = "tests/data/line-wrapping.md"; 9 | 10 | const TEST_PAGE_RENDER: &str = "tests/data/page-render"; 11 | const TEST_PAGE_COMPACT_RENDER: &str = "tests/data/page-compact-render"; 12 | const TEST_PAGE_LINE_WRAPPING_RENDER: &str = "tests/data/line-wrapping-render"; 13 | 14 | const CONFIG_LINE_WRAPPING: &str = "tests/data/line-wrapping.toml"; 15 | const CONFIG_DEFAULT: &str = "/dev/null"; 16 | 17 | fn tlrc(cfg: &str, page: &str) -> Command { 18 | let mut cmd = Command::cargo_bin("tldr").unwrap(); 19 | cmd.args(["--config", cfg, "--render", page]); 20 | cmd 21 | } 22 | 23 | #[test] 24 | fn raw_md() { 25 | let expected = fs::read_to_string(TEST_PAGE).unwrap(); 26 | tlrc(CONFIG_DEFAULT, TEST_PAGE) 27 | .args(["--raw"]) 28 | .assert() 29 | .stdout(expected); 30 | } 31 | 32 | #[test] 33 | fn regular_render() { 34 | let expected = fs::read_to_string(TEST_PAGE_RENDER).unwrap(); 35 | tlrc(CONFIG_DEFAULT, TEST_PAGE).assert().stdout(expected); 36 | } 37 | 38 | #[test] 39 | fn compact_render() { 40 | let expected = fs::read_to_string(TEST_PAGE_COMPACT_RENDER).unwrap(); 41 | tlrc(CONFIG_DEFAULT, TEST_PAGE) 42 | .args(["--compact"]) 43 | .assert() 44 | .stdout(expected); 45 | } 46 | 47 | #[test] 48 | fn does_not_exist() { 49 | tlrc(CONFIG_DEFAULT, "/some/page/that/does/not/exist.md") 50 | .assert() 51 | .failure(); 52 | } 53 | 54 | #[test] 55 | fn short_opts() { 56 | tlrc(CONFIG_DEFAULT, TEST_PAGE_OPTION_PLACEHOLDERS) 57 | .args(["--short-options"]) 58 | .assert() 59 | .stdout(" foo -s\n\n"); 60 | } 61 | 62 | #[test] 63 | fn long_opts() { 64 | tlrc(CONFIG_DEFAULT, TEST_PAGE_OPTION_PLACEHOLDERS) 65 | .args(["--long-options"]) 66 | .assert() 67 | .stdout(" foo --long\n\n"); 68 | } 69 | 70 | #[test] 71 | fn both_opts() { 72 | tlrc(CONFIG_DEFAULT, TEST_PAGE_OPTION_PLACEHOLDERS) 73 | .args(["--short-options", "--long-options"]) 74 | .assert() 75 | .stdout(" foo [-s|--long]\n\n"); 76 | } 77 | 78 | #[test] 79 | fn wrap_lines() { 80 | let expected = fs::read_to_string(TEST_PAGE_LINE_WRAPPING_RENDER).unwrap(); 81 | tlrc(CONFIG_LINE_WRAPPING, TEST_PAGE_LINE_WRAPPING) 82 | .assert() 83 | .stdout(expected); 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | 3 | on: 4 | push: 5 | tags: v*.*.* 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | NAME: tlrc 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | permissions: 16 | contents: write 17 | 18 | jobs: 19 | release: 20 | name: Create the release 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout the repository 25 | uses: actions/checkout@v6 26 | 27 | - name: Create the release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: gh release create --title "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" 31 | 32 | build: 33 | name: Build ${{ matrix.target }} 34 | runs-on: ${{ matrix.os }} 35 | needs: release 36 | permissions: 37 | contents: write # to upload assets to releases 38 | attestations: write # to upload assets attestation for build provenance 39 | id-token: write # grant additional permission to attestation action to mint the OIDC token permission 40 | 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | include: 45 | - target: x86_64-unknown-linux-gnu 46 | os: ubuntu-latest 47 | 48 | - target: x86_64-unknown-linux-musl 49 | os: ubuntu-latest 50 | 51 | - target: x86_64-apple-darwin 52 | os: macos-latest 53 | 54 | - target: aarch64-apple-darwin 55 | os: macos-latest 56 | 57 | - target: x86_64-pc-windows-msvc 58 | os: windows-latest 59 | 60 | steps: 61 | - name: Checkout the repository 62 | uses: actions/checkout@v6 63 | 64 | - name: Install musl libc 65 | if: ${{ contains(matrix.target, 'musl') }} 66 | run: sudo apt install musl-tools 67 | 68 | - name: Set up Rust 69 | run: rustup update stable && rustup target add ${{ matrix.target }} 70 | 71 | - name: Build the executable 72 | run: cargo build --release --target ${{ matrix.target }} 73 | 74 | - name: Build the Debian package 75 | if: ${{ contains(matrix.target, 'linux') }} 76 | run: | 77 | cargo install cargo-deb --locked 78 | deb_path="$(cargo deb --target ${{ matrix.target }})" 79 | mv "$deb_path" "$NAME-$GITHUB_REF_NAME-${{ matrix.target }}.deb" 80 | 81 | - name: Create the archive 82 | env: 83 | BIN: tldr 84 | run: | 85 | if [[ ${{ matrix.os }} == *windows* ]]; then 86 | BIN="$BIN.exe" 87 | ARCHIVER=(7z a) 88 | EXTENSION="zip" 89 | else 90 | ARCHIVER=(tar -czvf) 91 | EXTENSION="tar.gz" 92 | fi 93 | 94 | mv "target/${{ matrix.target }}/release/$BIN" "$BIN" 95 | "${ARCHIVER[@]}" "$NAME-$GITHUB_REF_NAME-${{ matrix.target }}.$EXTENSION" "$BIN" LICENSE README.md tldr.1 completions 96 | 97 | - name: Upload the archive 98 | env: 99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 100 | run: gh release upload "$GITHUB_REF_NAME" "$NAME"-* 101 | 102 | - name: Attest release files 103 | id: attest 104 | uses: actions/attest-build-provenance@v3 105 | with: 106 | subject-path: '*.zip, *.tar.gz, *.deb' 107 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::io; 3 | use std::path::Path; 4 | use std::process::ExitCode; 5 | use std::result::Result as StdResult; 6 | 7 | use log::error; 8 | use yansi::Paint; 9 | 10 | #[derive(Debug)] 11 | pub enum ErrorKind { 12 | ParseToml, 13 | ParsePage, 14 | Download, 15 | Io, 16 | Other, 17 | } 18 | 19 | #[derive(Debug)] 20 | pub struct Error { 21 | pub kind: ErrorKind, 22 | message: String, 23 | } 24 | 25 | pub type Result = StdResult; 26 | 27 | impl Error { 28 | pub const DESC_NO_INTERNET: &'static str = 29 | "\n\nAn internet connection is required to download pages for the first time."; 30 | 31 | pub const DESC_AUTO_UPDATE_ERR: &'static str = 32 | "\n\nAn error occurred during the automatic update. \ 33 | To skip updating the cache, run tldr with --offline."; 34 | 35 | pub const DESC_LANG_NOT_INSTALLED: &'static str = 36 | "\n\nThe language you are trying to view the page in is not installed.\n\ 37 | Please update your config and run 'tldr --update' to install a new language."; 38 | 39 | pub fn new(message: T) -> Self 40 | where 41 | T: Display, 42 | { 43 | Self { 44 | kind: ErrorKind::Other, 45 | message: message.to_string(), 46 | } 47 | } 48 | 49 | /// Set the `ErrorKind`. 50 | pub fn kind(mut self, kind: ErrorKind) -> Self { 51 | self.kind = kind; 52 | self 53 | } 54 | 55 | /// Append `description` to the error message. 56 | pub fn describe(mut self, description: T) -> Self 57 | where 58 | T: Display, 59 | { 60 | self.message = format!("{} {description}", self.message); 61 | self 62 | } 63 | 64 | pub fn parse_page(page_path: &Path, i: usize, line: &str) -> Self { 65 | Error::new(format!( 66 | "'{}' is not a valid tldr page. (line {}):\n\n {}\n", 67 | page_path.display(), 68 | i, 69 | line.bold(), 70 | )) 71 | .kind(ErrorKind::ParsePage) 72 | } 73 | 74 | pub fn parse_sumfile() -> Self { 75 | Error::new("could not parse the checksum file").kind(ErrorKind::Download) 76 | } 77 | 78 | pub fn desc_page_does_not_exist(try_update: bool) -> String { 79 | let e = if try_update { 80 | "Try running 'tldr --update'.\n\n" 81 | } else { 82 | "\n\n" 83 | }; 84 | format!( 85 | "{e}\ 86 | If the page does not exist, you can create an issue here:\n\ 87 | {}\n\ 88 | or document it yourself and create a pull request here:\n\ 89 | {}", 90 | "https://github.com/tldr-pages/tldr/issues".bold(), 91 | "https://github.com/tldr-pages/tldr/pulls".bold() 92 | ) 93 | } 94 | 95 | pub fn offline_no_cache() -> Self { 96 | Error::new("cache does not exist. Run tldr without --offline to download pages.") 97 | .kind(ErrorKind::Download) 98 | } 99 | 100 | pub fn messed_up_cache(e: &str) -> Self { 101 | Error::new(format!( 102 | "{e}\n\nThis should never happen, did you delete something from the cache?\n\ 103 | Please run 'tldr --clean-cache' followed by 'tldr --update' to redownload all pages." 104 | )) 105 | } 106 | 107 | /// Print the error message to stderr and return an appropriate `ExitCode`. 108 | pub fn exit_code(self) -> ExitCode { 109 | error!("{}", self.message); 110 | 111 | match self.kind { 112 | ErrorKind::Other | ErrorKind::Io => 1, 113 | ErrorKind::ParseToml => 3, 114 | ErrorKind::Download => 4, 115 | ErrorKind::ParsePage => 5, 116 | } 117 | .into() 118 | } 119 | } 120 | 121 | macro_rules! from_impl { 122 | ( $from:ty, $kind:tt ) => { 123 | impl From<$from> for Error { 124 | fn from(e: $from) -> Self { 125 | Error::new(e).kind(ErrorKind::$kind) 126 | } 127 | } 128 | }; 129 | } 130 | 131 | from_impl! { io::Error, Io } 132 | from_impl! { toml::de::Error, ParseToml } 133 | from_impl! { ureq::Error, Download } 134 | from_impl! { zip::result::ZipError, Download } 135 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{ArgAction, ArgGroup, ColorChoice, Parser}; 4 | 5 | const DEFAULT_PLATFORM: &str = if cfg!(target_os = "linux") { 6 | "linux" 7 | } else if cfg!(target_os = "macos") { 8 | "osx" 9 | } else if cfg!(target_os = "windows") { 10 | "windows" 11 | } else if cfg!(target_os = "freebsd") { 12 | "freebsd" 13 | } else if cfg!(target_os = "openbsd") { 14 | "openbsd" 15 | } else if cfg!(target_os = "netbsd") { 16 | "netbsd" 17 | } else if cfg!(target_os = "android") { 18 | "android" 19 | } else { 20 | "common" 21 | }; 22 | 23 | const AFTER_HELP: &str = if cfg!(target_os = "windows") { 24 | // Man pages are not available on Windows. 25 | "See https://tldr.sh/tlrc for more information." 26 | } else { 27 | "See 'man tldr' or https://tldr.sh/tlrc for more information." 28 | }; 29 | 30 | #[derive(Parser)] 31 | #[command( 32 | arg_required_else_help = true, 33 | about, 34 | // This env var is generated and set in the build script. 35 | version = env!("VERSION_STRING"), 36 | disable_version_flag = true, 37 | after_help = AFTER_HELP, 38 | group = ArgGroup::new("operations").required(true), 39 | override_usage = "\x1b[1mtldr\x1b[0m [OPTIONS] [PAGE]...", 40 | help_template = "{before-help}{name} {version}\n\ 41 | {about-with-newline}\n\ 42 | {usage-heading} {usage}\n\n\ 43 | {all-args}{after-help}" 44 | )] 45 | pub struct Cli { 46 | /// The tldr page to show. 47 | #[arg(group = "operations")] 48 | pub page: Vec, 49 | 50 | /// Update the cache. 51 | #[arg(short, long, group = "operations")] 52 | pub update: bool, 53 | 54 | /// List all pages in the current platform. 55 | #[arg(short, long, group = "operations")] 56 | pub list: bool, 57 | 58 | /// List all pages. 59 | #[arg(short = 'a', long, group = "operations")] 60 | pub list_all: bool, 61 | 62 | /// List available platforms. 63 | #[arg(long, group = "operations")] 64 | pub list_platforms: bool, 65 | 66 | /// List installed languages. 67 | #[arg(long, group = "operations")] 68 | pub list_languages: bool, 69 | 70 | /// Show cache information (path, age, installed languages and the number of pages). 71 | #[arg(short, long, group = "operations")] 72 | pub info: bool, 73 | 74 | /// Render the specified markdown file. 75 | #[arg(short, long, group = "operations", value_name = "FILE")] 76 | pub render: Option, 77 | 78 | /// Interactively delete contents of the cache directory. 79 | #[arg(long, group = "operations")] 80 | pub clean_cache: bool, 81 | 82 | /// Print the default config. 83 | #[arg(long, group = "operations")] 84 | pub gen_config: bool, 85 | 86 | /// Print the default config path and create the config directory. 87 | #[arg(long, group = "operations")] 88 | pub config_path: bool, 89 | 90 | /// Specify the platform to use (linux, osx, windows, etc.). 91 | #[arg(short, long, default_value = DEFAULT_PLATFORM)] 92 | pub platform: String, 93 | 94 | /// Specify the languages to use. 95 | #[arg(short = 'L', long = "language", value_name = "LANGUAGE_CODE")] 96 | pub languages: Option>, 97 | 98 | /// Display short options wherever possible (e.g. '-s'). 99 | #[arg(long)] 100 | pub short_options: bool, 101 | 102 | /// Display long options wherever possible (e.g. '--long'). 103 | #[arg(long)] 104 | pub long_options: bool, 105 | 106 | /// Display a link to edit the shown page on GitHub. 107 | #[arg(long)] 108 | pub edit: bool, 109 | 110 | /// Do not update the cache, even if it is stale. 111 | #[arg(short, long)] 112 | pub offline: bool, 113 | 114 | /// Strip empty lines from output. 115 | #[arg(short, long)] 116 | pub compact: bool, 117 | 118 | /// Do not strip empty lines from output (overrides --compact). 119 | #[arg(long)] 120 | pub no_compact: bool, 121 | 122 | /// Print pages in raw markdown instead of rendering them. 123 | #[arg(short = 'R', long)] 124 | pub raw: bool, 125 | 126 | /// Render pages instead of printing raw file contents (overrides --raw). 127 | #[arg(long)] 128 | pub no_raw: bool, 129 | 130 | /// Suppress status messages and warnings. 131 | #[arg(short, long)] 132 | pub quiet: bool, 133 | 134 | /// Be more verbose (can be specified twice). 135 | #[arg(long, action = ArgAction::Count)] 136 | pub verbose: u8, 137 | 138 | /// Specify when to enable color. 139 | #[arg(long, value_name = "WHEN", default_value_t = ColorChoice::default())] 140 | pub color: ColorChoice, 141 | 142 | /// Specify an alternative path to the config file. 143 | #[arg(long, value_name = "FILE")] 144 | pub config: Option, 145 | 146 | /// Print version. 147 | #[arg(short, long, action = ArgAction::Version)] 148 | version: (), 149 | } 150 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod cache; 3 | mod config; 4 | mod error; 5 | mod output; 6 | mod util; 7 | 8 | use std::process::ExitCode; 9 | 10 | use clap::Parser; 11 | use log::{info, warn}; 12 | use yansi::Paint; 13 | 14 | use crate::args::Cli; 15 | use crate::cache::Cache; 16 | use crate::config::{Config, OptionStyle}; 17 | use crate::error::{Error, Result}; 18 | use crate::output::PageRenderer; 19 | use crate::util::{init_color, Logger}; 20 | 21 | fn main() -> ExitCode { 22 | let cli = Cli::parse(); 23 | init_color(cli.color); 24 | Logger::init(cli.quiet, cli.verbose); 25 | 26 | match run(cli) { 27 | Ok(()) => ExitCode::SUCCESS, 28 | Err(e) => e.exit_code(), 29 | } 30 | } 31 | 32 | fn include_cli_in_config(cfg: &mut Config, cli: &Cli) { 33 | cfg.output.edit_link |= cli.edit; 34 | cfg.output.compact = !cli.no_compact && (cli.compact || cfg.output.compact); 35 | cfg.output.raw_markdown = !cli.no_raw && (cli.raw || cfg.output.raw_markdown); 36 | match (cli.short_options, cli.long_options) { 37 | (false, false) => {} 38 | (true, true) => cfg.output.option_style = OptionStyle::Both, 39 | (true, false) => cfg.output.option_style = OptionStyle::Short, 40 | (false, true) => cfg.output.option_style = OptionStyle::Long, 41 | } 42 | } 43 | 44 | fn run(cli: Cli) -> Result<()> { 45 | if cli.config_path { 46 | return Config::print_path(); 47 | } 48 | 49 | if cli.gen_config { 50 | return Config::print_default(); 51 | } 52 | 53 | let mut cfg = Config::new(cli.config.as_deref())?; 54 | include_cli_in_config(&mut cfg, &cli); 55 | 56 | if let Some(path) = cli.render { 57 | return PageRenderer::print(&path, &cfg); 58 | } 59 | 60 | // This is needed later to print a different error message if --language was used. 61 | let languages_are_from_cli = cli.languages.is_some(); 62 | // We need to clone() because this vector will not be sorted, 63 | // unlike the one in the config. 64 | let languages = cli.languages.unwrap_or_else(|| cfg.cache.languages.clone()); 65 | let cache = Cache::new(&cfg.cache.dir); 66 | 67 | if cli.clean_cache { 68 | return cache.clean(); 69 | } 70 | 71 | if cli.update { 72 | // update() should never use languages from --language. 73 | return cache.update(&cfg.cache.mirror, &mut cfg.cache.languages); 74 | } 75 | 76 | // Update after displaying the page? 77 | let mut update_later = false; 78 | 79 | if !cache.subdir_exists(cache::ENGLISH_DIR) { 80 | if cli.offline { 81 | return Err(Error::offline_no_cache()); 82 | } 83 | info!("cache is empty, downloading..."); 84 | cache 85 | .update(&cfg.cache.mirror, &mut cfg.cache.languages) 86 | .map_err(|e| e.describe(Error::DESC_NO_INTERNET))?; 87 | } else if cfg.cache.auto_update && cache.age()? > cfg.cache_max_age() { 88 | let age = util::duration_fmt(cache.age()?.as_secs()); 89 | let age = age.green().bold(); 90 | 91 | if cli.offline { 92 | warn!("cache is stale (last update: {age} ago). Run tldr without --offline to update."); 93 | } else if cfg.cache.defer_auto_update { 94 | info!("cache is stale (last update: {age} ago), update has been deferred"); 95 | update_later = true; 96 | } else { 97 | info!("cache is stale (last update: {age} ago), updating..."); 98 | cache 99 | .update(&cfg.cache.mirror, &mut cfg.cache.languages) 100 | .map_err(|e| e.describe(Error::DESC_AUTO_UPDATE_ERR))?; 101 | } 102 | } 103 | 104 | // "macos" should be an alias of "osx". 105 | // Since the `macos` directory doesn't exist, this has to be changed before it 106 | // gets passed to cache functions (which expect directory names). 107 | let platform = if cli.platform == "macos" { 108 | "osx" 109 | } else { 110 | &cli.platform 111 | }; 112 | 113 | if cli.list { 114 | cache.list_for(platform)?; 115 | } else if cli.list_all { 116 | cache.list_all()?; 117 | } else if cli.info { 118 | cache.info(&cfg)?; 119 | } else if cli.list_platforms { 120 | cache.list_platforms()?; 121 | } else if cli.list_languages { 122 | cache.list_languages()?; 123 | } else { 124 | let page_name = cli.page.join("-").to_lowercase(); 125 | let mut page_paths = cache.find(&page_name, &languages, platform)?; 126 | let forced_update_no_page = update_later && page_paths.is_empty(); 127 | if forced_update_no_page { 128 | // Since the page hasn't been found and the cache is stale, disregard the defer option. 129 | warn!("page not found, updating now..."); 130 | cache 131 | .update(&cfg.cache.mirror, &mut cfg.cache.languages) 132 | .map_err(|e| e.describe(Error::DESC_AUTO_UPDATE_ERR))?; 133 | page_paths = cache.find(&page_name, &languages, platform)?; 134 | // Reset the defer flag in order not to update twice. 135 | update_later = false; 136 | } 137 | 138 | if page_paths.is_empty() { 139 | let mut e = Error::new("page not found."); 140 | return if languages_are_from_cli { 141 | e = e.describe("Try running tldr without --language."); 142 | 143 | if !languages 144 | .iter() 145 | .all(|x| cache.subdir_exists(&format!("pages.{x}"))) 146 | { 147 | e = e.describe(Error::DESC_LANG_NOT_INSTALLED); 148 | } 149 | 150 | Err(e) 151 | } else { 152 | // If the cache has been updated, don't suggest running 'tldr --update'. 153 | Err(e.describe(Error::desc_page_does_not_exist(!forced_update_no_page))) 154 | }; 155 | } 156 | 157 | PageRenderer::print_cache_result(&page_paths, &cfg)?; 158 | } 159 | 160 | if update_later { 161 | cache 162 | .update(&cfg.cache.mirror, &mut cfg.cache.languages) 163 | .map_err(|e| e.describe(Error::DESC_AUTO_UPDATE_ERR))?; 164 | } 165 | 166 | Ok(()) 167 | } 168 | -------------------------------------------------------------------------------- /tldr.1: -------------------------------------------------------------------------------- 1 | .\" vim: colorcolumn=100 textwidth=100 2 | .TH "TLRC" "1" "2025-10-01" "tlrc 1.12.0" "tlrc manual" 3 | .nh 4 | .ad l 5 | .SH NAME 6 | tlrc - official tldr client written in Rust 7 | . 8 | . 9 | .SH SYNOPSIS 10 | \fItldr\fR [options] [page] 11 | . 12 | . 13 | .SH OPTIONS 14 | .TP 4 15 | .B -u, --update 16 | Update the cache of tldr pages.\& 17 | This will first download the sha256sums of all archives and compare them\& 18 | to the old sums to determine which languages need updating.\& 19 | If you want to force a redownload, run \fItldr\fR \fB--clean-cache\fR beforehand. 20 | . 21 | .TP 4 22 | .B -l, --list 23 | List all pages in the current platform. 24 | . 25 | .TP 4 26 | .B -a, --list-all 27 | List all pages. 28 | . 29 | .TP 4 30 | .B --list-platforms 31 | List available platforms. 32 | . 33 | .TP 4 34 | .B --list-languages 35 | List available languages. Use \fB--info\fR for a language list with more information. 36 | . 37 | .TP 4 38 | .B -i, --info 39 | Show cache information (path, age, installed languages and the number of pages). 40 | . 41 | .TP 4 42 | \fB-r, --render\fR 43 | Render the specified markdown file. 44 | . 45 | .TP 4 46 | .B --clean-cache 47 | Interactively delete contents of the cache directory.\& 48 | .br 49 | The \fItldr.sha256sums\fR file is always removed to force a redownload of all page archives\& 50 | during the next update. You can also choose to delete already downloaded languages. 51 | . 52 | .TP 4 53 | .B --gen-config 54 | Print the default config to standard output. 55 | . 56 | .TP 4 57 | .B --config-path 58 | Print the default config path and create the config directory if it does not exist. 59 | . 60 | .TP 4 61 | \fB-p, --platform\fR 62 | Specify the platform to use (linux, osx, windows, etc.). 63 | .sp 64 | Default: the operating system you are \fBcurrently running\fR 65 | . 66 | .TP 4 67 | \fB-L, --language\fR 68 | Specify the language to show pages in.\& 69 | Can be used multiple times.\& 70 | Overrides all other language detection methods.\& 71 | \fItlrc\fR will not fall back to English when this option is used, and will instead\& 72 | show an error. Note that this option does not affect languages downloaded on \fB--update\fR.\& 73 | If you want to use languages not defined in environment variables, use the\& 74 | \fIcache.languages\fR option in the config file. 75 | .sp 76 | Default: taken from the config or the \fBLANG\fR and \fBLANGUAGE\fR environment variables.\& 77 | See \fBhttps://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language\fR 78 | for a detailed description of how \fItlrc\fR determines the language. 79 | . 80 | .TP 4 81 | .B --short-options 82 | In option placeholders, display short options wherever possible. 83 | .br 84 | Example: "\fI{{[-s|--long]}}\fR" will be displayed as "\fB-s\fR" when using this option.\& 85 | Equivalent of setting \fIoutput.option_style\fR="\fBshort\fR" in the config. 86 | .sp 87 | When used with \fB--long-options\fR, the above placeholder will be displayed\& 88 | as "\fB[-s|--long]\fR". Using both options is equivalent to\& 89 | setting \fIoutput.option_style\fR="\fBboth\fR" in the config. 90 | . 91 | .TP 4 92 | .B --long-options 93 | In option placeholders, display long options wherever possible. 94 | .br 95 | Example: "\fI{{[-s|--long]}}\fR" will be displayed as "\fB--long\fR" when using this option.\& 96 | Equivalent of setting \fIoutput.option_style\fR="\fBlong\fR" in the config. 97 | .sp 98 | This is the default. 99 | . 100 | .TP 4 101 | .B -o, --offline 102 | Do not update the cache, even if it is stale and automatic updates are on.\& 103 | Similar to setting \fIcache.auto_update\fR=\fBfalse\fR in the config, except using this will\& 104 | show an error if the cache is empty. 105 | . 106 | .TP 4 107 | .B -c, --compact 108 | Strip empty lines from output. Equivalent of setting \fIoutput.compact\fR=\fBtrue\fR in the config. 109 | . 110 | .TP 4 111 | .B --no-compact 112 | Do not strip empty lines from output. Equivalent of setting\& 113 | \fIoutput.compact\fR=\fBfalse\fR in the config. This always overrides \fB--compact\fR. 114 | . 115 | .TP 4 116 | .B -R, --raw 117 | Print pages in raw markdown. Equivalent of setting\& 118 | \fIoutput.raw_markdown\fR=\fBtrue\fR in the config. 119 | . 120 | .TP 4 121 | .B --no-raw 122 | Render pages instead of printing raw file contents. Equivalent of setting\& 123 | \fIoutput.raw_markdown\fR=\fBfalse\fR in the config. This always overrides \fB--raw\fR. 124 | . 125 | .TP 4 126 | .B -q, --quiet 127 | Suppress status messages and warnings.\& 128 | In other words, this makes \fItlrc\fR print only pages and errors. 129 | .sp 130 | This always overrides \fB--verbose\fR. 131 | . 132 | .TP 4 133 | .B --verbose 134 | Be more verbose, print debug information. Useful to see what exactly is being done if you're\& 135 | having issues. 136 | .sp 137 | Can be specified twice for even more messages. Using \fB--verbose\fR more than twice has no other\& 138 | effect. 139 | . 140 | .TP 4 141 | \fB--color\fR 142 | Specify when to enable color. 143 | .br 144 | Can be one of the following: '\fBalways\fR', '\fBnever\fR', '\fBauto\fR'. 145 | .br 146 | \fBalways\fR forces colors on; \fBnever\fR forces colors off; and \fBauto\fR 147 | only automatically enables colors when outputting onto a tty and\& 148 | the \fBNO_COLOR\fR environment variable is not set or is an empty string. 149 | .sp 150 | Default: \fBauto\fR 151 | . 152 | .TP 4 153 | \fB--config\fR 154 | Specify an alternative path to the config file. This option overrides all config detection\& 155 | methods (i.e. OS-specific directories and the \fI$TLRC_CONFIG\fR environment variable). 156 | .sp 157 | Default: \fBplatform-dependent\fR (use \fB--config-path\fR to see the default path for your system) 158 | . 159 | .TP 4 160 | .B -v, --version 161 | Print version information. 162 | . 163 | .TP 4 164 | .B -h, --help 165 | Print a help message. 166 | . 167 | . 168 | .SH CONFIGURATION 169 | To generate a default config file, run: 170 | .IP 171 | .nf 172 | \fItldr\fR --gen-config > "$(\fItldr\fR --config-path)" 173 | .fi 174 | .PP 175 | See \fBhttps://github.com/tldr-pages/tlrc#configuration\fR for an example config file\& 176 | with explanations. 177 | .sp 178 | The default config path depends on your operating system: 179 | .br 180 | Linux and BSD: \fI$XDG_CONFIG_HOME\fB/tlrc/config.toml\fR or \fB~/.config/tlrc/config.toml\fR if\& 181 | \fI$XDG_CONFIG_HOME\fR is unset 182 | .br 183 | macOS: \fB~/Library/Application Support/tlrc/config.toml\fR 184 | .br 185 | Windows: \fI%ROAMINGAPPDATA%\fB\\tlrc\\config.toml\fR 186 | .sp 187 | No matter the OS, you can set the \fI$TLRC_CONFIG\fR environment variable or use\& 188 | \fB--config\fR to override the default path. The command-line option takes precedence over all\& 189 | other detection methods. 190 | . 191 | . 192 | .SH EXAMPLES 193 | See the tldr page for \fBtar\fR: 194 | .IP 195 | .nf 196 | \fItldr\fR tar 197 | .fi 198 | .PP 199 | . 200 | See the tldr page for \fBdiskpart\fR, from platform \fBwindows\fR: 201 | .IP 202 | .nf 203 | \fItldr\fR --platform windows diskpart 204 | .fi 205 | .PP 206 | . 207 | . 208 | .SH EXIT STATUSES 209 | .TP 210 | 0 211 | OK 212 | . 213 | .TP 214 | 1 215 | I/O and various other errors 216 | . 217 | .TP 218 | 2 219 | Invalid command-line arguments 220 | . 221 | .TP 222 | 3 223 | TOML (config file) parse error 224 | . 225 | .TP 226 | 4 227 | Errors related to cache updates (e.g. a failed HTTP GET request) 228 | . 229 | .TP 230 | 5 231 | Tldr syntax error (e.g. a non-empty line that does not start with '# ', '> ', '- ' or '`') 232 | . 233 | . 234 | .SH SEE ALSO 235 | tldr client specification 236 | .br 237 | .B https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md 238 | .br 239 | .sp 240 | tlrc repository (report issues with the client here) 241 | .br 242 | .B https://github.com/tldr-pages/tlrc 243 | .sp 244 | tldr-pages repository (report issues with the pages here) 245 | .br 246 | .B https://github.com/tldr-pages/tldr 247 | .sp 248 | An online version of this man page is available here: 249 | .br 250 | .B https://tldr.sh/tlrc 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # tlrc 4 | 5 | A [tldr](https://github.com/tldr-pages/tldr) client written in Rust. 6 | 7 | [![CI](https://img.shields.io/github/actions/workflow/status/tldr-pages/tlrc/ci.yml?label=CI&logo=github&labelColor=363a4f&logoColor=d9e0ee)](https://github.com/tldr-pages/tlrc/actions/workflows/ci.yml) 8 | [![release](https://img.shields.io/github/v/release/tldr-pages/tlrc?&logo=github&color=cba6f7&logoColor=d9e0ee&labelColor=363a4f)][latest-release] 9 | [![crates.io](https://img.shields.io/crates/v/tlrc?&logo=rust&color=cba6f7&logoColor=d9e0ee&labelColor=363a4f)][crate] 10 | [![license](https://img.shields.io/github/license/tldr-pages/tlrc?color=b4befe&labelColor=363a4f)](/LICENSE) 11 |
12 | [![github downloads](https://img.shields.io/github/downloads/tldr-pages/tlrc/total?logo=github&color=94e2d5&logoColor=d9e0ee&labelColor=363a4f)][latest-release] 13 | [![matrix](https://img.shields.io/matrix/tldr-pages%3Amatrix.org?logo=matrix&color=94e2d5&logoColor=d9e0ee&labelColor=363a4f&label=tldr-pages%20matrix)](https://matrix.to/#/#tldr-pages:matrix.org) 14 | 15 | ![screenshot](https://github.com/tldr-pages/tlrc/assets/126529524/daa76702-f437-4a99-adfb-7830a6f33eb9) 16 | 17 |
18 | 19 | ## Installation 20 | 21 | 22 | Packaging status 23 | 24 | 25 | ### Linux/macOS using Homebrew 26 | 27 | Install [tlrc](https://formulae.brew.sh/formula/tlrc) with Homebrew: 28 | 29 | ```shell 30 | brew install tlrc 31 | ``` 32 | 33 | ### Linux/macOS using Nix 34 | 35 | Install [tlrc](https://search.nixos.org/packages?channel=unstable&show=tlrc) from nixpkgs. 36 | 37 | ### Arch Linux 38 | 39 | Install [tlrc](https://aur.archlinux.org/packages/tlrc) (from source) or [tlrc-bin](https://aur.archlinux.org/packages/tlrc-bin) (prebuilt) from the AUR. 40 | 41 | ### openSUSE 42 | 43 | Install [tlrc](https://software.opensuse.org/package/tlrc) with Zypper: 44 | 45 | ```shell 46 | zypper install tlrc 47 | ``` 48 | 49 | ### Windows using Winget 50 | 51 | Install [tlrc](https://github.com/microsoft/winget-pkgs/tree/master/manifests/t/tldr-pages/tlrc) with Winget: 52 | 53 | ```shell 54 | winget install tldr-pages.tlrc 55 | ``` 56 | 57 | ### Windows using Scoop 58 | 59 | Install [tlrc](https://scoop.sh/#/apps?q=tlrc&id=67f36cdb01b1573ed454af11605b7b8efc732dc7) with Scoop: 60 | 61 | ```shell 62 | scoop install tlrc 63 | ``` 64 | 65 | ### macOS using MacPorts 66 | 67 | Install [tlrc](https://ports.macports.org/port/tlrc/details) with MacPorts: 68 | 69 | ```shell 70 | port install tlrc 71 | ``` 72 | 73 | ### NetBSD 74 | 75 | Install [tlrc](https://ftp.netbsd.org/pub/NetBSD/NetBSD-current/pkgsrc/net/tlrc/index.html) with `pkgin`: 76 | 77 | ```shell 78 | pkgin install tlrc 79 | ``` 80 | 81 | ### From crates.io 82 | 83 | To build [tlrc][crate] from a source tarball, run: 84 | 85 | ```shell 86 | cargo install tlrc --locked 87 | ``` 88 | 89 | > [!NOTE] 90 | > Shell completion files and the man page will not be installed that way. 91 | 92 | ### From GitHub Releases 93 | 94 | You can find prebuilt binaries and Debian packages [here][latest-release]. 95 | 96 | ## Usage 97 | 98 | See `man tldr` or the [online manpage](https://tldr.sh/tlrc). For a brief description, you can also run: 99 | 100 | ```shell 101 | tldr --help 102 | ``` 103 | 104 | ## Configuration 105 | 106 | Tlrc can be customized with a [TOML](https://toml.io) configuration file. To get the default path for your system, run: 107 | 108 | ```shell 109 | tldr --config-path 110 | ``` 111 | 112 | To generate a default config file, run: 113 | 114 | ```shell 115 | tldr --gen-config > "$(tldr --config-path)" 116 | ``` 117 | 118 | or copy the below example. 119 | 120 | ### Configuration options 121 | 122 | ```toml 123 | [cache] 124 | # Override the cache directory ('~' will be expanded to your home directory). 125 | dir = "/path/to/cache" 126 | # Override the base URL used for downloading tldr pages. 127 | # The mirror must provide files with the same names as the official tldr pages repository: 128 | # mirror/tldr.sha256sums must point to the SHA256 checksums of all assets 129 | # mirror/tldr-pages.LANGUAGE.zip must point to a zip archive that contains platform directories with pages in LANGUAGE 130 | mirror = "https://github.com/tldr-pages/tldr/releases/latest/download" 131 | # Automatically update the cache if it's older than max_age hours. 132 | auto_update = true 133 | # Perform the automatic update after the page is shown (the default is to update first, then show the page). 134 | defer_auto_update = false 135 | max_age = 336 # 336 hours = 2 weeks 136 | # Specify a list of desired page languages. If it's empty, languages specified in 137 | # the LANG and LANGUAGE environment variables are downloaded. 138 | # English is implied and will always be downloaded. 139 | # You can see a list of language codes here: https://github.com/tldr-pages/tldr 140 | # Example: ["de", "pl"] 141 | languages = [] 142 | 143 | [output] 144 | # Show the title in the rendered page. 145 | show_title = true 146 | # Show the platform name ('common', 'linux', etc.) in the title. 147 | platform_title = false 148 | # Prefix descriptions of examples with hyphens. 149 | show_hyphens = false 150 | # Use a custom string instead of a hyphen. 151 | example_prefix = "- " 152 | # Set the max line length. 0 means to use the terminal width. 153 | # If a description is longer than this value, it will be split 154 | # into multiple lines. 155 | line_length = 0 156 | # Strip empty lines from output. 157 | compact = false 158 | # In option placeholders, show the specified option style. 159 | # Example: {{[-s|--long]}} 160 | # short : -s 161 | # long : --long 162 | # both : [-s|--long] 163 | option_style = "long" 164 | # Print pages in raw markdown. 165 | raw_markdown = false 166 | 167 | # Number of spaces to put before each line of the page. 168 | [indent] 169 | # Command name. 170 | title = 2 171 | # Command description. 172 | description = 2 173 | # Descriptions of examples. 174 | bullet = 2 175 | # Example command invocations. 176 | example = 4 177 | 178 | # Style for the title of the page (command name). 179 | [style.title] 180 | # Fixed colors: "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "default", 181 | # "bright_black", "bright_red", "bright_green", "bright_yellow", "bright_blue", 182 | # "bright_magenta", "bright_cyan", "bright_white" 183 | # 256color ANSI code: { color256 = 50 } 184 | # RGB: { rgb = [0, 255, 255] } 185 | # Hex: { hex = "#ffffff" } 186 | color = "magenta" 187 | background = "default" 188 | bold = true 189 | underline = false 190 | italic = false 191 | dim = false 192 | strikethrough = false 193 | 194 | # Style for the description of the page. 195 | [style.description] 196 | color = "magenta" 197 | background = "default" 198 | bold = false 199 | underline = false 200 | italic = false 201 | dim = false 202 | strikethrough = false 203 | 204 | # Style for descriptions of examples. 205 | [style.bullet] 206 | color = "green" 207 | background = "default" 208 | bold = false 209 | underline = false 210 | italic = false 211 | dim = false 212 | strikethrough = false 213 | 214 | # Style for command examples. 215 | [style.example] 216 | color = "cyan" 217 | background = "default" 218 | bold = false 219 | underline = false 220 | italic = false 221 | dim = false 222 | strikethrough = false 223 | 224 | # Style for URLs inside the description. 225 | [style.url] 226 | color = "red" 227 | background = "default" 228 | bold = false 229 | underline = false 230 | italic = true 231 | dim = false 232 | strikethrough = false 233 | 234 | # Style for text surrounded by backticks (`). 235 | [style.inline_code] 236 | color = "yellow" 237 | background = "default" 238 | bold = false 239 | underline = false 240 | italic = true 241 | dim = false 242 | strikethrough = false 243 | 244 | # Style for placeholders inside command examples. 245 | [style.placeholder] 246 | color = "red" 247 | background = "default" 248 | bold = false 249 | underline = false 250 | italic = true 251 | dim = false 252 | strikethrough = false 253 | ``` 254 | 255 | For a style similar to [tldr-python-client](https://github.com/tldr-pages/tldr-python-client), add this to your config: 256 | 257 | ```toml 258 | [output] 259 | show_hyphens = true 260 | compact = true 261 | 262 | [style] 263 | title.color = "default" 264 | title.bold = true 265 | description.color = "default" 266 | bullet.color = "green" 267 | example.color = "red" 268 | placeholder.color = "default" 269 | ``` 270 | 271 | [latest-release]: https://github.com/tldr-pages/tlrc/releases/latest 272 | [crate]: https://crates.io/crates/tlrc 273 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::env; 3 | use std::ffi::OsStr; 4 | use std::fmt::Write as _; 5 | use std::io::{self, IsTerminal, Write}; 6 | use std::iter; 7 | use std::mem; 8 | use std::path::Path; 9 | 10 | use clap::ColorChoice; 11 | use log::debug; 12 | use ring::digest::{digest, SHA256}; 13 | 14 | /// A simple logger for the `log` crate that logs to stderr. 15 | pub struct Logger; 16 | 17 | impl Logger { 18 | pub fn init(quiet: bool, verbose: u8) { 19 | let lvl = match (quiet, verbose) { 20 | (true, _) => log::LevelFilter::Error, // --quiet 21 | (false, 0) => log::LevelFilter::Info, // default log level 22 | (false, 1) => log::LevelFilter::Debug, // --verbose 23 | (false, _) => log::LevelFilter::Trace, // --verbose --verbose 24 | }; 25 | 26 | // The logger isn't set anywhere else, this is safe to unwrap. 27 | log::set_logger(&Self).unwrap(); 28 | log::set_max_level(lvl); 29 | } 30 | } 31 | 32 | impl log::Log for Logger { 33 | fn enabled(&self, _metadata: &log::Metadata) -> bool { 34 | // This isn't needed, log::set_max_level is enough for such a simple use case. 35 | true 36 | } 37 | 38 | // stderr is flushed on every writeln! call. 39 | fn flush(&self) {} 40 | 41 | fn log(&self, record: &log::Record) { 42 | use yansi::Paint; 43 | 44 | let level = match record.level() { 45 | log::Level::Trace => "trace:".blue().bold(), 46 | log::Level::Debug => "debug:".magenta().bold(), 47 | log::Level::Info => "info:".cyan().bold(), 48 | log::Level::Warn => "warning:".yellow().bold(), 49 | log::Level::Error => "error:".red().bold(), 50 | }; 51 | 52 | let _ = match record.target() { 53 | t if t.starts_with("tldr") => writeln!(io::stderr(), "{level} {}", record.args()), 54 | t => writeln!(io::stderr(), "{level} [{}] {}", t.bold(), record.args()), 55 | }; 56 | } 57 | } 58 | 59 | /// Print a status message without a trailing newline. 60 | /// If verbose logging is enabled, use `log::info!` normally. 61 | macro_rules! info_start { 62 | ( $( $arg:tt )* ) => { 63 | if log::log_enabled!(log::Level::Debug) { 64 | log::info!($($arg)*); 65 | } else if log::log_enabled!(log::Level::Info) { 66 | use std::io::Write; 67 | use yansi::Paint; 68 | let mut stderr = std::io::stderr().lock(); 69 | let _ = write!(stderr, "{} ", "info:".cyan().bold()); 70 | let _ = write!(stderr, $($arg)*); 71 | } 72 | }; 73 | } 74 | 75 | /// End the status message started using `info_start`. 76 | /// If verbose logging is enabled, do nothing. 77 | macro_rules! info_end { 78 | ( $( $arg:tt )* ) => { 79 | if !log::log_enabled!(log::Level::Debug) && log::log_enabled!(log::Level::Info) { 80 | use std::io::Write; 81 | let _ = writeln!(std::io::stderr(), $($arg)*); 82 | } 83 | }; 84 | } 85 | 86 | pub(crate) use {info_end, info_start}; 87 | 88 | /// Get languages from environment variables according to the tldr client specification. 89 | pub fn get_languages_from_env(out_vec: &mut Vec) { 90 | // https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language 91 | 92 | let Ok(var_lang) = env::var("LANG") else { 93 | debug!("LANG is not set, cannot get languages from env vars"); 94 | return; 95 | }; 96 | 97 | let var_language = env::var("LANGUAGE"); 98 | 99 | let languages = var_language 100 | .as_deref() 101 | .unwrap_or_default() 102 | .split_terminator(':') 103 | .chain(iter::once(&*var_lang)); 104 | 105 | for lang in languages { 106 | if lang.len() >= 5 && lang.chars().nth(2) == Some('_') { 107 | // _ (ll_CC - 5 characters) 108 | out_vec.push(lang[..5].to_string()); 109 | // (ll - 2 characters) 110 | out_vec.push(lang[..2].to_string()); 111 | } else if lang.len() == 2 { 112 | out_vec.push(lang.to_string()); 113 | } else { 114 | debug!("invalid language found in LANG or LANGUAGE: '{lang}'"); 115 | } 116 | } 117 | } 118 | 119 | /// Initialize color outputting. 120 | pub fn init_color(color_mode: ColorChoice) { 121 | match color_mode { 122 | ColorChoice::Always => {} 123 | ColorChoice::Never => yansi::disable(), 124 | ColorChoice::Auto => { 125 | let no_color = env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty()); 126 | 127 | if no_color || !io::stdout().is_terminal() || !io::stderr().is_terminal() { 128 | yansi::disable(); 129 | } 130 | } 131 | } 132 | } 133 | 134 | pub trait Dedup { 135 | /// Deduplicate a vector in place preserving the order of elements. 136 | fn dedup_nosort(&mut self); 137 | } 138 | 139 | impl Dedup for Vec 140 | where 141 | T: PartialEq, 142 | { 143 | fn dedup_nosort(&mut self) { 144 | let old = mem::replace(self, Vec::with_capacity(self.len())); 145 | for x in old { 146 | if !self.contains(&x) { 147 | self.push(x); 148 | } 149 | } 150 | } 151 | } 152 | 153 | pub trait PagePathExt { 154 | /// Extracts the page name from its path. 155 | fn page_name(&self) -> Option>; 156 | /// Extracts the platform from the page path. 157 | fn page_platform(&self) -> Option>; 158 | } 159 | 160 | impl PagePathExt for Path { 161 | fn page_name(&self) -> Option> { 162 | self.file_stem().map(OsStr::to_string_lossy) 163 | } 164 | 165 | fn page_platform(&self) -> Option> { 166 | self.parent() 167 | .and_then(|parent| parent.file_name().map(OsStr::to_string_lossy)) 168 | } 169 | } 170 | 171 | /// Calculates the SHA256 hash and returns a hexadecimal string. 172 | pub fn sha256_hexdigest(data: &[u8]) -> String { 173 | let digest = digest(&SHA256, data); 174 | let mut hex = String::with_capacity(64); 175 | 176 | for part in digest.as_ref() { 177 | let _ = write!(hex, "{part:02x}"); 178 | } 179 | 180 | hex 181 | } 182 | 183 | const DAY: u64 = 86400; 184 | const HOUR: u64 = 3600; 185 | const MINUTE: u64 = 60; 186 | 187 | /// Convert time in seconds to a human-readable `String`. 188 | pub fn duration_fmt(mut secs: u64) -> String { 189 | let days = secs / DAY; 190 | secs %= DAY; 191 | let hours = secs / HOUR; 192 | 193 | if days == 0 { 194 | secs %= HOUR; 195 | let minutes = secs / MINUTE; 196 | 197 | if hours == 0 { 198 | if minutes == 0 { 199 | format!("{secs}s") 200 | } else { 201 | secs %= MINUTE; 202 | 203 | if secs == 0 { 204 | format!("{minutes}min") 205 | } else { 206 | format!("{minutes}min, {secs}s") 207 | } 208 | } 209 | } else if minutes == 0 { 210 | format!("{hours}h") 211 | } else { 212 | format!("{hours}h, {minutes}min") 213 | } 214 | } else if hours == 0 { 215 | format!("{days}d") 216 | } else { 217 | format!("{days}d, {hours}h") 218 | } 219 | } 220 | 221 | #[cfg(test)] 222 | mod tests { 223 | use super::*; 224 | use std::env; 225 | 226 | fn prepare_env(lang: Option<&str>, language: Option<&str>) { 227 | if let Some(lang) = lang { 228 | env::set_var("LANG", lang); 229 | } else { 230 | env::remove_var("LANG"); 231 | } 232 | 233 | if let Some(language) = language { 234 | env::set_var("LANGUAGE", language); 235 | } else { 236 | env::remove_var("LANGUAGE"); 237 | } 238 | } 239 | 240 | #[test] 241 | fn env_languages() { 242 | // This vector contains duplicates - de-dupping is done in cache.update() 243 | // and cache.find(), because update() requires a sorted vector, whereas 244 | // find() - an unsorted one. 245 | let mut out_vec = vec![]; 246 | 247 | prepare_env(Some("cz"), Some("it:cz:de")); 248 | get_languages_from_env(&mut out_vec); 249 | assert_eq!(out_vec, ["it", "cz", "de", "cz"]); 250 | 251 | prepare_env(Some("cz"), Some("it:de:fr")); 252 | out_vec.clear(); 253 | get_languages_from_env(&mut out_vec); 254 | assert_eq!(out_vec, ["it", "de", "fr", "cz"]); 255 | 256 | prepare_env(Some("it"), None); 257 | out_vec.clear(); 258 | get_languages_from_env(&mut out_vec); 259 | assert_eq!(out_vec, ["it"]); 260 | 261 | prepare_env(None, Some("it:cz")); 262 | out_vec.clear(); 263 | get_languages_from_env(&mut out_vec); 264 | assert!(out_vec.is_empty()); 265 | 266 | prepare_env(None, None); 267 | out_vec.clear(); 268 | get_languages_from_env(&mut out_vec); 269 | assert!(out_vec.is_empty()); 270 | 271 | prepare_env(Some("en_US.UTF-8"), Some("de_DE.UTF-8:pl:en")); 272 | out_vec.clear(); 273 | get_languages_from_env(&mut out_vec); 274 | assert_eq!(out_vec, ["de_DE", "de", "pl", "en", "en_US", "en"]); 275 | } 276 | 277 | #[test] 278 | fn sha256() { 279 | assert_eq!( 280 | sha256_hexdigest(b"This is a test."), 281 | "a8a2f6ebe286697c527eb35a58b5539532e9b3ae3b64d4eb0a46fb657b41562c" 282 | ); 283 | } 284 | 285 | #[test] 286 | fn dur_fmt() { 287 | const SECOND: u64 = 1; 288 | 289 | assert_eq!(duration_fmt(SECOND), "1s"); 290 | 291 | assert_eq!(duration_fmt(MINUTE), "1min"); 292 | assert_eq!(duration_fmt(MINUTE + SECOND), "1min, 1s"); 293 | 294 | assert_eq!(duration_fmt(HOUR), "1h"); 295 | assert_eq!(duration_fmt(HOUR + SECOND), "1h"); 296 | assert_eq!(duration_fmt(HOUR + MINUTE), "1h, 1min"); 297 | assert_eq!(duration_fmt(HOUR + MINUTE + SECOND), "1h, 1min"); 298 | 299 | assert_eq!(duration_fmt(DAY), "1d"); 300 | assert_eq!(duration_fmt(DAY + SECOND), "1d"); 301 | assert_eq!(duration_fmt(DAY + HOUR), "1d, 1h"); 302 | assert_eq!(duration_fmt(DAY + HOUR + SECOND), "1d, 1h"); 303 | } 304 | 305 | #[test] 306 | fn page_path_and_platform() { 307 | let p = Path::new("/home/user/.cache/tlrc/pages.lang/platform/page.md"); 308 | assert_eq!(p.page_name(), Some("page".into())); 309 | assert_eq!(p.page_platform(), Some("platform".into())); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::env; 3 | use std::fs; 4 | use std::io::{self, Write}; 5 | use std::path::{Path, PathBuf}; 6 | use std::time::Duration; 7 | 8 | use log::{debug, warn}; 9 | use serde::de::{Unexpected, Visitor}; 10 | use serde::{Deserialize, Deserializer, Serialize}; 11 | use yansi::{Color, Style}; 12 | 13 | use crate::cache::Cache; 14 | use crate::error::{Error, ErrorKind, Result}; 15 | use crate::util; 16 | 17 | fn hex_to_rgb<'de, D>(deserializer: D) -> std::result::Result<[u8; 3], D::Error> 18 | where 19 | D: Deserializer<'de>, 20 | { 21 | const HEX_ERR: &str = "6 hexadecimal digits, optionally prefixed with '#'"; 22 | 23 | struct HexColorVisitor; 24 | impl Visitor<'_> for HexColorVisitor { 25 | type Value = [u8; 3]; 26 | 27 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 28 | formatter.write_str(HEX_ERR) 29 | } 30 | 31 | fn visit_str(self, v: &str) -> std::result::Result 32 | where 33 | E: serde::de::Error, 34 | { 35 | let hex = v.strip_prefix('#').unwrap_or(v); 36 | 37 | if hex.len() != 6 { 38 | return Err(serde::de::Error::invalid_length(hex.len(), &HEX_ERR)); 39 | } 40 | 41 | let invalid_val = |_| serde::de::Error::invalid_value(Unexpected::Str(v), &HEX_ERR); 42 | let r = u8::from_str_radix(&hex[0..2], 16).map_err(invalid_val)?; 43 | let g = u8::from_str_radix(&hex[2..4], 16).map_err(invalid_val)?; 44 | let b = u8::from_str_radix(&hex[4..6], 16).map_err(invalid_val)?; 45 | 46 | Ok([r, g, b]) 47 | } 48 | } 49 | 50 | deserializer.deserialize_str(HexColorVisitor) 51 | } 52 | 53 | #[derive(Serialize, Deserialize, Default, Clone, Copy)] 54 | #[serde(rename_all = "snake_case")] 55 | pub enum OutputColor { 56 | Black, 57 | Red, 58 | Green, 59 | Yellow, 60 | Blue, 61 | Magenta, 62 | Cyan, 63 | White, 64 | #[default] 65 | Default, 66 | BrightBlack, 67 | BrightRed, 68 | BrightGreen, 69 | BrightYellow, 70 | BrightBlue, 71 | BrightMagenta, 72 | BrightCyan, 73 | BrightWhite, 74 | Color256(u8), 75 | Rgb([u8; 3]), 76 | #[serde(deserialize_with = "hex_to_rgb")] 77 | Hex([u8; 3]), 78 | } 79 | 80 | impl From for yansi::Color { 81 | fn from(c: OutputColor) -> Self { 82 | match c { 83 | OutputColor::Black => Color::Black, 84 | OutputColor::Red => Color::Red, 85 | OutputColor::Green => Color::Green, 86 | OutputColor::Yellow => Color::Yellow, 87 | OutputColor::Blue => Color::Blue, 88 | OutputColor::Magenta => Color::Magenta, 89 | OutputColor::Cyan => Color::Cyan, 90 | OutputColor::White => Color::White, 91 | OutputColor::Default => Color::Primary, 92 | OutputColor::BrightBlack => Color::BrightBlack, 93 | OutputColor::BrightRed => Color::BrightRed, 94 | OutputColor::BrightGreen => Color::BrightGreen, 95 | OutputColor::BrightYellow => Color::BrightYellow, 96 | OutputColor::BrightBlue => Color::BrightBlue, 97 | OutputColor::BrightMagenta => Color::BrightMagenta, 98 | OutputColor::BrightCyan => Color::BrightCyan, 99 | OutputColor::BrightWhite => Color::BrightWhite, 100 | OutputColor::Color256(c) => Color::Fixed(c), 101 | OutputColor::Rgb(rgb) | OutputColor::Hex(rgb) => Color::Rgb(rgb[0], rgb[1], rgb[2]), 102 | } 103 | } 104 | } 105 | 106 | #[derive(Serialize, Deserialize, Default, Clone, Copy)] 107 | #[serde(deny_unknown_fields, default)] 108 | pub struct OutputStyle { 109 | pub color: OutputColor, 110 | pub background: OutputColor, 111 | pub bold: bool, 112 | pub underline: bool, 113 | pub italic: bool, 114 | pub dim: bool, 115 | pub strikethrough: bool, 116 | } 117 | 118 | impl From for yansi::Style { 119 | fn from(s: OutputStyle) -> Self { 120 | let mut style = Style::new().fg(s.color.into()).bg(s.background.into()); 121 | 122 | if s.bold { 123 | style = style.bold(); 124 | } 125 | if s.italic { 126 | style = style.italic(); 127 | } 128 | if s.underline { 129 | style = style.underline(); 130 | } 131 | if s.dim { 132 | style = style.dim(); 133 | } 134 | if s.strikethrough { 135 | style = style.strike(); 136 | } 137 | 138 | style 139 | } 140 | } 141 | 142 | #[derive(Serialize, Deserialize)] 143 | #[serde(deny_unknown_fields, default)] 144 | pub struct StyleConfig { 145 | pub title: OutputStyle, 146 | pub description: OutputStyle, 147 | pub bullet: OutputStyle, 148 | pub example: OutputStyle, 149 | pub url: OutputStyle, 150 | pub inline_code: OutputStyle, 151 | pub placeholder: OutputStyle, 152 | } 153 | 154 | impl Default for StyleConfig { 155 | fn default() -> Self { 156 | StyleConfig { 157 | title: OutputStyle { 158 | color: OutputColor::Magenta, 159 | background: OutputColor::default(), 160 | bold: true, 161 | underline: false, 162 | italic: false, 163 | dim: false, 164 | strikethrough: false, 165 | }, 166 | description: OutputStyle { 167 | color: OutputColor::Magenta, 168 | background: OutputColor::default(), 169 | bold: false, 170 | underline: false, 171 | italic: false, 172 | dim: false, 173 | strikethrough: false, 174 | }, 175 | bullet: OutputStyle { 176 | color: OutputColor::Green, 177 | background: OutputColor::default(), 178 | bold: false, 179 | underline: false, 180 | italic: false, 181 | dim: false, 182 | strikethrough: false, 183 | }, 184 | example: OutputStyle { 185 | color: OutputColor::Cyan, 186 | background: OutputColor::default(), 187 | bold: false, 188 | underline: false, 189 | italic: false, 190 | dim: false, 191 | strikethrough: false, 192 | }, 193 | url: OutputStyle { 194 | color: OutputColor::Red, 195 | background: OutputColor::default(), 196 | bold: false, 197 | underline: false, 198 | italic: true, 199 | dim: false, 200 | strikethrough: false, 201 | }, 202 | inline_code: OutputStyle { 203 | color: OutputColor::Yellow, 204 | background: OutputColor::default(), 205 | bold: false, 206 | underline: false, 207 | italic: true, 208 | dim: false, 209 | strikethrough: false, 210 | }, 211 | placeholder: OutputStyle { 212 | color: OutputColor::Red, 213 | background: OutputColor::default(), 214 | bold: false, 215 | underline: false, 216 | italic: true, 217 | dim: false, 218 | strikethrough: false, 219 | }, 220 | } 221 | } 222 | } 223 | 224 | #[derive(Serialize, Deserialize)] 225 | #[serde(deny_unknown_fields, default)] 226 | pub struct CacheConfig { 227 | /// Cache directory. 228 | pub dir: PathBuf, 229 | /// The mirror of tldr-pages to use. 230 | pub mirror: Cow<'static, str>, 231 | /// Automatically update the cache 232 | /// if it is older than `max_age` hours. 233 | pub auto_update: bool, 234 | /// Perform the automatic update after the page is shown. 235 | pub defer_auto_update: bool, 236 | /// Max cache age in hours. 237 | max_age: u64, 238 | /// Languages to download. 239 | pub languages: Vec, 240 | } 241 | 242 | impl Default for CacheConfig { 243 | fn default() -> Self { 244 | Self { 245 | dir: Cache::locate(), 246 | mirror: Cow::Borrowed("https://github.com/tldr-pages/tldr/releases/latest/download"), 247 | auto_update: true, 248 | defer_auto_update: false, 249 | // 2 weeks 250 | max_age: 24 * 7 * 2, 251 | languages: vec![], 252 | } 253 | } 254 | } 255 | 256 | /// Defines which options should be shown in short|long placeholders (`{{[ ... ]}}`). 257 | #[derive(Serialize, Deserialize, PartialEq, Default)] 258 | #[serde(rename_all = "lowercase")] 259 | pub enum OptionStyle { 260 | Short, 261 | #[default] 262 | Long, 263 | Both, 264 | } 265 | 266 | #[derive(Serialize, Deserialize)] 267 | #[serde(deny_unknown_fields, default)] 268 | pub struct OutputConfig { 269 | /// Show the page title. 270 | pub show_title: bool, 271 | /// Show the platform in the title. 272 | pub platform_title: bool, 273 | /// Show hyphens before example descriptions. 274 | pub show_hyphens: bool, 275 | /// Display a link to edit the shown page on GitHub. 276 | pub edit_link: bool, 277 | /// Show a custom string instead of a hyphen. 278 | pub example_prefix: Cow<'static, str>, 279 | /// Set the max line length. 0 means to use the terminal width. 280 | pub line_length: usize, 281 | /// Strip empty lines from pages. 282 | pub compact: bool, 283 | /// Display the specified options in pages wherever possible. 284 | pub option_style: OptionStyle, 285 | /// Print pages in raw markdown. 286 | pub raw_markdown: bool, 287 | } 288 | 289 | impl Default for OutputConfig { 290 | fn default() -> Self { 291 | Self { 292 | show_title: true, 293 | platform_title: false, 294 | show_hyphens: false, 295 | edit_link: false, 296 | example_prefix: Cow::Borrowed("- "), 297 | line_length: 0, 298 | compact: false, 299 | option_style: OptionStyle::default(), 300 | raw_markdown: false, 301 | } 302 | } 303 | } 304 | 305 | #[derive(Serialize, Deserialize)] 306 | #[serde(deny_unknown_fields, default)] 307 | pub struct IndentConfig { 308 | pub title: usize, 309 | pub description: usize, 310 | pub bullet: usize, 311 | pub example: usize, 312 | } 313 | 314 | impl Default for IndentConfig { 315 | fn default() -> Self { 316 | Self { 317 | title: 2, 318 | description: 2, 319 | bullet: 2, 320 | example: 4, 321 | } 322 | } 323 | } 324 | 325 | #[derive(Serialize, Deserialize, Default)] 326 | #[serde(deny_unknown_fields, default)] 327 | pub struct Config { 328 | pub cache: CacheConfig, 329 | pub output: OutputConfig, 330 | pub indent: IndentConfig, 331 | pub style: StyleConfig, 332 | } 333 | 334 | impl Config { 335 | fn parse(path: &Path) -> Result { 336 | Ok(toml::from_str(&fs::read_to_string(path).map_err(|e| { 337 | Error::new(format!("'{}': {e}", path.display())).kind(ErrorKind::Io) 338 | })?)?) 339 | } 340 | 341 | pub fn new(cli_config_path: Option<&Path>) -> Result { 342 | let cfg_res = if let Some(path) = cli_config_path { 343 | if path.is_file() { 344 | debug!("config file (from --config): {path:?}"); 345 | Self::parse(path) 346 | } else { 347 | warn!("'{}': not a file, ignoring --config", path.display()); 348 | Ok(Self::default()) 349 | } 350 | } else { 351 | let path = Self::locate(); 352 | if path.is_file() { 353 | debug!("config file found: {path:?}"); 354 | Self::parse(&path) 355 | } else { 356 | debug!("{path:?}: not a file, using the default config"); 357 | Ok(Self::default()) 358 | } 359 | }; 360 | 361 | cfg_res.map(|mut cfg| { 362 | if cfg.cache.languages.is_empty() { 363 | debug!("languages not found in config, trying from env vars"); 364 | util::get_languages_from_env(&mut cfg.cache.languages); 365 | } 366 | // English pages should always be downloaded and searched. 367 | cfg.cache.languages.push("en".to_string()); 368 | 369 | if cfg.cache.dir.starts_with("~") { 370 | let mut p = dirs::home_dir().unwrap(); 371 | p.extend(cfg.cache.dir.components().skip(1)); 372 | cfg.cache.dir = p; 373 | } 374 | cfg 375 | }) 376 | } 377 | 378 | /// Get the default path to the config file. 379 | pub fn locate() -> PathBuf { 380 | env::var_os("TLRC_CONFIG").map_or_else( 381 | || { 382 | dirs::config_dir() 383 | .unwrap() 384 | .join(env!("CARGO_PKG_NAME")) 385 | .join("config.toml") 386 | }, 387 | PathBuf::from, 388 | ) 389 | } 390 | 391 | /// Print the default path to the config file and create the config directory. 392 | pub fn print_path() -> Result<()> { 393 | let config_path = Config::locate(); 394 | writeln!(io::stdout(), "{}", config_path.display())?; 395 | 396 | fs::create_dir_all(config_path.parent().ok_or_else(|| { 397 | Error::new("cannot create the config directory: the path has only one component") 398 | })?)?; 399 | 400 | Ok(()) 401 | } 402 | 403 | /// Print the default config. 404 | pub fn print_default() -> Result<()> { 405 | let mut cfg = Config::default(); 406 | let home = dirs::home_dir().unwrap(); 407 | 408 | if cfg.cache.dir.starts_with(&home) { 409 | let rel_part = cfg.cache.dir.strip_prefix(&home).unwrap(); 410 | cfg.cache.dir = Path::new("~").join(rel_part); 411 | } 412 | 413 | let cfg = toml::ser::to_string_pretty(&cfg).unwrap(); 414 | write!(io::stdout(), "{cfg}")?; 415 | Ok(()) 416 | } 417 | 418 | /// Convert the number of hours from config to a `Duration`. 419 | pub const fn cache_max_age(&self) -> Duration { 420 | Duration::from_secs(self.cache.max_age * 60 * 60) 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/output.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::fmt::Write as _; 3 | use std::fs::File; 4 | use std::io::{self, BufRead, BufReader, BufWriter, Write}; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use log::{info, log_enabled, warn}; 8 | use terminal_size::terminal_size; 9 | use unicode_width::UnicodeWidthStr; 10 | use yansi::{Paint, Style}; 11 | 12 | use crate::config::{Config, OptionStyle}; 13 | use crate::error::{Error, ErrorKind, Result}; 14 | use crate::util::PagePathExt; 15 | 16 | const TITLE: &str = "# "; 17 | const DESC: &str = "> "; 18 | const BULLET: &str = "- "; 19 | const EXAMPLE: char = '`'; 20 | 21 | struct RenderStyles { 22 | title: Style, 23 | desc: Style, 24 | bullet: Style, 25 | example: Style, 26 | url: Style, 27 | inline_code: Style, 28 | placeholder: Style, 29 | } 30 | 31 | pub struct PageRenderer<'a> { 32 | /// Path to the page. 33 | path: &'a Path, 34 | /// A buffered reader containing the page. 35 | reader: BufReader, 36 | /// A buffered handle to standard output. 37 | stdout: BufWriter>, 38 | /// The line of the page that is currently being worked with. 39 | current_line: String, 40 | /// The line number of the current line. 41 | lnum: usize, 42 | /// Max line length. 43 | max_len: Option, 44 | /// Style configuration. 45 | style: RenderStyles, 46 | /// Other options. 47 | cfg: &'a Config, 48 | } 49 | 50 | /// Write a `yansi::Painted` to a `String`. 51 | /// 52 | /// This is used to append something to a `String` without creating `String`s for every part of a 53 | /// line that's highlighted using a different style. 54 | macro_rules! write_paint { 55 | ($buf:expr, $what:expr) => { 56 | // This will never return an error, we're writing to a `String`. 57 | let _ = write!($buf, "{}", $what); 58 | }; 59 | } 60 | 61 | /// Type of the line. 62 | /// Does not include types that aren't wrapped (i.e. title, empty lines and examples). 63 | #[derive(Clone, Copy, PartialEq)] 64 | enum LineType { 65 | Desc, 66 | Bullet, 67 | } 68 | 69 | impl<'a> PageRenderer<'a> { 70 | fn hl_code(&self, s: &str, style_normal: Style) -> String { 71 | let split: Vec<&str> = s.split('`').collect(); 72 | let mut buf = String::with_capacity(s.len()); 73 | 74 | // Highlight beginning not found. 75 | if split.len() == 1 { 76 | write_paint!(buf, s.paint(style_normal)); 77 | return buf; 78 | } 79 | 80 | for (i, part) in split.into_iter().enumerate() { 81 | // Only odd indexes contain the part to be highlighted. 82 | // "aa `bb` cc `dd` ee" 83 | // 0: "aa " 84 | // 1: "bb" (highlighted) 85 | // 2: " cc " 86 | // 3: "dd" (highlighted) 87 | // 4: " ee" 88 | if i % 2 == 0 { 89 | write_paint!(buf, part.paint(style_normal)); 90 | } else { 91 | write_paint!(buf, part.paint(self.style.inline_code)); 92 | } 93 | } 94 | 95 | buf 96 | } 97 | 98 | fn hl_url(&self, s: &str, style_normal: Style) -> String { 99 | let split: Vec<&str> = s.split("') { 110 | // The first part of the second split contains the part to be highlighted. 111 | // 112 | // "More information: ." 113 | // 0: "More information: " => does not match 114 | // 1: "s://example.com>." => 0: "s://example.com" (highlighted) 115 | // 1: ">." 116 | let part_split = part.split_once('>').unwrap(); 117 | 118 | // " String { 132 | let split: Vec<&str> = s.split("{{").collect(); 133 | let mut buf = String::with_capacity(s.len()); 134 | 135 | // Highlight beginning not found. 136 | if split.len() == 1 { 137 | write_paint!(buf, s.paint(style_normal)); 138 | return buf; 139 | } 140 | 141 | for part in split { 142 | // Finding the last double ending brace is required for special cases with three 143 | // closing curly braces ("}}}"). 144 | // The first brace is inside the placeholder, and the last two mark the end of it. 145 | if let Some(idx) = part.rfind("}}") { 146 | // The first part of the second split contains the part to be highlighted. 147 | // 148 | // "aa bb {{cc}} {{dd}} ee" 149 | // 0: "aa bb " => does not match 150 | // 1: "cc}} " => 0: "cc" (highlighted) 151 | // 1: "}}" 152 | // 2: "dd}} ee" => 0: "dd" (highlighted) 153 | // 1: "}} ee" 154 | let (inside, outside) = part.split_at(idx); 155 | 156 | // Select the long or short option. 157 | // Skip if the user wants to display both or if the placeholder doesn't contain 158 | // option selection (`[-s|--long]`). 159 | if self.cfg.output.option_style != OptionStyle::Both 160 | && inside.starts_with('[') 161 | && inside.ends_with(']') 162 | && inside.contains('|') 163 | { 164 | let (short, long) = inside.split_once('|').unwrap(); 165 | // A single option will be displayed, using the normal style (static part). 166 | if self.cfg.output.option_style == OptionStyle::Short { 167 | // Cut out the leading `[`. 168 | write_paint!(buf, &short[1..].paint(style_normal)); 169 | } else { 170 | // Cut out the trailing `]`. 171 | write_paint!(buf, &long[..long.len() - 1].paint(style_normal)); 172 | } 173 | } else { 174 | // Both options will be displayed, or this isn't an option placeholder. 175 | // The placeholder style is used in both cases. 176 | write_paint!(buf, inside.paint(self.style.placeholder)); 177 | } 178 | 179 | // `outside` begins with "}}". We need to cut that out. 180 | write_paint!(buf, &outside[2..].paint(style_normal)); 181 | } else { 182 | // Highlight ending not found. 183 | write_paint!(buf, part.paint(style_normal)); 184 | } 185 | } 186 | 187 | buf 188 | } 189 | 190 | /// Split the line into multiple lines if it's longer than the configured max length. 191 | fn splitln(&self, s: &'a str, indent: &str, ltype: LineType) -> Cow<'a, str> { 192 | let Some(max_len) = self.max_len else { 193 | // We don't have the max length. Just print the entire line then. 194 | return Cow::Borrowed(s); 195 | }; 196 | 197 | let len_indent = indent.len(); 198 | let prefix_width = if ltype == LineType::Bullet && self.cfg.output.show_hyphens { 199 | self.cfg.output.example_prefix.width() 200 | } else { 201 | 0 202 | }; 203 | let base_width = len_indent + prefix_width; 204 | 205 | if base_width + s.width() <= max_len { 206 | // The line is shorter than the max length. There is nothing to do. 207 | return Cow::Borrowed(s); 208 | } 209 | 210 | let words = s.split(' '); 211 | let len_s = s.len(); 212 | let mut cur_width = base_width; 213 | // current_len + base_width * amount of added newlines 214 | let mut buf = String::with_capacity(len_s + base_width * (len_s / max_len)); 215 | 216 | // If the example prefix is set, we need more whitespace at the beginning of the next line. 217 | let indent = if prefix_width == 0 { 218 | Cow::Borrowed(indent) 219 | } else { 220 | Cow::Owned(" ".repeat(prefix_width) + indent) 221 | }; 222 | 223 | // Is the current word highlighted (inside backticks)? 224 | let mut inside_hl = false; 225 | 226 | let style_normal = match ltype { 227 | LineType::Desc => self.style.desc, 228 | LineType::Bullet => self.style.bullet, 229 | }; 230 | 231 | for w in words { 232 | let mut w_width = w.width(); 233 | let backtick_count = w.chars().filter(|x| *x == '`').count(); 234 | // Backticks won't be displayed. 235 | w_width -= backtick_count; 236 | 237 | // current + word + space after the word 238 | if cur_width + w_width + 1 > max_len && cur_width != base_width { 239 | // If the next word is added, the line will be longer than the configured line 240 | // length. 241 | // 242 | // We need to add a newline + indentation, and reset the current length. 243 | if yansi::is_enabled() { 244 | // Style reset. Without this, whitespace will have a background color (if one 245 | // is set). 246 | let _ = style_normal.fmt_suffix(&mut buf); 247 | } 248 | buf.push('\n'); 249 | buf += &indent; 250 | if yansi::is_enabled() { 251 | // Reenable the style. 252 | let _ = if inside_hl { 253 | self.style.inline_code.fmt_prefix(&mut buf) 254 | } else { 255 | style_normal.fmt_prefix(&mut buf) 256 | }; 257 | } 258 | cur_width = base_width; 259 | } else if cur_width != base_width { 260 | // If this isn't the beginning of the line, add a space after the word. 261 | buf.push(' '); 262 | cur_width += 1; 263 | } 264 | 265 | buf += w; 266 | cur_width += w_width; 267 | 268 | // If there are two backticks in `w`, then `w` contains all the highlighted text. 269 | // If there is only one (e.g `ab cd` => split into words ["`ab", "cd`"]), it 270 | // starts/ends the highlight. 271 | if backtick_count == 1 { 272 | inside_hl = !inside_hl; 273 | } 274 | } 275 | 276 | Cow::Owned(buf) 277 | } 278 | 279 | /// Print or render the page according to the provided config. 280 | pub fn print(path: &'a Path, cfg: &'a Config) -> Result<()> { 281 | let mut page = File::open(path) 282 | .map_err(|e| Error::new(format!("'{}': {e}", path.display())).kind(ErrorKind::Io))?; 283 | 284 | if cfg.output.raw_markdown { 285 | io::copy(&mut page, &mut io::stdout()).map_err(|e| { 286 | Error::new(format!("'{}': {e}", path.display())).kind(ErrorKind::Io) 287 | })?; 288 | return Ok(()); 289 | } 290 | 291 | Self { 292 | path, 293 | reader: BufReader::new(page), 294 | stdout: BufWriter::new(io::stdout().lock()), 295 | current_line: String::new(), 296 | lnum: 0, 297 | max_len: if cfg.output.line_length == 0 { 298 | terminal_size().map(|x| x.0 .0 as usize) 299 | } else { 300 | Some(cfg.output.line_length) 301 | }, 302 | style: RenderStyles { 303 | title: cfg.style.title.into(), 304 | desc: cfg.style.description.into(), 305 | bullet: cfg.style.bullet.into(), 306 | example: cfg.style.example.into(), 307 | url: cfg.style.url.into(), 308 | inline_code: cfg.style.inline_code.into(), 309 | placeholder: cfg.style.placeholder.into(), 310 | }, 311 | cfg, 312 | } 313 | .render() 314 | } 315 | 316 | /// Print the first page that was found and warnings for every other page. 317 | pub fn print_cache_result(paths: &'a [PathBuf], cfg: &'a Config) -> Result<()> { 318 | if log_enabled!(log::Level::Warn) && paths.len() != 1 { 319 | let mut stderr = io::stderr().lock(); 320 | let other_pages = &paths[1..]; 321 | let width = other_pages 322 | .iter() 323 | .map(|x| x.page_platform().unwrap().len()) 324 | .max() 325 | .unwrap(); 326 | 327 | warn!("{} page(s) found for other platforms:", other_pages.len()); 328 | 329 | for (i, path) in other_pages.iter().enumerate() { 330 | // The path always ends with the page file, and its parent is always the 331 | // platform directory. This is safe to unwrap. 332 | let name = path.page_name().unwrap(); 333 | let platform = path.page_platform().unwrap(); 334 | 335 | writeln!( 336 | stderr, 337 | "{} {platform: "pages", 355 | _ => &pages_dir, 356 | }; 357 | 358 | let platform = components.next().unwrap().to_string_lossy(); 359 | let filename = components.next().unwrap().to_string_lossy(); 360 | 361 | info!("edit this page on GitHub:\nhttps://github.com/tldr-pages/tldr/edit/main/{pages_dir}/{platform}/{filename}"); 362 | } 363 | 364 | Self::print(first, cfg) 365 | } 366 | 367 | /// Load the next line into the line buffer. 368 | fn next_line(&mut self) -> Result { 369 | // The `Paint` trait from yansi also has a method named `clear`. 370 | // This will be resolved in a future release: https://github.com/SergioBenitez/yansi/issues/42 371 | //self.current_line.clear(); 372 | String::clear(&mut self.current_line); 373 | self.lnum += 1; 374 | let n = self 375 | .reader 376 | .read_line(&mut self.current_line) 377 | .map_err(|e| Error::new(format!("'{}': {e}", self.path.display())))?; 378 | let len = self.current_line.trim_end().len(); 379 | self.current_line.truncate(len); 380 | 381 | Ok(n) 382 | } 383 | 384 | /// Write the current line to the page buffer as a title. 385 | fn add_title(&mut self) -> Result<()> { 386 | if !self.cfg.output.show_title { 387 | return Ok(()); 388 | } 389 | self.add_newline()?; 390 | 391 | let line = self.current_line.strip_prefix(TITLE).unwrap(); 392 | let title = if self.cfg.output.platform_title { 393 | if let Some(platform) = self.path.page_platform() { 394 | Cow::Owned(format!("{platform}/{line}")) 395 | } else { 396 | Cow::Borrowed(line) 397 | } 398 | } else { 399 | Cow::Borrowed(line) 400 | }; 401 | 402 | let title = title.paint(self.style.title); 403 | let indent = " ".repeat(self.cfg.indent.title); 404 | writeln!(self.stdout, "{indent}{title}")?; 405 | 406 | Ok(()) 407 | } 408 | 409 | /// Write the current line to the page buffer as a description. 410 | fn add_desc(&mut self) -> Result<()> { 411 | let indent = " ".repeat(self.cfg.indent.description); 412 | let line = self.current_line.strip_prefix(DESC).unwrap(); 413 | let line = self.splitln(line, &indent, LineType::Desc); 414 | let desc = self.hl_code(&self.hl_url(&line, self.style.desc), self.style.desc); 415 | 416 | writeln!(self.stdout, "{indent}{desc}")?; 417 | 418 | Ok(()) 419 | } 420 | 421 | /// Write the current line to the page buffer as a bullet point. 422 | fn add_bullet(&mut self) -> Result<()> { 423 | let indent = " ".repeat(self.cfg.indent.bullet); 424 | let line = self.current_line.strip_prefix(BULLET).unwrap(); 425 | let line = self.splitln(line, &indent, LineType::Bullet); 426 | 427 | let bullet = self.hl_code(&self.hl_url(&line, self.style.bullet), self.style.bullet); 428 | write!(self.stdout, "{indent}")?; 429 | if self.cfg.output.show_hyphens { 430 | let prefix = self.cfg.output.example_prefix.paint(self.style.bullet); 431 | write!(self.stdout, "{prefix}")?; 432 | } 433 | writeln!(self.stdout, "{bullet}")?; 434 | 435 | Ok(()) 436 | } 437 | 438 | /// Write the current line to the page buffer as an example. 439 | fn add_example(&mut self) -> Result<()> { 440 | // Add spaces around escaped curly braces in order not to 441 | // interpret them as a placeholder (e.g. in "\{\{{{ }}\}\}"). 442 | self.current_line = self 443 | .current_line 444 | .replace("\\{\\{", " \\{\\{ ") 445 | .replace("\\}\\}", " \\}\\} "); 446 | 447 | let indent = " ".repeat(self.cfg.indent.example); 448 | let line = self 449 | .current_line 450 | .strip_prefix(EXAMPLE) 451 | .unwrap() 452 | .strip_suffix('`') 453 | .ok_or_else(|| { 454 | Error::parse_page(self.path, self.lnum, &self.current_line) 455 | .describe("\nEvery line with an example must end with a backtick '`'.") 456 | })?; 457 | 458 | let example = self 459 | .hl_placeholder(line, self.style.example) 460 | // Remove the extra spaces and backslashes. 461 | .replace(" \\{\\{ ", "{{") 462 | .replace(" \\}\\} ", "}}"); 463 | 464 | writeln!(self.stdout, "{indent}{example}")?; 465 | 466 | Ok(()) 467 | } 468 | 469 | /// Write a newline to the page buffer if compact mode is not turned on. 470 | fn add_newline(&mut self) -> Result<()> { 471 | if !self.cfg.output.compact { 472 | writeln!(self.stdout)?; 473 | } 474 | 475 | Ok(()) 476 | } 477 | 478 | /// Render the page to standard output. 479 | fn render(&mut self) -> Result<()> { 480 | while self.next_line()? != 0 { 481 | if self.current_line.starts_with(TITLE) { 482 | self.add_title()?; 483 | } else if self.current_line.starts_with(DESC) { 484 | self.add_desc()?; 485 | } else if self.current_line.starts_with(BULLET) { 486 | self.add_bullet()?; 487 | } else if self.current_line.starts_with(EXAMPLE) { 488 | self.add_example()?; 489 | } else if self.current_line.chars().all(char::is_whitespace) { 490 | self.add_newline()?; 491 | } else { 492 | return Err( 493 | Error::parse_page(self.path, self.lnum, &self.current_line).describe( 494 | "\nEvery non-empty line must begin with either '# ', '> ', '- ' or '`'.", 495 | ), 496 | ); 497 | } 498 | } 499 | 500 | self.add_newline()?; 501 | Ok(self.stdout.flush()?) 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use std::collections::{BTreeMap, HashMap}; 3 | use std::ffi::OsString; 4 | use std::fs::{self, File}; 5 | use std::io::{self, BufRead, BufWriter, Cursor, Write}; 6 | use std::path::{Path, PathBuf}; 7 | use std::time::Duration; 8 | 9 | use log::{debug, info, warn}; 10 | use once_cell::unsync::OnceCell; 11 | use ureq::tls::{RootCerts, TlsConfig}; 12 | use yansi::Paint; 13 | use zip::ZipArchive; 14 | 15 | use crate::config::Config; 16 | use crate::error::{Error, Result}; 17 | use crate::util::{self, info_end, info_start, Dedup}; 18 | 19 | pub const ENGLISH_DIR: &str = "pages.en"; 20 | const CHECKSUM_FILE: &str = "tldr.sha256sums"; 21 | const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), '/', env!("CARGO_PKG_VERSION")); 22 | const HTTP_TIMEOUT: Option = Some(Duration::from_secs(10)); 23 | 24 | type PagesArchive = ZipArchive>>; 25 | 26 | pub struct Cache<'a> { 27 | dir: &'a Path, 28 | platforms: OnceCell>, 29 | age: OnceCell, 30 | } 31 | 32 | impl<'a> Cache<'a> { 33 | pub fn new(dir: &'a Path) -> Self { 34 | Self { 35 | dir, 36 | platforms: OnceCell::new(), 37 | age: OnceCell::new(), 38 | } 39 | } 40 | 41 | /// Get the default path to the cache. 42 | pub fn locate() -> PathBuf { 43 | dirs::cache_dir().unwrap().join(env!("CARGO_PKG_NAME")) 44 | } 45 | 46 | /// Return `true` if the specified subdirectory exists in the cache. 47 | pub fn subdir_exists(&self, sd: &str) -> bool { 48 | self.dir.join(sd).is_dir() 49 | } 50 | 51 | /// Send a GET request with the provided agent and return the response body. 52 | fn get_asset(agent: &ureq::Agent, url: &str) -> Result> { 53 | info_start!("downloading '{}'... ", url.split('/').next_back().unwrap()); 54 | 55 | let mut resp = match agent.get(url).call() { 56 | Ok(r) => r, 57 | Err(e) => { 58 | info_end!("{}", "FAILED".red().bold()); 59 | return Err(e.into()); 60 | } 61 | }; 62 | let body = resp.body_mut(); 63 | let bytes = match body.with_config().limit(1_000_000_000).read_to_vec() { 64 | Ok(v) => v, 65 | Err(e) => { 66 | info_end!("{}", "FAILED".red().bold()); 67 | return Err(e.into()); 68 | } 69 | }; 70 | 71 | #[allow(clippy::cast_precision_loss)] 72 | let dl_kib = bytes.len() as f64 / 1024.0; 73 | if dl_kib < 1024.0 { 74 | info_end!("{:.02} KiB", dl_kib.green().bold()); 75 | } else { 76 | info_end!("{:.02} MiB", (dl_kib / 1024.0).green().bold()); 77 | } 78 | 79 | Ok(bytes) 80 | } 81 | 82 | /// Download tldr pages archives for directories that are out of date and update the checksum file. 83 | fn download_and_verify( 84 | &self, 85 | mirror: &str, 86 | languages: &[String], 87 | ) -> Result> { 88 | let agent = ureq::Agent::config_builder() 89 | .user_agent(USER_AGENT) 90 | // The global timeout isn't set, because it prevents some people from downloading 91 | // page archives. See https://github.com/tldr-pages/tlrc/issues/131. 92 | .timeout_resolve(HTTP_TIMEOUT) 93 | .timeout_connect(HTTP_TIMEOUT) 94 | .tls_config( 95 | TlsConfig::builder() 96 | .root_certs(RootCerts::PlatformVerifier) 97 | .build(), 98 | ) 99 | .build() 100 | .into(); 101 | 102 | let sums = Self::get_asset(&agent, &format!("{mirror}/{CHECKSUM_FILE}"))?; 103 | let sums_str = String::from_utf8_lossy(&sums); 104 | let sum_map = Self::parse_sumfile(&sums_str)?; 105 | debug!("sum file parsed, available languages: {:?}", sum_map.keys()); 106 | 107 | let old_sumfile_path = self.dir.join(CHECKSUM_FILE); 108 | let old_sums = fs::read_to_string(&old_sumfile_path).unwrap_or_default(); 109 | let old_sum_map = Self::parse_sumfile(&old_sums).unwrap_or_default(); 110 | 111 | let mut langdir_archive_map = BTreeMap::new(); 112 | 113 | for lang in languages { 114 | let lang = &**lang; 115 | let Some(sum) = sum_map.get(lang) else { 116 | debug!("'{lang}': language not available, skipping it"); 117 | continue; 118 | }; 119 | 120 | let lang_dir = format!("pages.{lang}"); 121 | if Some(sum) == old_sum_map.get(lang) && self.subdir_exists(&lang_dir) { 122 | info!("'{lang_dir}' is up to date"); 123 | continue; 124 | } 125 | 126 | let archive = Self::get_asset(&agent, &format!("{mirror}/tldr-pages.{lang}.zip"))?; 127 | info_start!("validating sha256sums... "); 128 | let actual_sum = util::sha256_hexdigest(&archive); 129 | 130 | if sum != &actual_sum { 131 | info_end!("{}", "FAILED".red().bold()); 132 | return Err(Error::new(format!( 133 | "SHA256 sum mismatch!\n\ 134 | expected : {sum}\n\ 135 | got : {actual_sum}" 136 | ))); 137 | } 138 | 139 | info_end!("{}", "OK".green().bold()); 140 | 141 | langdir_archive_map.insert(lang_dir, ZipArchive::new(Cursor::new(archive))?); 142 | } 143 | 144 | fs::create_dir_all(self.dir)?; 145 | File::create(&old_sumfile_path)?.write_all(&sums)?; 146 | 147 | Ok(langdir_archive_map) 148 | } 149 | 150 | fn parse_sumfile(s: &str) -> Result> { 151 | // Subtract 3, because 3 lines are skipped in the loop. 152 | let mut map = HashMap::with_capacity(s.lines().count().saturating_sub(3)); 153 | 154 | for l in s.lines() { 155 | // The file looks like this: 156 | // sha256sum tldr-pages.lang.zip 157 | // sha256sum tldr-pages.lang.zip 158 | // ... 159 | 160 | let mut spl = l.split_whitespace(); 161 | let sum = spl.next().ok_or_else(Error::parse_sumfile)?; 162 | let path = spl.next().ok_or_else(Error::parse_sumfile)?; 163 | 164 | // Skip other files, the full archive, and the old English archive. 165 | // This map is used to detect languages available to download. 166 | // Not skipping index.json makes "json" a language. 167 | // Not skipping archives without a language in the filename makes "zip" a language. 168 | if !path.ends_with("zip") 169 | || path.ends_with("tldr.zip") 170 | || path.ends_with("tldr-pages.zip") 171 | { 172 | continue; 173 | } 174 | 175 | let lang = path.split('.').nth(1).ok_or_else(Error::parse_sumfile)?; 176 | map.insert(lang, sum); 177 | } 178 | 179 | Ok(map) 180 | } 181 | 182 | /// Extract pages from the language archive and update the page counters. 183 | fn extract_lang_archive( 184 | &self, 185 | lang_dir: &str, 186 | archive: &mut PagesArchive, 187 | n_existing: i32, 188 | all_downloaded: &mut i32, 189 | all_new: &mut i32, 190 | ) -> Result<()> { 191 | info_start!("extracting '{lang_dir}'... "); 192 | 193 | let mut n_downloaded = 0; 194 | 195 | for i in 0..archive.len() { 196 | let mut zipfile = archive.by_index(i)?; 197 | let Some(fname) = zipfile.enclosed_name() else { 198 | debug!( 199 | "found an unsafe path in the zip archive: '{}', ignoring it", 200 | zipfile.name() 201 | ); 202 | continue; 203 | }; 204 | 205 | // Skip files that are not in a directory (we want only pages). 206 | if zipfile.is_file() && fname.parent() == Some(Path::new("")) { 207 | continue; 208 | } 209 | 210 | let path = self.dir.join(lang_dir).join(&fname); 211 | 212 | if zipfile.is_dir() { 213 | fs::create_dir_all(&path)?; 214 | continue; 215 | } 216 | 217 | let mut file = File::create(&path)?; 218 | io::copy(&mut zipfile, &mut file)?; 219 | 220 | n_downloaded += 1; 221 | } 222 | 223 | let n_new = n_downloaded - n_existing; 224 | *all_downloaded += n_downloaded; 225 | *all_new += n_new; 226 | 227 | info_end!( 228 | "{} pages, {} new", 229 | n_downloaded.green().bold(), 230 | n_new.green().bold() 231 | ); 232 | 233 | Ok(()) 234 | } 235 | 236 | /// Delete the old cache and replace it with a fresh copy. 237 | pub fn update(&self, mirror: &str, languages: &mut Vec) -> Result<()> { 238 | // Sort to always download archives in alphabetical order. 239 | languages.sort_unstable(); 240 | // The user can put duplicates in the config file. 241 | languages.dedup(); 242 | 243 | let archives = self.download_and_verify(mirror, languages)?; 244 | 245 | if archives.is_empty() { 246 | info!( 247 | "there is nothing to do. Run 'tldr --clean-cache' if you want to force an update." 248 | ); 249 | return Ok(()); 250 | } 251 | 252 | let mut all_downloaded = 0; 253 | let mut all_new = 0; 254 | 255 | for (lang_dir, mut archive) in archives { 256 | // `list_all_vec` can fail when `pages.en` is empty, hence the default of 0. 257 | #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] 258 | let n_existing = self.list_all_vec(&lang_dir).map(|v| v.len()).unwrap_or(0) as i32; 259 | 260 | let lang_dir_full = self.dir.join(&lang_dir); 261 | if lang_dir_full.is_dir() { 262 | fs::remove_dir_all(&lang_dir_full)?; 263 | } 264 | 265 | if let Err(e) = self.extract_lang_archive( 266 | &lang_dir, 267 | &mut archive, 268 | n_existing, 269 | &mut all_downloaded, 270 | &mut all_new, 271 | ) { 272 | info_end!("{}", "FAILED".red().bold()); 273 | return Err(e); 274 | } 275 | } 276 | 277 | info!( 278 | "cache update successful (total: {} pages, {} new).", 279 | all_downloaded.green().bold(), 280 | all_new.green().bold(), 281 | ); 282 | 283 | Ok(()) 284 | } 285 | 286 | /// Interactively delete contents of the cache directory. 287 | pub fn clean(&self) -> Result<()> { 288 | if !self.dir.is_dir() || fs::read_dir(self.dir).map(|mut rd| rd.next().is_none())? { 289 | info!("cache does not exist, not cleaning."); 290 | fs::create_dir_all(self.dir)?; 291 | return Ok(()); 292 | } 293 | 294 | let sumfile = self.dir.join(CHECKSUM_FILE); 295 | if sumfile.is_file() { 296 | info!("removing '{}'...", sumfile.display().red()); 297 | fs::remove_file(sumfile)?; 298 | } 299 | 300 | let mut stdout = io::stdout().lock(); 301 | let mut stdin = io::stdin().lock(); 302 | let mut resp = String::new(); 303 | 304 | for dir in fs::read_dir(self.dir)? { 305 | let dir = dir?.path(); 306 | 307 | write!(stdout, "Remove '{}'? [y/N] ", dir.display().red())?; 308 | stdout.flush()?; 309 | stdin.read_line(&mut resp)?; 310 | 311 | if resp.starts_with(['y', 'Y']) { 312 | info!("removing..."); 313 | fs::remove_dir_all(dir)?; 314 | } 315 | 316 | String::clear(&mut resp); 317 | } 318 | 319 | Ok(()) 320 | } 321 | 322 | /// Find out what platforms are available. 323 | fn get_platforms(&self) -> Result<&[OsString]> { 324 | self.platforms 325 | .get_or_try_init(|| { 326 | let mut result = vec![]; 327 | 328 | for entry in fs::read_dir(self.dir.join(ENGLISH_DIR))? { 329 | let entry = entry?; 330 | let path = entry.path(); 331 | let platform = path.file_name().unwrap(); 332 | 333 | result.push(platform.to_os_string()); 334 | } 335 | 336 | if result.is_empty() { 337 | Err(Error::messed_up_cache( 338 | "'pages.en' contains no platform directories.", 339 | )) 340 | } else { 341 | // read_dir() order can differ across runs, so it's 342 | // better to sort the Vec for consistency. 343 | result.sort_unstable(); 344 | debug!("found platforms: {result:?}"); 345 | Ok(result) 346 | } 347 | }) 348 | .map(Vec::as_slice) 349 | } 350 | 351 | /// Find out what platforms are available and check if the provided platform exists. 352 | fn get_platforms_and_check(&self, platform: &str) -> Result<&[OsString]> { 353 | let platforms = self.get_platforms()?; 354 | 355 | if platforms.iter().all(|x| x != platform) { 356 | Err(Error::new(format!( 357 | "platform '{platform}' does not exist.\n{} {}.", 358 | "Possible values:".bold(), 359 | platforms.join(", ".as_ref()).to_string_lossy() 360 | ))) 361 | } else { 362 | Ok(platforms) 363 | } 364 | } 365 | 366 | /// Find a page for the given platform. 367 | fn find_page_for

(&self, fname: &str, platform: P, lang_dirs: &[String]) -> Option 368 | where 369 | P: AsRef, 370 | { 371 | for lang_dir in lang_dirs { 372 | let path = self.dir.join(lang_dir).join(&platform).join(fname); 373 | 374 | debug!("trying path: {path:?}"); 375 | if path.is_file() { 376 | debug!("page found"); 377 | return Some(path); 378 | } 379 | } 380 | 381 | None 382 | } 383 | 384 | /// Find all pages with the given name. 385 | pub fn find(&self, name: &str, languages: &[String], platform: &str) -> Result> { 386 | // https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#page-resolution 387 | 388 | let platforms = self.get_platforms_and_check(platform)?; 389 | let file = format!("{name}.md"); 390 | debug!("searching for page: '{file}'"); 391 | 392 | let mut result = vec![]; 393 | let mut lang_dirs: Vec = languages.iter().map(|x| format!("pages.{x}")).collect(); 394 | // We can't sort here - order is defined by the user. 395 | lang_dirs.dedup_nosort(); 396 | 397 | // `common` is always searched, so we skip the search for the specified platform 398 | // if the user has requested only `common` (to prevent searching twice) 399 | if platform != "common" { 400 | if let Some(path) = self.find_page_for(&file, platform, &lang_dirs) { 401 | result.push(path); 402 | } 403 | } 404 | 405 | // Fall back to `common` if the page is not found in `platform`. 406 | if let Some(path) = self.find_page_for(&file, "common", &lang_dirs) { 407 | result.push(path); 408 | } 409 | 410 | // Fall back to all other platforms if the page is not found in `platform`. 411 | for alt_platform in platforms { 412 | // `platform` and `common` were already searched, so we can skip them here. 413 | if alt_platform == platform || alt_platform == "common" { 414 | continue; 415 | } 416 | 417 | if let Some(path) = self.find_page_for(&file, alt_platform, &lang_dirs) { 418 | if result.is_empty() { 419 | let alt_platform = alt_platform.to_string_lossy(); 420 | 421 | if platform == "common" { 422 | warn!( 423 | "showing page from platform '{alt_platform}', \ 424 | because '{name}' does not exist in 'common'" 425 | ); 426 | } else { 427 | warn!( 428 | "showing page from platform '{alt_platform}', \ 429 | because '{name}' does not exist in '{platform}' and 'common'" 430 | ); 431 | } 432 | } 433 | 434 | result.push(path); 435 | } 436 | } 437 | 438 | debug!("found {} page(s)", result.len()); 439 | Ok(result) 440 | } 441 | 442 | /// List all available pages in `lang` for `platform`. 443 | fn list_dir(&self, platform: P, lang_dir: Q) -> Result> 444 | where 445 | P: AsRef, 446 | Q: AsRef, 447 | { 448 | match fs::read_dir(self.dir.join(lang_dir.as_ref()).join(platform)) { 449 | Ok(entries) => { 450 | let entries = entries.map(|res| res.map(|ent| ent.file_name())); 451 | Ok(entries.collect::>>()?) 452 | } 453 | // If the directory does not exist, return an empty Vec instead of an error 454 | // (some platform directories do not exist in some translations). 455 | Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]), 456 | Err(e) => Err(e.into()), 457 | } 458 | } 459 | 460 | fn print_basenames(mut pages: Vec) -> Result<()> { 461 | if pages.is_empty() { 462 | return Err(Error::messed_up_cache( 463 | "no pages found, but the 'pages.en' directory exists.", 464 | )); 465 | } 466 | 467 | // Show pages in alphabetical order. 468 | pages.sort_unstable(); 469 | // There are pages with the same name across multiple platforms. 470 | // Listing these multiple times makes no sense. 471 | pages.dedup(); 472 | 473 | let mut stdout = BufWriter::new(io::stdout().lock()); 474 | 475 | for page in pages { 476 | let page = page.to_string_lossy(); 477 | let page = page.strip_suffix(".md").unwrap_or(&page); 478 | 479 | writeln!(stdout, "{page}")?; 480 | } 481 | 482 | Ok(stdout.flush()?) 483 | } 484 | 485 | /// List all pages in English for `platform` and common. 486 | pub fn list_for(&self, platform: &str) -> Result<()> { 487 | // This is here just to check if the platform exists. 488 | self.get_platforms_and_check(platform)?; 489 | 490 | let pages = if platform == "common" { 491 | self.list_dir(platform, ENGLISH_DIR)? 492 | } else { 493 | self.list_dir(platform, ENGLISH_DIR)? 494 | .into_iter() 495 | .chain(self.list_dir("common", ENGLISH_DIR)?) 496 | .collect() 497 | }; 498 | 499 | Self::print_basenames(pages) 500 | } 501 | 502 | /// List all pages in `lang` and return a `Vec`. 503 | fn list_all_vec(&self, lang_dir: S) -> Result> 504 | where 505 | S: AsRef, 506 | { 507 | let mut result = vec![]; 508 | 509 | for platform in self.get_platforms()? { 510 | result.append(&mut self.list_dir(platform, &lang_dir)?); 511 | } 512 | 513 | Ok(result) 514 | } 515 | 516 | /// List all pages in English. 517 | pub fn list_all(&self) -> Result<()> { 518 | Self::print_basenames(self.list_all_vec(ENGLISH_DIR)?) 519 | } 520 | 521 | /// List platforms (used in shell completions). 522 | pub fn list_platforms(&self) -> Result<()> { 523 | let platforms = self.get_platforms()?.join("\n".as_ref()); 524 | writeln!(io::stdout(), "{}", platforms.to_string_lossy())?; 525 | Ok(()) 526 | } 527 | 528 | /// List languages (used in shell completions). 529 | pub fn list_languages(&self) -> Result<()> { 530 | let languages = fs::read_dir(self.dir)? 531 | .filter(|res| res.is_ok() && res.as_ref().unwrap().path().is_dir()) 532 | .map(|res| res.unwrap().file_name()); 533 | let mut stdout = io::stdout().lock(); 534 | 535 | for lang in languages { 536 | let lang = lang.to_string_lossy(); 537 | let lang = lang.strip_prefix("pages.").unwrap_or(&lang); 538 | 539 | writeln!(stdout, "{lang}")?; 540 | } 541 | 542 | Ok(()) 543 | } 544 | 545 | /// Show cache information. 546 | pub fn info(&self, cfg: &Config) -> Result<()> { 547 | let mut n_map = BTreeMap::new(); 548 | let mut n_total = 0; 549 | 550 | for lang_dir in fs::read_dir(self.dir)? { 551 | let lang_dir = lang_dir?; 552 | if !lang_dir.path().is_dir() { 553 | continue; 554 | } 555 | let lang_dir = lang_dir.file_name(); 556 | let n = self.list_all_vec(&lang_dir)?.len(); 557 | 558 | let lang = lang_dir.to_string_lossy(); 559 | let lang = lang.strip_prefix("pages.").unwrap_or(&lang); 560 | 561 | n_map.insert(lang.to_string(), n); 562 | n_total += n; 563 | } 564 | 565 | let mut stdout = io::stdout().lock(); 566 | let age = self.age()?.as_secs(); 567 | 568 | writeln!( 569 | stdout, 570 | "Cache: {} (last update: {} ago)", 571 | self.dir.display().red(), 572 | util::duration_fmt(age).green().bold() 573 | )?; 574 | 575 | if cfg.cache.auto_update { 576 | let max_age = cfg.cache_max_age().as_secs(); 577 | if max_age > age { 578 | let age_diff = max_age - age; 579 | 580 | writeln!( 581 | stdout, 582 | "Automatic update in {}", 583 | util::duration_fmt(age_diff).green().bold() 584 | )?; 585 | } 586 | } else { 587 | writeln!(stdout, "Automatic updates are disabled")?; 588 | } 589 | 590 | writeln!(stdout, "Installed languages:")?; 591 | let width = cmp::max(n_map.keys().map(String::len).max().unwrap(), 5); 592 | // "total" is 5 characters long. ^^ 593 | 594 | for (lang, n) in n_map { 595 | writeln!(stdout, "{lang:width$} : {}", n.green().bold())?; 596 | } 597 | 598 | writeln!( 599 | stdout, 600 | "{:width$} : {} pages", 601 | "total", 602 | n_total.green().bold() 603 | )?; 604 | 605 | Ok(()) 606 | } 607 | 608 | /// Get the age of the cache. 609 | pub fn age(&self) -> Result { 610 | self.age 611 | .get_or_try_init(|| { 612 | let sumfile = self.dir.join(CHECKSUM_FILE); 613 | let metadata = if sumfile.is_file() { 614 | fs::metadata(&sumfile) 615 | } else { 616 | // The sumfile is not available, fall back to the base directory. 617 | fs::metadata(self.dir) 618 | }?; 619 | 620 | metadata.modified()?.elapsed().map_err(|_| { 621 | Error::new( 622 | "the system clock is not functioning correctly.\n\ 623 | Modification time of the cache is later than the current system time.\n\ 624 | Please fix your system clock.", 625 | ) 626 | }) 627 | }) 628 | .copied() 629 | } 630 | } 631 | 632 | #[cfg(test)] 633 | mod tests { 634 | use super::*; 635 | use tempfile::{tempdir, TempDir}; 636 | 637 | /// Create a temporary cache dir for tests with the specified pages. 638 | fn prepare(pages: &[&str]) -> TempDir { 639 | let cachedir = tempdir().unwrap(); 640 | let d = cachedir.path(); 641 | 642 | for p in pages { 643 | let mut page_dir = Path::new(p).components(); 644 | page_dir.next_back(); 645 | fs::create_dir_all(d.join(page_dir)).unwrap(); 646 | File::create(d.join(p)).unwrap(); 647 | } 648 | 649 | cachedir 650 | } 651 | 652 | #[test] 653 | fn not_found() { 654 | let tmpdir = prepare(&["pages.en/common/b.md", "pages.en/linux/b.md"]); 655 | let c = Cache::new(tmpdir.path()); 656 | let pages = c.find("a", &["en".to_string()], "common").unwrap(); 657 | assert!(pages.is_empty()); 658 | } 659 | 660 | #[test] 661 | #[should_panic = "platform 'some_platform' does not exist"] 662 | fn platform_does_not_exist() { 663 | let tmpdir = prepare(&["pages.en/common/b.md", "pages.en/linux/b.md"]); 664 | let c = Cache::new(tmpdir.path()); 665 | c.find("a", &["en".to_string()], "some_platform").unwrap(); 666 | } 667 | 668 | #[test] 669 | fn platform_priority() { 670 | let tmpdir = prepare(&[ 671 | "pages.en/common/a.md", 672 | "pages.en/linux/a.md", 673 | "pages.en/osx/b.md", 674 | ]); 675 | let c = Cache::new(tmpdir.path()); 676 | 677 | let pages_common = c.find("a", &["en".to_string()], "common").unwrap(); 678 | let pages_linux = c.find("a", &["en".to_string()], "linux").unwrap(); 679 | let pages_osx = c.find("a", &["en".to_string()], "osx").unwrap(); 680 | 681 | assert_eq!(pages_common, pages_osx); 682 | assert_eq!(pages_common.len(), 2); 683 | assert!(pages_common[0].ends_with("pages.en/common/a.md")); 684 | assert!(pages_common[1].ends_with("pages.en/linux/a.md")); 685 | 686 | assert_eq!(pages_linux.len(), 2); 687 | assert!(pages_linux[0].ends_with("pages.en/linux/a.md")); 688 | assert!(pages_linux[1].ends_with("pages.en/common/a.md")); 689 | } 690 | 691 | #[test] 692 | #[allow(clippy::similar_names)] 693 | fn lang_priority() { 694 | let tmpdir = prepare(&[ 695 | "pages.en/common/a.md", 696 | "pages.xy/common/a.md", 697 | "pages.en/common/b.md", 698 | "pages.en/linux/c.md", 699 | ]); 700 | let c = Cache::new(tmpdir.path()); 701 | 702 | let pages_a_en = c 703 | .find("a", &["en".to_string(), "xy".to_string()], "linux") 704 | .unwrap(); 705 | let pages_a_xy = c 706 | .find("a", &["xy".to_string(), "en".to_string()], "common") 707 | .unwrap(); 708 | 709 | assert_eq!(pages_a_en.len(), 1); 710 | assert_eq!(pages_a_xy.len(), 1); 711 | 712 | assert!(pages_a_en[0].ends_with("pages.en/common/a.md")); 713 | assert!(pages_a_xy[0].ends_with("pages.xy/common/a.md")); 714 | 715 | let pages_b_xy = c 716 | .find("b", &["xy".to_string(), "en".to_string()], "common") 717 | .unwrap(); 718 | 719 | assert_eq!(pages_b_xy.len(), 1); 720 | assert!(pages_b_xy[0].ends_with("pages.en/common/b.md")); 721 | } 722 | 723 | #[test] 724 | fn list_pages() { 725 | let tmpdir = prepare(&[ 726 | "pages.en/common/a.md", 727 | "pages.en/common/b.md", 728 | "pages.en/linux/c.md", 729 | "pages.en/osx/d.md", 730 | "pages.xy/linux/e.md", 731 | ]); 732 | let c = Cache::new(tmpdir.path()); 733 | 734 | let mut list = c.list_dir("common", "pages.en").unwrap(); 735 | list.sort_unstable(); 736 | assert_eq!(list, vec!["a.md", "b.md"]); 737 | 738 | let mut list = c.list_dir("linux", "pages.en").unwrap(); 739 | list.sort_unstable(); 740 | assert_eq!(list, vec!["c.md"]); 741 | 742 | let mut list = c.list_all_vec("pages.en").unwrap(); 743 | list.sort_unstable(); 744 | assert_eq!(list, vec!["a.md", "b.md", "c.md", "d.md"]); 745 | } 746 | 747 | #[test] 748 | fn list_platforms() { 749 | let tmpdir = prepare(&[ 750 | "pages.en/common/a.md", 751 | "pages.en/linux/a.md", 752 | "pages.en/osx/a.md", 753 | ]); 754 | let c = Cache::new(tmpdir.path()); 755 | assert_eq!(c.get_platforms().unwrap(), &["common", "linux", "osx"]); 756 | } 757 | 758 | #[test] 759 | fn parse_sumfile() { 760 | let s = "xyz pages.en.zip\nzyx pages.xy.zip\nabc someotherfile\ncba index.json"; 761 | let map = HashMap::from([("en", "xyz"), ("xy", "zyx")]); 762 | assert_eq!(Cache::parse_sumfile(s).unwrap(), map); 763 | } 764 | } 765 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "adler2" 7 | version = "2.0.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 | 11 | [[package]] 12 | name = "anstream" 13 | version = "0.6.20" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" 16 | dependencies = [ 17 | "anstyle", 18 | "anstyle-parse", 19 | "anstyle-query", 20 | "anstyle-wincon", 21 | "colorchoice", 22 | "is_terminal_polyfill", 23 | "utf8parse", 24 | ] 25 | 26 | [[package]] 27 | name = "anstyle" 28 | version = "1.0.13" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 31 | 32 | [[package]] 33 | name = "anstyle-parse" 34 | version = "0.2.7" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 37 | dependencies = [ 38 | "utf8parse", 39 | ] 40 | 41 | [[package]] 42 | name = "anstyle-query" 43 | version = "1.1.4" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 46 | dependencies = [ 47 | "windows-sys 0.60.2", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-wincon" 52 | version = "3.0.10" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 55 | dependencies = [ 56 | "anstyle", 57 | "once_cell_polyfill", 58 | "windows-sys 0.60.2", 59 | ] 60 | 61 | [[package]] 62 | name = "arbitrary" 63 | version = "1.4.2" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 66 | dependencies = [ 67 | "derive_arbitrary", 68 | ] 69 | 70 | [[package]] 71 | name = "assert_cmd" 72 | version = "2.1.1" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" 75 | dependencies = [ 76 | "anstyle", 77 | "bstr", 78 | "libc", 79 | "predicates", 80 | "predicates-core", 81 | "predicates-tree", 82 | "wait-timeout", 83 | ] 84 | 85 | [[package]] 86 | name = "base64" 87 | version = "0.22.1" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 90 | 91 | [[package]] 92 | name = "bitflags" 93 | version = "2.9.4" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 96 | 97 | [[package]] 98 | name = "bstr" 99 | version = "1.12.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" 102 | dependencies = [ 103 | "memchr", 104 | "regex-automata", 105 | "serde", 106 | ] 107 | 108 | [[package]] 109 | name = "byteorder" 110 | version = "1.5.0" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 113 | 114 | [[package]] 115 | name = "bytes" 116 | version = "1.10.1" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 119 | 120 | [[package]] 121 | name = "cc" 122 | version = "1.2.39" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" 125 | dependencies = [ 126 | "find-msvc-tools", 127 | "shlex", 128 | ] 129 | 130 | [[package]] 131 | name = "cesu8" 132 | version = "1.1.0" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 135 | 136 | [[package]] 137 | name = "cfg-if" 138 | version = "1.0.3" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 141 | 142 | [[package]] 143 | name = "clap" 144 | version = "4.5.51" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" 147 | dependencies = [ 148 | "clap_builder", 149 | "clap_derive", 150 | ] 151 | 152 | [[package]] 153 | name = "clap_builder" 154 | version = "4.5.51" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" 157 | dependencies = [ 158 | "anstream", 159 | "anstyle", 160 | "clap_lex", 161 | "strsim", 162 | "terminal_size", 163 | ] 164 | 165 | [[package]] 166 | name = "clap_derive" 167 | version = "4.5.49" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 170 | dependencies = [ 171 | "heck", 172 | "proc-macro2", 173 | "quote", 174 | "syn", 175 | ] 176 | 177 | [[package]] 178 | name = "clap_lex" 179 | version = "0.7.5" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 182 | 183 | [[package]] 184 | name = "colorchoice" 185 | version = "1.0.4" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 188 | 189 | [[package]] 190 | name = "combine" 191 | version = "4.6.7" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 194 | dependencies = [ 195 | "bytes", 196 | "memchr", 197 | ] 198 | 199 | [[package]] 200 | name = "core-foundation" 201 | version = "0.10.1" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 204 | dependencies = [ 205 | "core-foundation-sys", 206 | "libc", 207 | ] 208 | 209 | [[package]] 210 | name = "core-foundation-sys" 211 | version = "0.8.7" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 214 | 215 | [[package]] 216 | name = "crc32fast" 217 | version = "1.5.0" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 220 | dependencies = [ 221 | "cfg-if", 222 | ] 223 | 224 | [[package]] 225 | name = "derive_arbitrary" 226 | version = "1.4.2" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 229 | dependencies = [ 230 | "proc-macro2", 231 | "quote", 232 | "syn", 233 | ] 234 | 235 | [[package]] 236 | name = "difflib" 237 | version = "0.4.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" 240 | 241 | [[package]] 242 | name = "dirs" 243 | version = "6.0.0" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 246 | dependencies = [ 247 | "dirs-sys", 248 | ] 249 | 250 | [[package]] 251 | name = "dirs-sys" 252 | version = "0.5.0" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 255 | dependencies = [ 256 | "libc", 257 | "option-ext", 258 | "redox_users", 259 | "windows-sys 0.61.1", 260 | ] 261 | 262 | [[package]] 263 | name = "equivalent" 264 | version = "1.0.2" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 267 | 268 | [[package]] 269 | name = "errno" 270 | version = "0.3.14" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 273 | dependencies = [ 274 | "libc", 275 | "windows-sys 0.61.1", 276 | ] 277 | 278 | [[package]] 279 | name = "fastrand" 280 | version = "2.3.0" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 283 | 284 | [[package]] 285 | name = "find-msvc-tools" 286 | version = "0.1.2" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" 289 | 290 | [[package]] 291 | name = "flate2" 292 | version = "1.1.2" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" 295 | dependencies = [ 296 | "crc32fast", 297 | "libz-rs-sys", 298 | "miniz_oxide", 299 | ] 300 | 301 | [[package]] 302 | name = "fnv" 303 | version = "1.0.7" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 306 | 307 | [[package]] 308 | name = "getrandom" 309 | version = "0.2.16" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 312 | dependencies = [ 313 | "cfg-if", 314 | "libc", 315 | "wasi 0.11.1+wasi-snapshot-preview1", 316 | ] 317 | 318 | [[package]] 319 | name = "getrandom" 320 | version = "0.3.3" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 323 | dependencies = [ 324 | "cfg-if", 325 | "libc", 326 | "r-efi", 327 | "wasi 0.14.7+wasi-0.2.4", 328 | ] 329 | 330 | [[package]] 331 | name = "hashbrown" 332 | version = "0.16.0" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 335 | 336 | [[package]] 337 | name = "heck" 338 | version = "0.5.0" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 341 | 342 | [[package]] 343 | name = "http" 344 | version = "1.3.1" 345 | source = "registry+https://github.com/rust-lang/crates.io-index" 346 | checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 347 | dependencies = [ 348 | "bytes", 349 | "fnv", 350 | "itoa", 351 | ] 352 | 353 | [[package]] 354 | name = "httparse" 355 | version = "1.10.1" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 358 | 359 | [[package]] 360 | name = "indexmap" 361 | version = "2.11.4" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 364 | dependencies = [ 365 | "equivalent", 366 | "hashbrown", 367 | ] 368 | 369 | [[package]] 370 | name = "is_terminal_polyfill" 371 | version = "1.70.1" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 374 | 375 | [[package]] 376 | name = "itoa" 377 | version = "1.0.15" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 380 | 381 | [[package]] 382 | name = "jni" 383 | version = "0.21.1" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 386 | dependencies = [ 387 | "cesu8", 388 | "cfg-if", 389 | "combine", 390 | "jni-sys", 391 | "log", 392 | "thiserror 1.0.69", 393 | "walkdir", 394 | "windows-sys 0.45.0", 395 | ] 396 | 397 | [[package]] 398 | name = "jni-sys" 399 | version = "0.3.0" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 402 | 403 | [[package]] 404 | name = "libc" 405 | version = "0.2.176" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 408 | 409 | [[package]] 410 | name = "libredox" 411 | version = "0.1.10" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 414 | dependencies = [ 415 | "bitflags", 416 | "libc", 417 | ] 418 | 419 | [[package]] 420 | name = "libz-rs-sys" 421 | version = "0.5.2" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" 424 | dependencies = [ 425 | "zlib-rs", 426 | ] 427 | 428 | [[package]] 429 | name = "linux-raw-sys" 430 | version = "0.11.0" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 433 | 434 | [[package]] 435 | name = "log" 436 | version = "0.4.28" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 439 | 440 | [[package]] 441 | name = "memchr" 442 | version = "2.7.6" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 445 | 446 | [[package]] 447 | name = "miniz_oxide" 448 | version = "0.8.9" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 451 | dependencies = [ 452 | "adler2", 453 | ] 454 | 455 | [[package]] 456 | name = "once_cell" 457 | version = "1.21.3" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 460 | 461 | [[package]] 462 | name = "once_cell_polyfill" 463 | version = "1.70.1" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 466 | 467 | [[package]] 468 | name = "openssl-probe" 469 | version = "0.1.6" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 472 | 473 | [[package]] 474 | name = "option-ext" 475 | version = "0.2.0" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 478 | 479 | [[package]] 480 | name = "percent-encoding" 481 | version = "2.3.2" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 484 | 485 | [[package]] 486 | name = "predicates" 487 | version = "3.1.3" 488 | source = "registry+https://github.com/rust-lang/crates.io-index" 489 | checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" 490 | dependencies = [ 491 | "anstyle", 492 | "difflib", 493 | "predicates-core", 494 | ] 495 | 496 | [[package]] 497 | name = "predicates-core" 498 | version = "1.0.9" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" 501 | 502 | [[package]] 503 | name = "predicates-tree" 504 | version = "1.0.12" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" 507 | dependencies = [ 508 | "predicates-core", 509 | "termtree", 510 | ] 511 | 512 | [[package]] 513 | name = "proc-macro2" 514 | version = "1.0.101" 515 | source = "registry+https://github.com/rust-lang/crates.io-index" 516 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 517 | dependencies = [ 518 | "unicode-ident", 519 | ] 520 | 521 | [[package]] 522 | name = "quote" 523 | version = "1.0.41" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 526 | dependencies = [ 527 | "proc-macro2", 528 | ] 529 | 530 | [[package]] 531 | name = "r-efi" 532 | version = "5.3.0" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 535 | 536 | [[package]] 537 | name = "redox_users" 538 | version = "0.5.2" 539 | source = "registry+https://github.com/rust-lang/crates.io-index" 540 | checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" 541 | dependencies = [ 542 | "getrandom 0.2.16", 543 | "libredox", 544 | "thiserror 2.0.17", 545 | ] 546 | 547 | [[package]] 548 | name = "regex-automata" 549 | version = "0.4.11" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" 552 | 553 | [[package]] 554 | name = "ring" 555 | version = "0.17.14" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 558 | dependencies = [ 559 | "cc", 560 | "cfg-if", 561 | "getrandom 0.2.16", 562 | "libc", 563 | "untrusted", 564 | "windows-sys 0.52.0", 565 | ] 566 | 567 | [[package]] 568 | name = "rustix" 569 | version = "1.1.2" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 572 | dependencies = [ 573 | "bitflags", 574 | "errno", 575 | "libc", 576 | "linux-raw-sys", 577 | "windows-sys 0.61.1", 578 | ] 579 | 580 | [[package]] 581 | name = "rustls" 582 | version = "0.23.32" 583 | source = "registry+https://github.com/rust-lang/crates.io-index" 584 | checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" 585 | dependencies = [ 586 | "log", 587 | "once_cell", 588 | "ring", 589 | "rustls-pki-types", 590 | "rustls-webpki", 591 | "subtle", 592 | "zeroize", 593 | ] 594 | 595 | [[package]] 596 | name = "rustls-native-certs" 597 | version = "0.8.1" 598 | source = "registry+https://github.com/rust-lang/crates.io-index" 599 | checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" 600 | dependencies = [ 601 | "openssl-probe", 602 | "rustls-pki-types", 603 | "schannel", 604 | "security-framework", 605 | ] 606 | 607 | [[package]] 608 | name = "rustls-pemfile" 609 | version = "2.2.0" 610 | source = "registry+https://github.com/rust-lang/crates.io-index" 611 | checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" 612 | dependencies = [ 613 | "rustls-pki-types", 614 | ] 615 | 616 | [[package]] 617 | name = "rustls-pki-types" 618 | version = "1.12.0" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 621 | dependencies = [ 622 | "zeroize", 623 | ] 624 | 625 | [[package]] 626 | name = "rustls-platform-verifier" 627 | version = "0.6.1" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "be59af91596cac372a6942530653ad0c3a246cdd491aaa9dcaee47f88d67d5a0" 630 | dependencies = [ 631 | "core-foundation", 632 | "core-foundation-sys", 633 | "jni", 634 | "log", 635 | "once_cell", 636 | "rustls", 637 | "rustls-native-certs", 638 | "rustls-platform-verifier-android", 639 | "rustls-webpki", 640 | "security-framework", 641 | "security-framework-sys", 642 | "webpki-root-certs", 643 | "windows-sys 0.59.0", 644 | ] 645 | 646 | [[package]] 647 | name = "rustls-platform-verifier-android" 648 | version = "0.1.1" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" 651 | 652 | [[package]] 653 | name = "rustls-webpki" 654 | version = "0.103.6" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" 657 | dependencies = [ 658 | "ring", 659 | "rustls-pki-types", 660 | "untrusted", 661 | ] 662 | 663 | [[package]] 664 | name = "same-file" 665 | version = "1.0.6" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 668 | dependencies = [ 669 | "winapi-util", 670 | ] 671 | 672 | [[package]] 673 | name = "schannel" 674 | version = "0.1.28" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 677 | dependencies = [ 678 | "windows-sys 0.61.1", 679 | ] 680 | 681 | [[package]] 682 | name = "security-framework" 683 | version = "3.5.1" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" 686 | dependencies = [ 687 | "bitflags", 688 | "core-foundation", 689 | "core-foundation-sys", 690 | "libc", 691 | "security-framework-sys", 692 | ] 693 | 694 | [[package]] 695 | name = "security-framework-sys" 696 | version = "2.15.0" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 699 | dependencies = [ 700 | "core-foundation-sys", 701 | "libc", 702 | ] 703 | 704 | [[package]] 705 | name = "serde" 706 | version = "1.0.228" 707 | source = "registry+https://github.com/rust-lang/crates.io-index" 708 | checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 709 | dependencies = [ 710 | "serde_core", 711 | "serde_derive", 712 | ] 713 | 714 | [[package]] 715 | name = "serde_core" 716 | version = "1.0.228" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 719 | dependencies = [ 720 | "serde_derive", 721 | ] 722 | 723 | [[package]] 724 | name = "serde_derive" 725 | version = "1.0.228" 726 | source = "registry+https://github.com/rust-lang/crates.io-index" 727 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 728 | dependencies = [ 729 | "proc-macro2", 730 | "quote", 731 | "syn", 732 | ] 733 | 734 | [[package]] 735 | name = "serde_spanned" 736 | version = "1.0.3" 737 | source = "registry+https://github.com/rust-lang/crates.io-index" 738 | checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" 739 | dependencies = [ 740 | "serde_core", 741 | ] 742 | 743 | [[package]] 744 | name = "shlex" 745 | version = "1.3.0" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 748 | 749 | [[package]] 750 | name = "socks" 751 | version = "0.3.4" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" 754 | dependencies = [ 755 | "byteorder", 756 | "libc", 757 | "winapi", 758 | ] 759 | 760 | [[package]] 761 | name = "strsim" 762 | version = "0.11.1" 763 | source = "registry+https://github.com/rust-lang/crates.io-index" 764 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 765 | 766 | [[package]] 767 | name = "subtle" 768 | version = "2.6.1" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 771 | 772 | [[package]] 773 | name = "syn" 774 | version = "2.0.106" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 777 | dependencies = [ 778 | "proc-macro2", 779 | "quote", 780 | "unicode-ident", 781 | ] 782 | 783 | [[package]] 784 | name = "tempfile" 785 | version = "3.23.0" 786 | source = "registry+https://github.com/rust-lang/crates.io-index" 787 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 788 | dependencies = [ 789 | "fastrand", 790 | "getrandom 0.3.3", 791 | "once_cell", 792 | "rustix", 793 | "windows-sys 0.61.1", 794 | ] 795 | 796 | [[package]] 797 | name = "terminal_size" 798 | version = "0.4.3" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" 801 | dependencies = [ 802 | "rustix", 803 | "windows-sys 0.60.2", 804 | ] 805 | 806 | [[package]] 807 | name = "termtree" 808 | version = "0.5.1" 809 | source = "registry+https://github.com/rust-lang/crates.io-index" 810 | checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" 811 | 812 | [[package]] 813 | name = "thiserror" 814 | version = "1.0.69" 815 | source = "registry+https://github.com/rust-lang/crates.io-index" 816 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 817 | dependencies = [ 818 | "thiserror-impl 1.0.69", 819 | ] 820 | 821 | [[package]] 822 | name = "thiserror" 823 | version = "2.0.17" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 826 | dependencies = [ 827 | "thiserror-impl 2.0.17", 828 | ] 829 | 830 | [[package]] 831 | name = "thiserror-impl" 832 | version = "1.0.69" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 835 | dependencies = [ 836 | "proc-macro2", 837 | "quote", 838 | "syn", 839 | ] 840 | 841 | [[package]] 842 | name = "thiserror-impl" 843 | version = "2.0.17" 844 | source = "registry+https://github.com/rust-lang/crates.io-index" 845 | checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 846 | dependencies = [ 847 | "proc-macro2", 848 | "quote", 849 | "syn", 850 | ] 851 | 852 | [[package]] 853 | name = "tlrc" 854 | version = "1.12.0" 855 | dependencies = [ 856 | "assert_cmd", 857 | "clap", 858 | "dirs", 859 | "log", 860 | "once_cell", 861 | "ring", 862 | "serde", 863 | "tempfile", 864 | "terminal_size", 865 | "toml", 866 | "unicode-width", 867 | "ureq", 868 | "yansi", 869 | "zip", 870 | ] 871 | 872 | [[package]] 873 | name = "toml" 874 | version = "0.9.8" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" 877 | dependencies = [ 878 | "indexmap", 879 | "serde_core", 880 | "serde_spanned", 881 | "toml_datetime", 882 | "toml_parser", 883 | "toml_writer", 884 | "winnow", 885 | ] 886 | 887 | [[package]] 888 | name = "toml_datetime" 889 | version = "0.7.3" 890 | source = "registry+https://github.com/rust-lang/crates.io-index" 891 | checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" 892 | dependencies = [ 893 | "serde_core", 894 | ] 895 | 896 | [[package]] 897 | name = "toml_parser" 898 | version = "1.0.4" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" 901 | dependencies = [ 902 | "winnow", 903 | ] 904 | 905 | [[package]] 906 | name = "toml_writer" 907 | version = "1.0.4" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" 910 | 911 | [[package]] 912 | name = "unicode-ident" 913 | version = "1.0.19" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" 916 | 917 | [[package]] 918 | name = "unicode-width" 919 | version = "0.2.2" 920 | source = "registry+https://github.com/rust-lang/crates.io-index" 921 | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 922 | 923 | [[package]] 924 | name = "untrusted" 925 | version = "0.9.0" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 928 | 929 | [[package]] 930 | name = "ureq" 931 | version = "3.1.2" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" 934 | dependencies = [ 935 | "base64", 936 | "log", 937 | "percent-encoding", 938 | "rustls", 939 | "rustls-pemfile", 940 | "rustls-pki-types", 941 | "rustls-platform-verifier", 942 | "socks", 943 | "ureq-proto", 944 | "utf-8", 945 | "webpki-roots", 946 | ] 947 | 948 | [[package]] 949 | name = "ureq-proto" 950 | version = "0.5.2" 951 | source = "registry+https://github.com/rust-lang/crates.io-index" 952 | checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" 953 | dependencies = [ 954 | "base64", 955 | "http", 956 | "httparse", 957 | "log", 958 | ] 959 | 960 | [[package]] 961 | name = "utf-8" 962 | version = "0.7.6" 963 | source = "registry+https://github.com/rust-lang/crates.io-index" 964 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 965 | 966 | [[package]] 967 | name = "utf8parse" 968 | version = "0.2.2" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 971 | 972 | [[package]] 973 | name = "wait-timeout" 974 | version = "0.2.1" 975 | source = "registry+https://github.com/rust-lang/crates.io-index" 976 | checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" 977 | dependencies = [ 978 | "libc", 979 | ] 980 | 981 | [[package]] 982 | name = "walkdir" 983 | version = "2.5.0" 984 | source = "registry+https://github.com/rust-lang/crates.io-index" 985 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 986 | dependencies = [ 987 | "same-file", 988 | "winapi-util", 989 | ] 990 | 991 | [[package]] 992 | name = "wasi" 993 | version = "0.11.1+wasi-snapshot-preview1" 994 | source = "registry+https://github.com/rust-lang/crates.io-index" 995 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 996 | 997 | [[package]] 998 | name = "wasi" 999 | version = "0.14.7+wasi-0.2.4" 1000 | source = "registry+https://github.com/rust-lang/crates.io-index" 1001 | checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" 1002 | dependencies = [ 1003 | "wasip2", 1004 | ] 1005 | 1006 | [[package]] 1007 | name = "wasip2" 1008 | version = "1.0.1+wasi-0.2.4" 1009 | source = "registry+https://github.com/rust-lang/crates.io-index" 1010 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 1011 | dependencies = [ 1012 | "wit-bindgen", 1013 | ] 1014 | 1015 | [[package]] 1016 | name = "webpki-root-certs" 1017 | version = "1.0.2" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" 1020 | dependencies = [ 1021 | "rustls-pki-types", 1022 | ] 1023 | 1024 | [[package]] 1025 | name = "webpki-roots" 1026 | version = "1.0.2" 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" 1028 | checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" 1029 | dependencies = [ 1030 | "rustls-pki-types", 1031 | ] 1032 | 1033 | [[package]] 1034 | name = "winapi" 1035 | version = "0.3.9" 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" 1037 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1038 | dependencies = [ 1039 | "winapi-i686-pc-windows-gnu", 1040 | "winapi-x86_64-pc-windows-gnu", 1041 | ] 1042 | 1043 | [[package]] 1044 | name = "winapi-i686-pc-windows-gnu" 1045 | version = "0.4.0" 1046 | source = "registry+https://github.com/rust-lang/crates.io-index" 1047 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1048 | 1049 | [[package]] 1050 | name = "winapi-util" 1051 | version = "0.1.11" 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" 1053 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 1054 | dependencies = [ 1055 | "windows-sys 0.61.1", 1056 | ] 1057 | 1058 | [[package]] 1059 | name = "winapi-x86_64-pc-windows-gnu" 1060 | version = "0.4.0" 1061 | source = "registry+https://github.com/rust-lang/crates.io-index" 1062 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1063 | 1064 | [[package]] 1065 | name = "windows-link" 1066 | version = "0.2.0" 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" 1068 | checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 1069 | 1070 | [[package]] 1071 | name = "windows-sys" 1072 | version = "0.45.0" 1073 | source = "registry+https://github.com/rust-lang/crates.io-index" 1074 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 1075 | dependencies = [ 1076 | "windows-targets 0.42.2", 1077 | ] 1078 | 1079 | [[package]] 1080 | name = "windows-sys" 1081 | version = "0.52.0" 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" 1083 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1084 | dependencies = [ 1085 | "windows-targets 0.52.6", 1086 | ] 1087 | 1088 | [[package]] 1089 | name = "windows-sys" 1090 | version = "0.59.0" 1091 | source = "registry+https://github.com/rust-lang/crates.io-index" 1092 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1093 | dependencies = [ 1094 | "windows-targets 0.52.6", 1095 | ] 1096 | 1097 | [[package]] 1098 | name = "windows-sys" 1099 | version = "0.60.2" 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" 1101 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1102 | dependencies = [ 1103 | "windows-targets 0.53.4", 1104 | ] 1105 | 1106 | [[package]] 1107 | name = "windows-sys" 1108 | version = "0.61.1" 1109 | source = "registry+https://github.com/rust-lang/crates.io-index" 1110 | checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" 1111 | dependencies = [ 1112 | "windows-link", 1113 | ] 1114 | 1115 | [[package]] 1116 | name = "windows-targets" 1117 | version = "0.42.2" 1118 | source = "registry+https://github.com/rust-lang/crates.io-index" 1119 | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 1120 | dependencies = [ 1121 | "windows_aarch64_gnullvm 0.42.2", 1122 | "windows_aarch64_msvc 0.42.2", 1123 | "windows_i686_gnu 0.42.2", 1124 | "windows_i686_msvc 0.42.2", 1125 | "windows_x86_64_gnu 0.42.2", 1126 | "windows_x86_64_gnullvm 0.42.2", 1127 | "windows_x86_64_msvc 0.42.2", 1128 | ] 1129 | 1130 | [[package]] 1131 | name = "windows-targets" 1132 | version = "0.52.6" 1133 | source = "registry+https://github.com/rust-lang/crates.io-index" 1134 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1135 | dependencies = [ 1136 | "windows_aarch64_gnullvm 0.52.6", 1137 | "windows_aarch64_msvc 0.52.6", 1138 | "windows_i686_gnu 0.52.6", 1139 | "windows_i686_gnullvm 0.52.6", 1140 | "windows_i686_msvc 0.52.6", 1141 | "windows_x86_64_gnu 0.52.6", 1142 | "windows_x86_64_gnullvm 0.52.6", 1143 | "windows_x86_64_msvc 0.52.6", 1144 | ] 1145 | 1146 | [[package]] 1147 | name = "windows-targets" 1148 | version = "0.53.4" 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" 1150 | checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" 1151 | dependencies = [ 1152 | "windows-link", 1153 | "windows_aarch64_gnullvm 0.53.0", 1154 | "windows_aarch64_msvc 0.53.0", 1155 | "windows_i686_gnu 0.53.0", 1156 | "windows_i686_gnullvm 0.53.0", 1157 | "windows_i686_msvc 0.53.0", 1158 | "windows_x86_64_gnu 0.53.0", 1159 | "windows_x86_64_gnullvm 0.53.0", 1160 | "windows_x86_64_msvc 0.53.0", 1161 | ] 1162 | 1163 | [[package]] 1164 | name = "windows_aarch64_gnullvm" 1165 | version = "0.42.2" 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" 1167 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 1168 | 1169 | [[package]] 1170 | name = "windows_aarch64_gnullvm" 1171 | version = "0.52.6" 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" 1173 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1174 | 1175 | [[package]] 1176 | name = "windows_aarch64_gnullvm" 1177 | version = "0.53.0" 1178 | source = "registry+https://github.com/rust-lang/crates.io-index" 1179 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 1180 | 1181 | [[package]] 1182 | name = "windows_aarch64_msvc" 1183 | version = "0.42.2" 1184 | source = "registry+https://github.com/rust-lang/crates.io-index" 1185 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 1186 | 1187 | [[package]] 1188 | name = "windows_aarch64_msvc" 1189 | version = "0.52.6" 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" 1191 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1192 | 1193 | [[package]] 1194 | name = "windows_aarch64_msvc" 1195 | version = "0.53.0" 1196 | source = "registry+https://github.com/rust-lang/crates.io-index" 1197 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 1198 | 1199 | [[package]] 1200 | name = "windows_i686_gnu" 1201 | version = "0.42.2" 1202 | source = "registry+https://github.com/rust-lang/crates.io-index" 1203 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 1204 | 1205 | [[package]] 1206 | name = "windows_i686_gnu" 1207 | version = "0.52.6" 1208 | source = "registry+https://github.com/rust-lang/crates.io-index" 1209 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1210 | 1211 | [[package]] 1212 | name = "windows_i686_gnu" 1213 | version = "0.53.0" 1214 | source = "registry+https://github.com/rust-lang/crates.io-index" 1215 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 1216 | 1217 | [[package]] 1218 | name = "windows_i686_gnullvm" 1219 | version = "0.52.6" 1220 | source = "registry+https://github.com/rust-lang/crates.io-index" 1221 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1222 | 1223 | [[package]] 1224 | name = "windows_i686_gnullvm" 1225 | version = "0.53.0" 1226 | source = "registry+https://github.com/rust-lang/crates.io-index" 1227 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 1228 | 1229 | [[package]] 1230 | name = "windows_i686_msvc" 1231 | version = "0.42.2" 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" 1233 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 1234 | 1235 | [[package]] 1236 | name = "windows_i686_msvc" 1237 | version = "0.52.6" 1238 | source = "registry+https://github.com/rust-lang/crates.io-index" 1239 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1240 | 1241 | [[package]] 1242 | name = "windows_i686_msvc" 1243 | version = "0.53.0" 1244 | source = "registry+https://github.com/rust-lang/crates.io-index" 1245 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 1246 | 1247 | [[package]] 1248 | name = "windows_x86_64_gnu" 1249 | version = "0.42.2" 1250 | source = "registry+https://github.com/rust-lang/crates.io-index" 1251 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 1252 | 1253 | [[package]] 1254 | name = "windows_x86_64_gnu" 1255 | version = "0.52.6" 1256 | source = "registry+https://github.com/rust-lang/crates.io-index" 1257 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1258 | 1259 | [[package]] 1260 | name = "windows_x86_64_gnu" 1261 | version = "0.53.0" 1262 | source = "registry+https://github.com/rust-lang/crates.io-index" 1263 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 1264 | 1265 | [[package]] 1266 | name = "windows_x86_64_gnullvm" 1267 | version = "0.42.2" 1268 | source = "registry+https://github.com/rust-lang/crates.io-index" 1269 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 1270 | 1271 | [[package]] 1272 | name = "windows_x86_64_gnullvm" 1273 | version = "0.52.6" 1274 | source = "registry+https://github.com/rust-lang/crates.io-index" 1275 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1276 | 1277 | [[package]] 1278 | name = "windows_x86_64_gnullvm" 1279 | version = "0.53.0" 1280 | source = "registry+https://github.com/rust-lang/crates.io-index" 1281 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 1282 | 1283 | [[package]] 1284 | name = "windows_x86_64_msvc" 1285 | version = "0.42.2" 1286 | source = "registry+https://github.com/rust-lang/crates.io-index" 1287 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 1288 | 1289 | [[package]] 1290 | name = "windows_x86_64_msvc" 1291 | version = "0.52.6" 1292 | source = "registry+https://github.com/rust-lang/crates.io-index" 1293 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1294 | 1295 | [[package]] 1296 | name = "windows_x86_64_msvc" 1297 | version = "0.53.0" 1298 | source = "registry+https://github.com/rust-lang/crates.io-index" 1299 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1300 | 1301 | [[package]] 1302 | name = "winnow" 1303 | version = "0.7.13" 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" 1305 | checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 1306 | 1307 | [[package]] 1308 | name = "wit-bindgen" 1309 | version = "0.46.0" 1310 | source = "registry+https://github.com/rust-lang/crates.io-index" 1311 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 1312 | 1313 | [[package]] 1314 | name = "yansi" 1315 | version = "1.0.1" 1316 | source = "registry+https://github.com/rust-lang/crates.io-index" 1317 | checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 1318 | 1319 | [[package]] 1320 | name = "zeroize" 1321 | version = "1.8.2" 1322 | source = "registry+https://github.com/rust-lang/crates.io-index" 1323 | checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 1324 | 1325 | [[package]] 1326 | name = "zip" 1327 | version = "6.0.0" 1328 | source = "registry+https://github.com/rust-lang/crates.io-index" 1329 | checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" 1330 | dependencies = [ 1331 | "arbitrary", 1332 | "crc32fast", 1333 | "flate2", 1334 | "indexmap", 1335 | "memchr", 1336 | ] 1337 | 1338 | [[package]] 1339 | name = "zlib-rs" 1340 | version = "0.5.2" 1341 | source = "registry+https://github.com/rust-lang/crates.io-index" 1342 | checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" 1343 | --------------------------------------------------------------------------------