├── .github └── workflows │ ├── chat-ops-dispatch.yml │ ├── ci.yml │ ├── docs.yml │ └── fmt-command.yml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── configuration │ │ ├── README.mdx │ │ ├── _category_.yml │ │ ├── config.yaml.mdx │ │ ├── defaults.mdx │ │ ├── dot.yaml.mdx │ │ ├── examples.md │ │ ├── os-specific-configuration.mdx │ │ └── templating.md │ ├── getting-started.md │ └── usage.md ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ ├── components │ │ ├── Features │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ └── TabedCodeBlock │ │ │ └── index.tsx │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ └── index.tsx ├── static │ ├── .nojekyll │ ├── img │ │ └── logo.svg │ ├── install.ps1 │ └── install.sh └── tsconfig.json ├── renovate.json └── src ├── cli.rs ├── commands ├── clone.rs ├── completions.rs ├── init.rs ├── install.rs ├── link.rs └── mod.rs ├── config.rs ├── dot ├── defaults.rs ├── error.rs ├── mod.rs ├── repr │ ├── capabilities_canonical.rs │ ├── capabilities_complex.rs │ ├── dot_canonical.rs │ ├── dot_complex.rs │ ├── dot_simplified.rs │ ├── installs_canonical.rs │ ├── installs_complex.rs │ ├── links_complex.rs │ ├── mod.rs │ ├── selector.rs │ └── test.rs └── test │ ├── data │ ├── directory_structure │ │ ├── test01 │ │ │ └── dot.yaml │ │ ├── test02 │ │ │ └── dot.yaml │ │ └── test03 │ │ │ ├── defaults.yaml │ │ │ ├── test04 │ │ │ ├── defaults.yaml │ │ │ └── dot.yaml │ │ │ ├── test05 │ │ │ └── dot.yaml │ │ │ └── test06 │ │ │ └── dot.yaml │ ├── file_formats │ │ ├── defaults.toml │ │ ├── test01 │ │ │ └── dot.yaml │ │ ├── test02 │ │ │ └── dot.toml │ │ └── test03 │ │ │ └── dot.json │ ├── mod.rs │ ├── selector │ │ ├── mod.rs │ │ ├── s01 │ │ │ ├── dot.yaml │ │ │ └── mod.rs │ │ ├── s02 │ │ │ ├── dot.yaml │ │ │ └── mod.rs │ │ ├── s03 │ │ │ ├── dot.yaml │ │ │ └── mod.rs │ │ ├── s04 │ │ │ ├── dot.yaml │ │ │ └── mod.rs │ │ └── s05 │ │ │ ├── dot.yaml │ │ │ └── mod.rs │ └── structure │ │ ├── mod.rs │ │ ├── s01 │ │ ├── dot.yaml │ │ └── mod.rs │ │ ├── s02 │ │ ├── dot.yaml │ │ └── mod.rs │ │ ├── s03 │ │ ├── dot.yaml │ │ └── mod.rs │ │ ├── s04 │ │ ├── dot.yaml │ │ └── mod.rs │ │ ├── s05 │ │ ├── dot.yaml │ │ └── mod.rs │ │ ├── s06 │ │ ├── dot.yaml │ │ └── mod.rs │ │ └── s07 │ │ ├── dot.yaml │ │ └── mod.rs │ └── mod.rs ├── helpers.rs ├── main.rs ├── state └── mod.rs ├── templating ├── mod.rs └── test │ ├── data │ └── dotfiles01 │ │ └── test01 │ │ ├── dot.yaml │ │ └── test02 │ │ └── dot.yaml │ └── mod.rs └── test ├── data ├── config │ └── config.yaml ├── dotfiles01 │ └── config.toml ├── dotfiles02 │ └── config.json └── dotfiles03 │ └── config.yaml └── mod.rs /.github/workflows/chat-ops-dispatch.yml: -------------------------------------------------------------------------------- 1 | name: Chat Ops 2 | on: 3 | issue_comment: 4 | types: [created] 5 | jobs: 6 | chatOpsDispatch: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Slash Command Dispatch 12 | uses: peter-evans/slash-command-dispatch@v4 13 | id: scd 14 | with: 15 | token: ${{ secrets.CHAT_OPS }} 16 | commands: | 17 | fmt 18 | issue-type: pull-request 19 | 20 | - name: Edit comment with error message 21 | if: steps.scd.outputs.error-message 22 | uses: peter-evans/create-or-update-comment@v4 23 | with: 24 | comment-id: ${{ github.event.comment.id }} 25 | body: | 26 | > ${{ steps.scd.outputs.error-message }} -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: 7 | - v* 8 | paths: 9 | - "**" 10 | - "!docs/**" 11 | - "!.github/**" 12 | - ".github/workflows/ci.yml" 13 | pull_request: 14 | branches: [main] 15 | paths: 16 | - "**" 17 | - "!docs/**" 18 | - "!.github/**" 19 | - ".github/workflows/ci.yml" 20 | workflow_dispatch: 21 | inputs: 22 | lint: 23 | description: Run lint job 24 | type: boolean 25 | default: true 26 | test: 27 | description: Run test job 28 | type: boolean 29 | default: true 30 | build: 31 | description: Run build job 32 | type: boolean 33 | default: false 34 | 35 | concurrency: 36 | group: ${{ github.ref }} 37 | cancel-in-progress: true 38 | 39 | env: 40 | CARGO_TERM_COLOR: always 41 | 42 | jobs: 43 | lint: 44 | name: lint 45 | runs-on: ubuntu-latest 46 | if: github.event_name != 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' && inputs.lint) 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - uses: dtolnay/rust-toolchain@stable 51 | with: 52 | components: clippy, rustfmt 53 | 54 | - uses: actions/cache@v4 55 | with: 56 | path: | 57 | ~/.cargo/bin/ 58 | ~/.cargo/registry/index/ 59 | ~/.cargo/registry/cache/ 60 | ~/.cargo/git/db/ 61 | target/ 62 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 63 | 64 | - run: cargo fmt --check 65 | 66 | - run: cargo clippy --all-features -- -D warnings 67 | 68 | - run: cargo clippy --features all-formats -- -D warnings 69 | 70 | test: 71 | name: Test Suite 72 | runs-on: ${{ matrix.target.runner }} 73 | if: github.event_name != 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' && inputs.test) 74 | needs: lint 75 | strategy: 76 | matrix: 77 | target: 78 | - triple: x86_64-pc-windows-msvc 79 | runner: windows-latest 80 | - triple: x86_64-apple-darwin 81 | runner: macos-latest 82 | - triple: x86_64-unknown-linux-gnu 83 | runner: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - uses: dtolnay/rust-toolchain@stable 88 | with: 89 | target: ${{ matrix.target.triple }} 90 | 91 | - uses: actions/cache@v4 92 | with: 93 | path: | 94 | ~/.cargo/bin/ 95 | ~/.cargo/registry/index/ 96 | ~/.cargo/registry/cache/ 97 | ~/.cargo/git/db/ 98 | target/ 99 | key: ${{ runner.os }}-${{ matrix.target.triple }}-cargo-${{ hashFiles('**/Cargo.lock') }} 100 | 101 | - run: cargo test --features all-formats --target ${{ matrix.target.triple }} 102 | 103 | build: 104 | name: build 105 | needs: test 106 | runs-on: ${{ matrix.target.runner }} 107 | if: (github.event_name == 'workflow_dispatch' && inputs.build) || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) 108 | strategy: 109 | matrix: 110 | target: 111 | # each musl target also needs to use vendored openssl-sys in Cargo.toml for the build to work 112 | - triple: x86_64-pc-windows-msvc 113 | filename: rotz.exe 114 | runner: windows-latest 115 | compile: native 116 | 117 | - triple: aarch64-pc-windows-msvc 118 | filename: rotz.exe 119 | runner: windows-latest 120 | compile: native 121 | args: --no-default-features --features all-formats 122 | 123 | - triple: i686-pc-windows-msvc 124 | filename: rotz.exe 125 | runner: windows-latest 126 | compile: native 127 | 128 | - triple: x86_64-unknown-linux-gnu 129 | filename: rotz 130 | runner: ubuntu-latest 131 | compile: cross 132 | 133 | - triple: aarch64-unknown-linux-gnu 134 | filename: rotz 135 | runner: ubuntu-latest 136 | compile: cross 137 | 138 | - triple: i686-unknown-linux-gnu 139 | filename: rotz 140 | runner: ubuntu-latest 141 | compile: cross 142 | 143 | - triple: x86_64-unknown-linux-musl 144 | filename: rotz 145 | runner: ubuntu-latest 146 | compile: cross 147 | 148 | - triple: aarch64-unknown-linux-musl 149 | filename: rotz 150 | runner: ubuntu-latest 151 | compile: cross 152 | 153 | - triple: i686-unknown-linux-musl 154 | filename: rotz 155 | runner: ubuntu-latest 156 | compile: cross 157 | 158 | - triple: x86_64-apple-darwin 159 | filename: rotz 160 | runner: macos-latest 161 | compile: native 162 | 163 | - triple: aarch64-apple-darwin 164 | filename: rotz 165 | runner: macos-latest 166 | compile: native 167 | 168 | steps: 169 | - uses: actions/checkout@v4 170 | 171 | - uses: dtolnay/rust-toolchain@stable 172 | with: 173 | target: ${{ matrix.target.triple }} 174 | if: matrix.target.compile == 'native' 175 | 176 | - uses: actions/cache@v4 177 | with: 178 | path: | 179 | ~/.cargo/bin/ 180 | ~/.cargo/registry/index/ 181 | ~/.cargo/registry/cache/ 182 | ~/.cargo/git/db/ 183 | key: ${{ runner.os }} 184 | 185 | - run: cargo install cross --git https://github.com/cross-rs/cross 186 | if: matrix.target.compile == 'cross' 187 | 188 | - run: cargo build --release --target ${{ matrix.target.triple }} --target-dir ${{ runner.temp }} ${{ matrix.target.args || '' }} 189 | if: matrix.target.compile == 'native' 190 | 191 | - run: cross build --release --target ${{ matrix.target.triple }} --target-dir ${{ runner.temp }} ${{ matrix.target.args || '' }} 192 | if: matrix.target.compile == 'cross' 193 | 194 | - uses: actions/upload-artifact@v4 195 | with: 196 | name: rotz-${{ matrix.target.triple }} 197 | path: ${{ runner.temp }}/${{ matrix.target.triple }}/release/${{ matrix.target.filename }} 198 | if-no-files-found: error 199 | 200 | release: 201 | name: Release 202 | runs-on: ubuntu-latest 203 | needs: build 204 | environment: crates 205 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 206 | 207 | env: 208 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 209 | steps: 210 | - uses: actions/checkout@v4 211 | 212 | - uses: dtolnay/rust-toolchain@stable 213 | 214 | - uses: actions/cache@v4 215 | with: 216 | path: | 217 | ~/.cargo/bin/ 218 | ~/.cargo/registry/index/ 219 | ~/.cargo/registry/cache/ 220 | ~/.cargo/git/db/ 221 | target/ 222 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 223 | 224 | - run: cargo publish --dry-run 225 | 226 | - uses: actions/download-artifact@v4 227 | with: 228 | path: artifacts 229 | 230 | - run: | 231 | cd artifacts; 232 | for i in */; do 233 | cd "$i"; 234 | zip -r "../${i%/}.zip" ./; 235 | cd ..; 236 | rm -rf "$i"; 237 | done 238 | 239 | - run: | 240 | cd artifacts; 241 | for i in *.zip; do 242 | sha256sum "$i" | cut -d " " -f 1 > "$i.sha256"; 243 | done 244 | 245 | - name: Create github Release 246 | uses: docker://antonyurchenko/git-release:v6 247 | with: 248 | args: artifacts/* 249 | env: 250 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 251 | 252 | - run: git clean -xdf 253 | 254 | - run: cargo publish 255 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - docs/** 8 | - .github/workflows/docs.yml 9 | pull_request: 10 | branches: [main] 11 | paths: 12 | - docs/** 13 | - .github/workflows/docs.yml 14 | 15 | concurrency: 16 | group: pages 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 25 | 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 22.x 29 | cache: npm 30 | cache-dependency-path: ./docs/package-lock.json 31 | 32 | - name: Build docs 33 | working-directory: docs 34 | run: | 35 | npm ci 36 | npm run build 37 | 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | with: 41 | path: ./docs/build 42 | 43 | deploy: 44 | needs: build 45 | 46 | permissions: 47 | pages: write 48 | id-token: write 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | 54 | if: github.ref == 'refs/heads/main' 55 | 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v4 62 | -------------------------------------------------------------------------------- /.github/workflows/fmt-command.yml: -------------------------------------------------------------------------------- 1 | name: Format Command 2 | on: 3 | repository_dispatch: 4 | types: [fmt-command] 5 | jobs: 6 | fmt: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | with: 11 | repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} 12 | ref: ${{ github.event.client_payload.pull_request.head.ref }} 13 | 14 | - uses: dtolnay/rust-toolchain@stable 15 | with: 16 | components: rustfmt 17 | 18 | - name: cargo fmt 19 | id: fmt 20 | run: | 21 | format=$(cargo fmt --check --quiet || echo "true") 22 | echo "format=$format" >> $GITHUB_OUTPUT 23 | 24 | - name: Commit to the PR branch 25 | if: steps.fmt.outputs.format == 'true' 26 | run: | 27 | cargo fmt 28 | git config --global user.name 'actions-bot' 29 | git config --global user.email '58130806+actions-bot@users.noreply.github.com' 30 | git commit -am ":art:" 31 | git push 32 | 33 | - name: Add reaction 34 | uses: peter-evans/create-or-update-comment@v4 35 | with: 36 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 37 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 38 | reactions: hooray 39 | reactions-edit-mode: replace -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | 10 | # Added by cargo 11 | 12 | /target 13 | 14 | .vscode 15 | 16 | trace*.json 17 | .helix 18 | 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | max_width = 200 3 | 4 | edition = "2024" 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.2.1] - 2025-04-14 10 | 11 | - Update dependencies 12 | - Update selector error messages 13 | 14 | ## [1.2.0] - 2025-03-07 15 | 16 | - Added shell completion ([#365](https://github.com/volllly/rotz/pull/365) by [@IcyTv](https://github.com/IcyTv)) 17 | 18 | ## [1.1.0] - 2025-02-27 19 | 20 | ### Added 21 | 22 | - Added feature to allow [advanced selection](https://volllly.github.io/rotz/docs/configuration/os-specific-configuration#advanced-selection) for the os keys ([#331](https://github.com/volllly/rotz/issues/331)). 23 | This allows for e.g. selecting by the distro name: 24 | ```yaml 25 | linux[whoami.distro^="Ubuntu"]: 26 | installs: sudo apt install -y {{ name }} 27 | linux[whoami.distro^="Arch"]: 28 | installs: sudo pacman -S --noconfirm {{ name }} 29 | ``` 30 | 31 | ### Changed 32 | 33 | - Updated terminal colors to be more readable 34 | 35 | ### Fixed 36 | 37 | - Fixed resolution of `~` to the users home directory in configuration and cli ([#358](https://github.com/volllly/rotz/issues/358)) 38 | 39 | ### Removed 40 | 41 | - Removed support for the previously deprecated name `dots.(yaml|toml|json)` for the defaults file `defaults.(yaml|toml|json)` 42 | 43 | ## [1.0.0] - 2024-12-17 44 | 45 | ### Removed 46 | 47 | - Removed the `sync` command from rotz ([#334](https://github.com/volllly/rotz/discussions/334)) 48 | 49 | ## [0.10.0] - 2023-12-10 50 | 51 | ### Added 52 | 53 | - Default files `default.(yaml|toml|json)` can now be located in any folder of the dotfiles repo. The defaults will be applied to all `dot.(yaml|toml|json)` files in the same folder and all subfolders. 54 | 55 | ### Changed 56 | 57 | - Repo level config file now don't need to specify `global`, `windows`, `linux` or `darwin` keys. If none is provided the `global` key will be used. 58 | 59 | ## [0.9.5] - 2023-07-14 60 | 61 | ### Added 62 | 63 | - Added build target for aarch64-pc-windows-msvc (without "handlebars_misc_helpers/http_attohttpc" feature) 64 | - Added .sha256 checksum files to releases 65 | 66 | ## [0.9.4] - 2023-07-05 67 | 68 | ### Added 69 | 70 | - Added build targets for aarch64 architectures @kecrily 71 | 72 | ## [0.9.3] - 2023-02-12 73 | 74 | ### Fixed 75 | 76 | - Issue where rotz would create empty symlinks if the source file does not exist 77 | 78 | ## [0.9.2] - 2023-01-18 79 | 80 | ### Fixed 81 | 82 | - Issue where rotz would incorrectly flag files as orphans 83 | 84 | ## [0.9.1] - 2022-11-06 85 | 86 | ### Added 87 | 88 | - Added binaries to relases 89 | 90 | ## [0.9.0] - 2022-10-07 91 | 92 | ### Added 93 | 94 | - Linked files are tracked and stored 95 | - When a previously linked file is not a link target anymore it will be removed ([#8](https://github.com/volllly/rotz/issues/8)) 96 | 97 | ### Changed 98 | 99 | - When previously linked file is linked again it will be automatically overwritten without the need for the `--force` cli flag 100 | 101 | ## [0.8.1] - 2022-09-29 102 | 103 | ### Fixed 104 | 105 | - Issue where rotz could not parse dots with mixed links section types ([#40](https://github.com/volllly/rotz/issues/40)) 106 | 107 | ### Changed 108 | 109 | - Updated cli parser to clap v4 which slightly changes help output 110 | 111 | ## [0.8.0] - 2022-09-16 112 | 113 | ### Added 114 | 115 | - Template helpers `#windows`, `#linx` and `#darwin` which work like `if`s for the respective os 116 | - `eval` template helper which evaluates the given string on the shell 117 | 118 | ## [0.7.1] - 2022-09-12 119 | 120 | ### Fixed 121 | 122 | - Filtering of dots in commands was not working correctly 123 | 124 | ## [0.7.0] - 2022-09-11 125 | 126 | ### Changed 127 | 128 | - The repo level config file now has support for a `force` key for forced values which cannot be changed by the config file 129 | - Rotz can now automatically detect the filetype and parse the format if the feature (`yaml`, `toml` or `json`) is enabled 130 | - The features `yaml`, `toml` and `json` can now be enabled simultaneously 131 | 132 | ### Added 133 | 134 | - Added `whoami` variable to templating 135 | - Added `directories` variable to templating 136 | - Add ability to recurse into subdirectories 137 | 138 | ### Fixed 139 | 140 | - Bug where the repo level config would not merge correctly 141 | 142 | ## [0.6.1] - 2022-08-18 143 | 144 | ### Changed 145 | 146 | - The repo level config file now uses the key `global` instead of `default` 147 | - The default `shell_command` on windows now correctly uses PowerShell instead of PowerShell Core 148 | 149 | ### Fixed 150 | 151 | - The repo level config file can now override config default values 152 | 153 | ## [0.6.0] - 2022-07-29 154 | 155 | ### Added 156 | 157 | - Implemented init command which initializes the config 158 | - Added templating to `dot.(yaml|toml|json)` files 159 | 160 | ### Removed 161 | 162 | - Removed the `repo` key from the config as its not needed 163 | 164 | ### Changed 165 | 166 | - The `repo` argument is now required for the clone command 167 | 168 | ## [0.5.0] - 2022-07-15 169 | 170 | ### Added 171 | 172 | - Implemented install command functionality 173 | 174 | ## [0.4.1] - 2022-06-30 175 | 176 | ### Fixed 177 | 178 | - Wildcard "*" in install command not working 179 | - Defaults and global values in `dot.(yaml|toml|json)` files not working correctly 180 | 181 | ## [0.4.0] - 2022-06-29 182 | 183 | ### Added 184 | 185 | - Global `--dry-run` cli parameter 186 | - Implemented install command functionality 187 | - Option to skip installing dependencies in install command 188 | - Option to continue on installation error in install command 189 | - Support for a repo level config file. You can now add a `config.(yaml|toml|json)` file containing os specific defaults to the root of your dotfiles repo. 190 | - `shell_command` configuration parameter 191 | 192 | ### Changed 193 | 194 | - Improved Error messages 195 | 196 | ### Fixed 197 | 198 | - Parsing of `dot.(yaml|toml|json)` files in the `installs` section 199 | 200 | ### Removed 201 | 202 | - Removed the `update` command. Updates to the applications should be performed by your packagemanager. 203 | 204 | ## [0.3.2] - 2022-06-28 205 | 206 | ### Fixed 207 | 208 | - Linking now also creates the parent directory if it's not present on windows 209 | 210 | ## [0.3.1] - 2022-05-27 211 | 212 | ### Added 213 | 214 | - Added error codes and help messages 215 | 216 | ### Changed 217 | 218 | - Refactored the command code 219 | 220 | ### Fixed 221 | 222 | - Linking now also creates the parent directory if it's not present 223 | 224 | ## [0.3.0] - 2022-05-09 225 | 226 | ### Added 227 | 228 | - `clone` command creates a config file with the repo configured if it does not exist 229 | - Started adding unit tests 230 | 231 | ### Changed 232 | 233 | - Better error messages 234 | - Moved from [eyre](https://crates.io/crates/eyre) to [miette](https://crates.io/crates/miette) for error handline 235 | 236 | ## [0.2.0] - 2022-02-21 237 | 238 | ### Added 239 | 240 | - Added `clone` command 241 | 242 | ### Fixed 243 | 244 | - Fixed `link` command default value for Dots not working 245 | 246 | ## [0.1.1] - 2022-02-18 247 | 248 | ### Changed 249 | 250 | - Updated Readme 251 | 252 | ## [0.1.0] - 2022-02-18 253 | 254 | ### Added 255 | 256 | - Cli parsing 257 | - Config parsing 258 | - `yaml` support 259 | - `toml` support 260 | - `json` support 261 | - Dotfile linking 262 | - Error handling 263 | 264 | [Unreleased]: https://github.com/volllly/rotz/compare/v1.2.1...HEAD 265 | [1.2.1]: https://github.com/volllly/rotz/releases/tag/v1.2.1 266 | [1.2.0]: https://github.com/volllly/rotz/releases/tag/v1.2.0 267 | [1.1.0]: https://github.com/volllly/rotz/releases/tag/v1.1.0 268 | [1.0.0]: https://github.com/volllly/rotz/releases/tag/v1.0.0 269 | [0.10.0]: https://github.com/volllly/rotz/releases/tag/v0.10.0 270 | [0.9.5]: https://github.com/volllly/rotz/releases/tag/v0.9.5 271 | [0.9.4]: https://github.com/volllly/rotz/releases/tag/v0.9.4 272 | [0.9.3]: https://github.com/volllly/rotz/releases/tag/v0.9.3 273 | [0.9.2]: https://github.com/volllly/rotz/releases/tag/v0.9.2 274 | [0.9.1]: https://github.com/volllly/rotz/releases/tag/v0.9.1 275 | [0.9.0]: https://github.com/volllly/rotz/releases/tag/v0.9.0 276 | [0.8.1]: https://github.com/volllly/rotz/releases/tag/v0.8.1 277 | [0.8.0]: https://github.com/volllly/rotz/releases/tag/v0.8.0 278 | [0.7.1]: https://github.com/volllly/rotz/releases/tag/v0.7.1 279 | [0.7.0]: https://github.com/volllly/rotz/releases/tag/v0.7.0 280 | [0.6.1]: https://github.com/volllly/rotz/releases/tag/v0.6.1 281 | [0.6.0]: https://github.com/volllly/rotz/releases/tag/v0.6.0 282 | [0.5.0]: https://github.com/volllly/rotz/releases/tag/v0.5.0 283 | [0.4.1]: https://github.com/volllly/rotz/releases/tag/v0.4.1 284 | [0.4.0]: https://github.com/volllly/rotz/releases/tag/v0.4.0 285 | [0.3.2]: https://github.com/volllly/rotz/releases/tag/v0.3.2 286 | [0.3.1]: https://github.com/volllly/rotz/releases/tag/v0.3.1 287 | [0.3.0]: https://github.com/volllly/rotz/releases/tag/v0.3.0 288 | [0.2.0]: https://github.com/volllly/rotz/releases/tag/v0.2.0 289 | [0.1.1]: https://github.com/volllly/rotz/releases/tag/v0.1.1 290 | [0.1.0]: https://github.com/volllly/rotz/releases/tag/v0.1.0 291 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rotz" 3 | version = "1.2.1" 4 | edition = "2024" 5 | authors = ["Paul Volavsek "] 6 | license = "MIT" 7 | description = "Fully cross platform dotfile manager written in rust." 8 | repository = "https://github.com/volllly/rotz" 9 | readme = "./README.md" 10 | documentation = "https://docs.rs/rotz/" 11 | keywords = ["dotfiles", "dotfiles-manager", "dotfile-manager", "cross-platform"] 12 | categories = ["command-line-utilities"] 13 | 14 | [features] 15 | 16 | default = ["all-formats", "handlebars_misc_helpers/http_attohttpc"] 17 | 18 | yaml = ["serde_yaml", "figment/yaml"] 19 | toml = ["serde_toml", "figment/toml"] 20 | json = ["serde_json", "figment/json"] 21 | 22 | all-formats = ["yaml", "toml", "json"] 23 | 24 | profiling = ["tracing", "tracing-tracy"] 25 | 26 | [dependencies] 27 | clap = { version = "4.5.4", features = ["derive", "color"] } 28 | serde_yaml = { version = "0.9.34", optional = true } 29 | serde_toml = { package = "toml", version = "0.8.12", optional = true } 30 | serde_json = { version = "1.0.116", optional = true } 31 | figment = { version = "0.10.18", default-features = false, features = ["env"] } 32 | directories = "6.0.0" 33 | serde = { version = "1.0.198", features = ["derive"] } 34 | crossterm = "0.29.0" 35 | itertools = "0.14.0" 36 | thiserror = "2.0.7" 37 | baker = "0.2.0" 38 | miette = { version = "7.5.0", features = ["fancy"] } 39 | indexmap = { version = "2.2.6", features = ["serde"] } 40 | handlebars = "6.2.0" 41 | handlebars_misc_helpers = { version = "0.17.0", default-features = false, features = [ 42 | "string", 43 | "json", 44 | ] } 45 | shellwords = "1.1.0" 46 | path-absolutize = "3.1.1" 47 | walkdir = "2.5.0" 48 | wax = { version = "0.6.0", features = ["miette"] } 49 | whoami = "1.5.1" 50 | path-slash = "0.2.1" 51 | velcro = "0.5.4" 52 | tap = "1.0.1" 53 | tracing = { version = "0.1.40", optional = true } 54 | tracing-tracy = { version = "0.11.0", optional = true } 55 | tracing-subscriber = { version = "0.3.18", optional = true } 56 | strum = { version = "0.27.1", features = ["derive"] } 57 | chumsky = "0.10.1" 58 | clap_complete = "4.5.46" 59 | 60 | [target.'cfg(windows)'.dependencies] 61 | junction = "1.0.0" 62 | 63 | [target.x86_64-unknown-linux-musl.dependencies] 64 | openssl-sys = { version = "0.9.106", features = ["vendored"] } 65 | 66 | [target.i686-unknown-linux-musl.dependencies] 67 | openssl-sys = { version = "0.9.106", features = ["vendored"] } 68 | 69 | [target.aarch64-unknown-linux-musl.dependencies] 70 | openssl-sys = { version = "0.9.106", features = ["vendored"] } 71 | 72 | 73 | [dev-dependencies] 74 | fake = { version = "4.0.0", features = ["derive"] } 75 | rand = "0.9.0" 76 | rstest = "0.25.0" 77 | speculoos = "0.13.0" 78 | 79 | [package.metadata.cross.build] 80 | pre-build = [ 81 | "dpkg --add-architecture $CROSS_DEB_ARCH", 82 | "apt-get update && apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH", 83 | ] 84 | 85 | [package.metadata.cross.target.x86_64-unknown-linux-musl] 86 | pre-build = [] 87 | 88 | [package.metadata.cross.target.i686-unknown-linux-musl] 89 | pre-build = [] 90 | 91 | [package.metadata.cross.target.aarch64-unknown-linux-musl] 92 | pre-build = [] 93 | 94 | [lints.clippy] 95 | all = { level = "warn", priority = 0 } 96 | pedantic = { level = "warn", priority = 0 } 97 | nursery = { level = "warn", priority = 0 } 98 | cargo = { level = "warn", priority = 0 } 99 | 100 | multiple_crate_versions = { level = "allow", priority = 1 } 101 | use_self = { level = "allow", priority = 1 } 102 | default_trait_access = { level = "allow", priority = 1 } 103 | redundant_pub_crate = { level = "allow", priority = 1 } 104 | filetype_is_file = { level = "warn", priority = 1 } 105 | string_to_string = { level = "warn", priority = 1 } 106 | unneeded_field_pattern = { level = "warn", priority = 1 } 107 | self_named_module_files = { level = "warn", priority = 1 } 108 | str_to_string = { level = "warn", priority = 1 } 109 | as_conversions = { level = "warn", priority = 1 } 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Paul Volavsek 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rotz 👃 2 | [![crates.io](https://img.shields.io/crates/v/rotz)](https://crates.io/crates/rotz) 3 | ![](https://img.shields.io/badge/platform-windows%20%7C%20linux%20%7C%20macos-lightgrey) 4 | [![](https://img.shields.io/crates/l/rotz)](https://github.com/volllly/rotz/blob/main/LICENSE) 5 | 6 | Fully cross platform dotfile manager and dev environment bootstrapper written in Rust. 7 | 8 | > `Rust Dotfilemanager`
9 | > `Rust Dotfile manager`
10 | > `Rust Dotfile s`
11 | > `Rust Dot s`
12 | > `R ust Dots`
13 | > `R ots`
14 | > `Rot s`
15 | > `Rotz` 16 | 17 | ## [📖 Documentation](https://volllly.github.io/rotz/) 18 | 19 | ## Overview 20 | 21 | Rotz has three main functionalities: 22 | 23 | 1. Linking dotfiles from a common repository to your system 24 | 2. Installing the applications you need to start working on an new/empty machine 25 | 3. Full Cross platform functionality [See Configuration](https://volllly.github.io/rotz/docs/configuration/os-specific-configuration) 26 | 27 | ## Installation 28 | 29 | ### Homebrew 30 | 31 | On Linux and MacOS you can install Rotz using [Homebrew](https://brew.sh/). 32 | 33 | ```sh 34 | brew install volllly/tap/rotz 35 | ``` 36 | 37 | ### Scoop 38 | 39 | On Windows you can install Rotz using [Scoop](https://scoop.sh/). 40 | 41 | ```pwsh 42 | scoop bucket add volllly https://github.com/volllly/scoop-bucket 43 | scoop install volllly/rotz 44 | ``` 45 | 46 | ### Cargo 47 | 48 | You can install Rotz using cargo everywhere if Rust is installed. 49 | 50 | ```bash 51 | cargo install rotz 52 | ``` 53 | 54 | #### File Formats 55 | 56 | Rotz uses [`yaml`](https://yaml.org/), [`toml`](https://toml.io/) or [`json`](https://www.json.org/) configuration files per default. 57 | 58 | > ***Note:** Rotz will auto detect the correct filetype.* 59 | 60 | You can install rotz with support for only one of the filetypes by using the `--features` flag. 61 | * ```sh 62 | cargo install rotz --no-default-features --features toml 63 | ``` 64 | * ```sh 65 | cargo install rotz --no-default-features --features json 66 | ``` 67 | 68 | ## Installer scripts 69 | 70 | ```sh 71 | curl -fsSL volllly.github.io/rotz/install.sh | sh 72 | ``` 73 | 74 | ```pwsh 75 | irm volllly.github.io/rotz/install.ps1 | iex 76 | ``` 77 | 78 | ## Getting Started 79 | 80 | If you already have a `dotfiles` repo you can clone it with the `rotz clone` command. 81 | 82 | ```sh 83 | rotz clone git@github.com:/.git 84 | ``` 85 | 86 | To bootstrap your dev environment use `rotz install`. 87 | 88 | To link your `dotfiles` use `rotz link`. 89 | 90 | ## Usage 91 | 92 | Run `rotz --help` to see all commands Rotz has. 93 | 94 | ## Contribute 95 | 96 | Feel free to create pull requests and issues for bugs, features or questions. 97 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/configuration/README.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | --- 4 | 5 | Rotz uses a git repo containing the `dotfiles` and [`yaml`](https://yaml.org/), [toml] or [json] files for configuration. 6 | 7 | :::tip 8 | To use another file format see [Other File Formats](#other-file-formats). 9 | ::: 10 | 11 | This git repo should be located at `~/.dotfiles`. Different paths can be specified using the `--dotfiles` cli flag or in the Rotz [config file](#configyaml). 12 | 13 | :::tip 14 | The location of the config file can be overridden using the `--config` cli flag. To get the default location of the config run `rotz --help` 15 | ::: 16 | 17 | Each managed application has a subfolder containing its `dotfiles` and a `dot.yaml` file. 18 | 19 | import TabedCodeBlock from '@site/src/components/TabedCodeBlock'; 20 | 21 | 44 | 45 | The file `dot.yaml`/`dot.toml`/`dot.json` contains information about how to install the application and where to link the dotfiles. 46 | 47 | ## Nesting 48 | 49 | The dots can also be nested. See the [Nesting](dot.yaml.mdx#nesting) section for more information. 50 | 51 | 86 | -------------------------------------------------------------------------------- /docs/docs/configuration/_category_.yml: -------------------------------------------------------------------------------- 1 | position: 3 2 | label: Configuration 3 | collapsible: true 4 | collapsed: false 5 | -------------------------------------------------------------------------------- /docs/docs/configuration/config.yaml.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: config.yaml 4 | --- 5 | import TabedCodeBlock from '@site/src/components/TabedCodeBlock'; 6 | import { Section } from '@ltd/j-toml'; 7 | 8 | The following settings are configurable in the config file like so: 9 | 10 | 11 | ", 14 | link_type: "<'symbolic'|'hard'>", 15 | shell_command: "", 16 | variables: "" 17 | }} /> 18 | 19 | Those settings can be overridden in the cli when applicable (see `rotz --help` and `rotz --help` to get more information). 20 | 21 | ## `shell_command` 22 | 23 | This setting allows to specify how Rotz should launch the install command. 24 | 25 | If this is not set the default values are used. 26 | 27 | 31 | 32 | 36 | 37 | 41 | 42 | 43 | 44 | ## `variables` 45 | 46 | These variables can be used in [templates](templating.md). 47 | 48 | 58 | 59 | ## Repo defaults 60 | 61 | It is possible to put a config file in your repo containing default values depending on the OS. These are overridden by the config file on the machine. 62 | 63 | ' 67 | }), 68 | windows: Section({ 69 | dotfiles: '' 70 | }), 71 | linux: Section({ 72 | dotfiles: '' 73 | }), 74 | darwin: Section({ 75 | dotfiles: '' 76 | }) 77 | }} /> 78 | 79 | > If no `global`, `windows`, `linux` or `darwin` key is provided the `global` key will be assumed. 80 | > data={{ 82 | > link_type: '' 83 | > }} /> 84 | -------------------------------------------------------------------------------- /docs/docs/configuration/defaults.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | title: Defaults 4 | --- 5 | import TabedCodeBlock from '@site/src/components/TabedCodeBlock'; 6 | import { Section } from '@ltd/j-toml'; 7 | 8 | The repo can also contain default files `defaults.yaml` in any folder of the repo. 9 | 10 | These files contain defaults which are automatically used in sub directories for empty keys in the `dot.yaml` files. 11 | 12 | You can use template strings `{{ name }}` to substitute the name of the application (the name of the folder the `dot.yaml` file is located in). See [templating](templating.md) for advanced templating. 13 | 14 | 23 | -------------------------------------------------------------------------------- /docs/docs/configuration/dot.yaml.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: dot.yaml 3 | sidebar_position: 2 4 | --- 5 | 6 | import TOCInline from '@theme/TOCInline'; 7 | import TabedCodeBlock from '@site/src/components/TabedCodeBlock'; 8 | import { Section } from '@ltd/j-toml'; 9 | 10 | > ## Sections 11 | > 12 | 13 | The `dot.yaml` file consists of four optional keys: 14 | 15 | | key | requirement | function | 16 | |------------|-------------|-------------------------------------------------------| 17 | | `links` | `optional` | Defines where to link which `dotfile` | 18 | | `installs` | `optional` | Defines the install command and install dependencies. | 19 | | `depends` | `optional` | Defines dependencies this application needs to work. | 20 | 21 | ## `links` 22 | 23 | The `links` section specifies where the dotfiles should be linked. 24 | 25 | It consists of multiple `key: value` pairs where the `key` is the filename of the `dotfile` and the `value` is the link path. 26 | 27 | 34 | 35 | ## `installs` 36 | 37 | The `installs` section contains the install command and optional install dependencies. 38 | 39 | It can either be a `string` containing the install command or have two sub keys. 40 | 41 | | key | requirement | function | 42 | |-----------|-------------|------------------------------------| 43 | | `cmd` | `required` | Contains the install command. | 44 | | `depends` | `optional` | Contains an array of dependencies. | 45 | 46 | 47 | 54 | 55 | 59 | 60 | :::note 61 | The command can also be set to `false`. This overwrites the value set in the [defaults `defaults.yaml`](./defaults) file. 62 | ::: 63 | 64 | 65 | ## `depends` 66 | 67 | The `depends` section contains an array of dependencies needed for the application to work correctly. 68 | 69 | These dependencies will also be installed when the application is installed. 70 | 71 | 75 | 76 | 77 | ## Nesting 78 | 79 | If you have dots nested in subdirectories dependencies need to specify them as a path. 80 | 81 | This can either be a path relative to the current dot or an absolute path (the dotfiles repo is used as root). 82 | 83 | 84 | 116 | 117 | 121 | 122 | 126 | -------------------------------------------------------------------------------- /docs/docs/configuration/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | sidebar_position: 6 4 | --- 5 | 6 | You can see all of this functionality used in my [own dotfiles repository](https://github.com/volllly/.dotfiles). 7 | -------------------------------------------------------------------------------- /docs/docs/configuration/os-specific-configuration.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: OS Specific Configuration 3 | sidebar_position: 4 4 | --- 5 | import TabedCodeBlock from '@site/src/components/TabedCodeBlock'; 6 | import { Section } from '@ltd/j-toml'; 7 | 8 | You can specify different behaviors per OS in all configuration files. 9 | 10 | Rotz can differentiate between Windows, Linux and MacOS. 11 | 12 | To specify OS Specific behavior you need to add top level keys named `linux`, `windows`, `darwin` (for MacOS) and `general` (applied to all OSs). 13 | 14 | 34 | 35 | 50 | 51 | You can also combine multiple OSs per key separating them with a `|`. 52 | 53 | 73 | 74 | ## Advanced Selection 75 | 76 | Sometimes it's necessairy to differentiate between different versions of an OS.\ 77 | For example, you might want to differentiate between Windows 10 and Windows 11 or between Ubuntu and Fedora.\ 78 | Or maybe you want to do differnt things depending on the host name of your machine. 79 | 80 | You can add a selector to the OS key to do this. 81 | 82 | A selector looks like this: `linux[some.key="some value"]`. 83 | 84 | You can check for equality with `=`, for starts with with `^`, for ends with with `$`, for contains with `*` and for not equals with `!=`. 85 | 86 | | Selector | Description | Example | 87 | |----------|-----------------------------------------------------|-------------------------------------------| 88 | | `=` | Check for equality | `linux[whoami.username="me"]` | 89 | | `^=` | Check if the value starts with the given string | `linux[whoami.distro^="Ubuntu"]` | 90 | | `$=` | Check if the value ends with the given string | `linux[whoami.realname$="Doe"]` | 91 | | `*=` | Check if the value contains the given string | `linux[whoami.arch*="64"]` | 92 | | `!=` | Check if the value is not equal to the given string | `linux[config.variables.profile!="work"]` | 93 | 94 | You can also add multiple selectors to the same key `linux[some.key="some value"][other.key="other value"]`.\ 95 | And even combine them with `|` like `linux[some.key="some value"]|windows[other.key="other value"]`. 96 | 97 | 106 | -------------------------------------------------------------------------------- /docs/docs/configuration/templating.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Templating 3 | sidebar_position: 5 4 | --- 5 | 6 | You can use [handlebars](https://handlebarsjs.com/guide/) template syntax in [`dot.yaml`](dot.yaml.mdx) files and the [defaults file](defaults.mdx). 7 | 8 | This allows for e.g. access to environment variables. 9 | 10 | ## Variables 11 | 12 | | Variable | Description | Example | 13 | |----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| 14 | | `config` | The current config | `depends: [ {{#each config.variables.some ~}} "{{this}}", {{/each }} ]` | 15 | | `env` | A map of Environment variables | `some.file: {{ env.HOME }}/some.file` | 16 | | `name` | The name of the current dot | `installs: apt install {{ name }}` | 17 | | `os` | The current os (either `windows`, `linux` or `darwin`) as used in dots | `{{#if (eq os "windows")}}some: value{{/if}}` | 18 | | `whoami` | A map of information about the environment (see [whoami](#whoami)). Provided by the [whoami](https://github.com/ardaku/whoami#features) crate. | `some.file: /home/{{ whoami.username }}/some.file` | 19 | | `dirs` | A map of directories (see [below](#directories) for a list of available directories). Provided by the [directories](https://github.com/dirs-dev/directories-rs#features) crate. | `some.file: {{ dirs.base.home }}/some.file` | 20 | 21 | ### `whoami` 22 | 23 | | Variable | Description | 24 | |----------------------|-------------------------------------------| 25 | | `whoami.desktop_env` | Information about the Desktop environment | 26 | | `whoami.devicename` | The device name | 27 | | `whoami.distro` | The os distro | 28 | | `whoami.hostname` | The hostname | 29 | | `whoami.lang` | An array of the users prefered languages | 30 | | `whoami.platform` | The current platform | 31 | | `whoami.realname` | The users full name | 32 | | `whoami.username` | The current users username | 33 | | `whoami.arch` | The CPU architecture of the system. | 34 | 35 | ### `directories` 36 | 37 | | Variable | 38 | |-------------------| 39 | | `dirs.base.cache` | 40 | | `dirs.base.config` | 41 | | `dirs.base.data` | 42 | | `dirs.base.data_local` | 43 | | `dirs.base.home` | 44 | | `dirs.base.preference` | 45 | | | 46 | | `dirs.user.audio` | 47 | | `dirs.user.desktop` | 48 | | `dirs.user.document` | 49 | | `dirs.user.download` | 50 | | `dirs.user.home` | 51 | | `dirs.user.picture` | 52 | | `dirs.user.public` | 53 | | `dirs.user.template` | 54 | | `dirs.user.video` | 55 | 56 | ## Helpers 57 | 58 | Rotz comes with helpers provided by the [handlebars_misc_helpers](https://github.com/davidb/handlebars_misc_helpers) crate. 59 | 60 | Theres also a number of inbuilt helpers provided 61 | 62 | ### `#windows`, `#linx` and `#darwin` 63 | 64 | These helpers are shorthands for checking the curent os. 65 | 66 | Instea of `{{ #if (eq os "windows") }}{{ else }}{{ /if }}` they can be used like this `{{ #windows }}{{ else }}{{ /windows }}`. 67 | 68 | ### `eval` 69 | 70 | The eval helper can be used to evalate a string on the shell configured by [`shell_command`](config.yaml.mdx#shell_command). 71 | 72 | The helper can be used like this `{{ eval "some --shell command" }}` 73 | -------------------------------------------------------------------------------- /docs/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Getting Started 4 | --- 5 | 6 | ## Overview 7 | 8 | Rotz has three main functionalities: 9 | 10 | 1. Linking dotfiles from a common repository to your system 11 | 2. Installing the applications you need to start working on an new/empty machine 12 | 3. Full Cross platform functionality [See Configuration](#os-specific-configuration) 13 | 14 | ## Installation 15 | 16 | ### Homebrew 17 | 18 | On Linux and MacOS you can install Rotz using [Homebrew](https://brew.sh/). 19 | 20 | ```sh title="homebrew" 21 | brew install volllly/tap/rotz 22 | ``` 23 | 24 | ### Scoop 25 | 26 | On Windows you can install Rotz using [Scoop](https://scoop.sh/). 27 | 28 | ```pwsh title="scoop" 29 | scoop bucket add volllly https://github.com/volllly/scoop-bucket 30 | scoop install volllly/rotz 31 | ``` 32 | 33 | ### Cargo 34 | 35 | You can install Rotz using cargo everywhere if Rust is installed. 36 | 37 | ```bash title="cargo" 38 | cargo install rotz 39 | ``` 40 | 41 | #### Other File Formats 42 | 43 | Rotz uses [`yaml`](https://yaml.org/), [`toml`](https://toml.io/) or [`json`](https://www.json.org/) configuration files per default. 44 | 45 | You can install Rotz with support for only one of the filetypes by using the `--features` flag. 46 | 47 | ```bash title="toml" 48 | cargo install rotz --no-default-features --features toml 49 | ``` 50 | 51 | ```bash title="json" 52 | cargo install rotz --no-default-features --features json 53 | ``` 54 | 55 | ## Installer scripts 56 | 57 | ```sh title="Linux and MacOS" 58 | curl -fsSL volllly.github.io/rotz/install.sh | sh 59 | ``` 60 | 61 | ```pwsh title="Windows" 62 | irm volllly.github.io/rotz/install.sh | iex 63 | ``` 64 | 65 | --- 66 | 67 | ## Getting Started 68 | 69 | If you already have a `dotfiles` repo you can clone it with the `rotz clone` command. 70 | 71 | ```sh title="Clone command" 72 | rotz clone git@github.com:/.git 73 | ``` 74 | 75 | To bootstrap your dev environment use `rotz install`. 76 | 77 | To link your `dotfiles` use `rotz link`. -------------------------------------------------------------------------------- /docs/docs/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: Usage 4 | --- 5 | 6 | Run `rotz --help` to see all commands Rotz has. -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { themes } = require("prism-react-renderer"); 4 | const lightTheme = themes.github; 5 | const darkTheme = themes.dracula; 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: "Rotz", 10 | tagline: 11 | "Fully cross platform dotfile manager and dev environment bootstrapper written in Rust.", 12 | url: "https://volllly.github.io", 13 | baseUrl: process.env.NODE_ENV == "development" ? "/" : "/rotz/", 14 | onBrokenLinks: "throw", 15 | onBrokenMarkdownLinks: "warn", 16 | favicon: "img/logo.svg", 17 | organizationName: "volllly", 18 | projectName: "rotz", 19 | trailingSlash: false, 20 | 21 | presets: [ 22 | [ 23 | "@docusaurus/preset-classic", 24 | /** @type {import('@docusaurus/preset-classic').Options} */ 25 | ({ 26 | docs: { 27 | sidebarPath: require.resolve("./sidebars.js"), 28 | editUrl: "https://github.com/volllly/rotz/tree/main/docs/", 29 | }, 30 | theme: { 31 | customCss: require.resolve("./src/css/custom.css"), 32 | }, 33 | }), 34 | ], 35 | ], 36 | 37 | themes: [ 38 | [ 39 | require.resolve("@easyops-cn/docusaurus-search-local"), 40 | { 41 | hashed: true, 42 | indexBlog: false, 43 | }, 44 | ], 45 | ], 46 | 47 | themeConfig: 48 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 49 | ({ 50 | navbar: { 51 | title: "Rotz", 52 | logo: { 53 | alt: "Nose Emoji", 54 | src: "img/logo.svg", 55 | }, 56 | items: [ 57 | { 58 | type: "doc", 59 | docId: "getting-started", 60 | position: "left", 61 | label: "Getting Started", 62 | }, 63 | { 64 | type: "doc", 65 | docId: "configuration/README", 66 | position: "left", 67 | label: "Configuration", 68 | }, 69 | { 70 | href: "https://github.com/volllly/rotz", 71 | label: "GitHub", 72 | position: "right", 73 | }, 74 | ], 75 | }, 76 | footer: { 77 | style: "dark", 78 | links: [ 79 | { 80 | title: "Docs", 81 | items: [ 82 | { 83 | label: "Getting Started", 84 | to: "/docs/getting-started", 85 | }, 86 | { 87 | label: "Configuration", 88 | to: "/docs/configuration", 89 | }, 90 | ], 91 | }, 92 | { 93 | title: "More", 94 | items: [ 95 | { 96 | label: "Changelog", 97 | href: "https://github.com/volllly/rotz/blob/main/CHANGELOG.md", 98 | }, 99 | { 100 | label: "GitHub", 101 | href: "https://github.com/volllly/rotz", 102 | }, 103 | { 104 | label: "crates.io", 105 | href: "https://crates.io/crates/rotz", 106 | }, 107 | ], 108 | }, 109 | ], 110 | copyright: 111 | 'Rotz is an evolution of Dotted', 112 | }, 113 | prism: { 114 | theme: lightTheme, 115 | darkTheme: darkTheme, 116 | additionalLanguages: ["toml"], 117 | }, 118 | }), 119 | }; 120 | 121 | module.exports = config; 122 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "set NODE_ENV=development&& docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.7.0", 19 | "@docusaurus/preset-classic": "3.7.0", 20 | "@easyops-cn/docusaurus-search-local": "0.49.2", 21 | "@ltd/j-toml": "1.38.0", 22 | "@mdx-js/react": "3.1.0", 23 | "clsx": "2.1.1", 24 | "prism-react-renderer": "2.4.1", 25 | "react": "19.1.0", 26 | "react-dom": "19.1.0", 27 | "yaml": "2.8.0" 28 | }, 29 | "devDependencies": { 30 | "@docusaurus/module-type-aliases": "3.7.0", 31 | "@tsconfig/docusaurus": "2.0.3", 32 | "typescript": "5.8.3" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.5%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 4 | const sidebars = { 5 | docs: [{type: 'autogenerated', dirName: '.'}], 6 | }; 7 | 8 | module.exports = sidebars; 9 | -------------------------------------------------------------------------------- /docs/src/components/Features/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './styles.module.css'; 4 | 5 | export type FeatureItem = { 6 | title: string; 7 | Svg?: React.ComponentType>; 8 | emoji?: string; 9 | description: JSX.Element; 10 | }; 11 | 12 | function Feature({ title, Svg, emoji, description }: FeatureItem) { 13 | return ( 14 |
15 |
16 | {Svg ? : <>} 17 | {emoji ? {emoji} : <>} 18 |
19 |
20 |

{title}

21 |

{description}

22 |
23 |
24 | ); 25 | } 26 | 27 | export function Features({ features }: { features: FeatureItem[] }): JSX.Element { 28 | return ( 29 |
30 |
31 |
32 | {features.map((props, idx) => ( 33 | 34 | ))} 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /docs/src/components/Features/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/components/TabedCodeBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tabs from '@theme/Tabs'; 3 | import TabItem from '@theme/TabItem'; 4 | import CodeBlock from '@theme/CodeBlock'; 5 | import YAML from 'yaml'; 6 | import TOML from '@ltd/j-toml'; 7 | 8 | export type TabedCodeBlockProps = { 9 | title: string; 10 | data?: any; 11 | yaml?: string; 12 | toml?: string; 13 | json?: string; 14 | predots: boolean; 15 | }; 16 | 17 | export default function TabedCodeBlock({ title, data, yaml, toml, json, predots }: TabedCodeBlockProps): JSX.Element { 18 | yaml = yaml ?? YAML.stringify(data); 19 | toml = toml ?? TOML.stringify(data, { 20 | newline: "\n", 21 | indent: " " 22 | }); 23 | json = json ?? JSON.stringify(data, null, " "); 24 | 25 | yaml = yaml.trim(); 26 | toml = toml.trim(); 27 | json = json.trim(); 28 | 29 | if (predots) { 30 | yaml = `...\n\n${yaml}`; 31 | toml = `...\n\n${toml}`; 32 | json = `{\n...\n${json.substring(1)}`; 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | {yaml} 40 | 41 | 42 | 43 | 44 | {toml} 45 | 46 | 47 | 48 | 49 | {json} 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ifm-color-primary: #4c80ff; 3 | --ifm-color-primary-dark: #4473e5; 4 | --ifm-color-primary-darker: #3c66cc; 5 | --ifm-color-primary-darkest: #3559b2; 6 | --ifm-color-primary-light: #5d8cf; 7 | --ifm-color-primary-lighter: #6f99ff; 8 | --ifm-color-primary-lightest: #81a6ff; 9 | 10 | --ifm-code-font-size: 95%; 11 | } 12 | 13 | /* [data-theme='dark'] { 14 | --ifm-color-primary: #4c80ff; 15 | --ifm-color-primary-dark: #4473e5; 16 | --ifm-color-primary-darker: #3c66cc; 17 | --ifm-color-primary-darkest: #3559b2; 18 | --ifm-color-primary-light: #5d8cf; 19 | --ifm-color-primary-lighter: #6f99ff; 20 | --ifm-color-primary-lightest: #81a6ff; 21 | } */ 22 | 23 | .docusaurus-highlight-code-line { 24 | background-color: rgba(0, 0, 0, 0.1); 25 | display: block; 26 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 27 | padding: 0 var(--ifm-pre-padding); 28 | } 29 | 30 | [data-theme='dark'] .docusaurus-highlight-code-line { 31 | background-color: rgba(0, 0, 0, 0.3); 32 | } 33 | 34 | hr { 35 | border: 0; 36 | border-top: thin solid rgba(0, 0, 0, 0.1); 37 | } 38 | 39 | code { 40 | white-space: pre; 41 | } -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .heroBanner { 2 | padding: 4rem 0; 3 | padding-bottom: 2em; 4 | text-align: center; 5 | position: relative; 6 | overflow: hidden; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | @media screen and (max-width: 996px) { 12 | .heroBanner { 13 | padding: 2rem; 14 | } 15 | } 16 | 17 | .buttons { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | flex-wrap: wrap; 22 | gap: 2em; 23 | margin-bottom: 2em; 24 | } 25 | 26 | .rotzname { 27 | display: inline-flex; 28 | flex-direction: column; 29 | font-size: var(--ifm-h1-font-size); 30 | margin-bottom: 1em; 31 | } 32 | 33 | .rotzname > span { 34 | font-family: var(--ifm-font-family-monospace); 35 | line-height: 1em; 36 | text-align: left; 37 | } -------------------------------------------------------------------------------- /docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import Layout from "@theme/Layout"; 4 | import Link from "@docusaurus/Link"; 5 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 6 | import styles from "./index.module.css"; 7 | import { Features, FeatureItem } from "@site/src/components/Features"; 8 | 9 | function HomepageHeader() { 10 | const { siteConfig } = useDocusaurusContext(); 11 | return ( 12 |
13 |
14 |
15 | Rust Dotfilemanager 16 | Rust Dotfile manager 17 | Rust Dotfile s 18 | Rust Dot s 19 | R ust Dots 20 | R ots 21 | Rot s 22 | Rotz 👃 23 |
24 |

{siteConfig.tagline}

25 |
26 | 30 | Getting started 31 | 32 | 36 | Configuration 37 | 38 |
39 |
40 |
41 | ); 42 | } 43 | 44 | let commandList: FeatureItem[] = [ 45 | { 46 | emoji: "⬇️", 47 | title: "Clone your dotfiles", 48 | description: ( 49 | <> 50 | With Rotz you can clone your dotfiles from a git repository using the{" "} 51 | rotz clone command. 52 | 53 | ), 54 | }, 55 | { 56 | emoji: "💿", 57 | title: "Install software", 58 | description: ( 59 | <> 60 | You can bootstrap your new machine using the rotz install{" "} 61 | command. 62 | 63 | ), 64 | }, 65 | { 66 | emoji: "🚀", 67 | title: "Deploy dotfiles", 68 | description: ( 69 | <> 70 | You can automatically symlink your dotfiles to the correct places using 71 | the rotz link command. 72 | 73 | ), 74 | }, 75 | ]; 76 | 77 | let featureList: FeatureItem[] = [ 78 | { 79 | emoji: "⚙️", 80 | title: "Versatile configuration", 81 | description: ( 82 | <> 83 | You can specify where to link your dotfiles to and what software to 84 | install in yaml, toml or json{" "} 85 | config files. 86 | 87 | ), 88 | }, 89 | { 90 | emoji: "🪟🐧🍎", 91 | title: "Cross platform", 92 | description: ( 93 | <> 94 | Rotz works on Windows, Linux and MacOs and has full support for 95 | different configurations on each platform. 96 | 97 | ), 98 | }, 99 | { 100 | emoji: "🦀", 101 | title: "Open source and written in rust", 102 | description: ( 103 | <> 104 | If you find a bug or have a feature request feel free to open a{" "} 105 | github issue or 106 | even a pull request. 107 | 108 | ), 109 | }, 110 | ]; 111 | 112 | export default function Home(): JSX.Element { 113 | const { siteConfig } = useDocusaurusContext(); 114 | return ( 115 | 116 | 117 |
118 |
119 | 120 |
121 |
124 |
125 | 126 |
127 |
128 |
129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volllly/rotz/72dbd5b6c7eed8f77921aa983de68836120d7c74/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 👃 2 | -------------------------------------------------------------------------------- /docs/static/install.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | $ErrorActionPreference = 'Stop' 4 | 5 | if ($v) { 6 | $Version = "v${v}" 7 | } 8 | if ($Args.Length -eq 1) { 9 | $Version = $Args.Get(0) 10 | } 11 | 12 | $RotzInstall = $env:ROTZ_INSTALL 13 | $BinDir = if ($RotzInstall) { 14 | "${RotzInstall}\bin" 15 | } else { 16 | "${Home}\.rotz\bin" 17 | } 18 | 19 | $RotzZip = "$BinDir\rotz.zip" 20 | $RotzExe = "$BinDir\rotz.exe" 21 | $Target = if([Environment]::Is64BitOperatingSystem) { 22 | 'x86_64-pc-windows-msvc' 23 | } else { 24 | 'i686-pc-windows-msvc' 25 | } 26 | 27 | $DownloadUrl = if (!$Version) { 28 | "https://github.com/volllly/rotz/releases/latest/download/rotz-${Target}.zip" 29 | } else { 30 | "https://github.com/volllly/rotz/releases/download/${Version}/rotz-${Target}.zip" 31 | } 32 | 33 | if (!(Test-Path $BinDir)) { 34 | New-Item $BinDir -ItemType Directory | Out-Null 35 | } 36 | 37 | curl.exe -Lo $RotzZip $DownloadUrl 38 | 39 | tar.exe xf $RotzZip -C $BinDir 40 | 41 | Remove-Item $RotzZip 42 | 43 | $User = [System.EnvironmentVariableTarget]::User 44 | $Path = [System.Environment]::GetEnvironmentVariable('Path', $User) 45 | if (!(";${Path};".ToLower() -like "*;${BinDir};*".ToLower())) { 46 | [System.Environment]::SetEnvironmentVariable('Path', "${Path};${BinDir}", $User) 47 | $Env:Path += ";${BinDir}" 48 | } 49 | 50 | Write-Output "Rotz was installed successfully to ${RotzExe}" 51 | Write-Output "Run 'rotz --help' to get started" -------------------------------------------------------------------------------- /docs/static/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if ! command -v unzip >/dev/null; then 6 | echo "Error: unzip is required to install Rotz." 1>&2 7 | exit 1 8 | fi 9 | 10 | if [ "$OS" = "Windows_NT" ]; then 11 | target="x86_64-pc-windows-msvc" 12 | else 13 | case $(uname -sm) in 14 | "Darwin x86_64") target="x86_64-apple-darwin" ;; 15 | "Darwin arm64") target="aarch64-apple-darwin" ;; 16 | "Linux aarch64") target="aarch64-unknown-linux-gnu" ;; 17 | # TOTO: Add support for musl 18 | *) target="x86_64-unknown-linux-gnu" ;; 19 | esac 20 | fi 21 | 22 | if [ $# -eq 0 ]; then 23 | rotz_uri="https://github.com/volllly/rotz/releases/latest/download/rotz-${target}.zip" 24 | else 25 | rotz_uri="https://github.com/volllly/rotz/releases/download/${1}/rotz-${target}.zip" 26 | fi 27 | 28 | rotz_install="${ROTZ_INSTALL:-$HOME/.rotz}" 29 | bin_dir="$rotz_install/bin" 30 | exe="$bin_dir/rotz" 31 | 32 | if [ ! -d "$bin_dir" ]; then 33 | mkdir -p "$bin_dir" 34 | fi 35 | 36 | curl --fail --location --progress-bar --output "$exe.zip" "$rotz_uri" 37 | unzip -d "$bin_dir" -o "$exe.zip" 38 | chmod +x "$exe" 39 | rm "$exe.zip" 40 | 41 | echo "Rotz was installed successfully to $exe" 42 | if command -v rotz >/dev/null; then 43 | echo "Run 'rotz --help' to get started" 44 | else 45 | case $SHELL in 46 | /bin/zsh) shell_profile=".zshrc" ;; 47 | *) shell_profile=".bashrc" ;; 48 | esac 49 | echo "Manually add the directory to your \$HOME/$shell_profile (or similar)" 50 | echo " export ROTZ_INSTALL=\"$rotz_install\"" 51 | echo " export PATH=\"\$ROTZ_INSTALL/bin:\$PATH\"" 52 | echo "Run '$exe --help' to get started" 53 | fi -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":label(C-Dependencies)" 6 | ], 7 | "packageRules": [ 8 | { 9 | "groupName": "crates", 10 | "matchDatasources": [ 11 | "crate" 12 | ], 13 | "lockFileMaintenance": { 14 | "enabled": true, 15 | "automerge": true 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, str::FromStr}; 2 | 3 | use baker::Bake; 4 | use clap::{Args, Parser, Subcommand}; 5 | use clap_complete::Shell; 6 | #[cfg(test)] 7 | use fake::Dummy; 8 | use figment::{ 9 | Error, Metadata, Profile, Provider, map, 10 | value::{Dict, Map, Value}, 11 | }; 12 | use itertools::Itertools; 13 | use tap::Pipe; 14 | #[cfg(feature = "profiling")] 15 | use tracing::instrument; 16 | 17 | use crate::{FILE_EXTENSIONS, PROJECT_DIRS, config::LinkType, helpers}; 18 | 19 | #[derive(Debug, Clone)] 20 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 21 | pub struct PathBuf(pub(crate) std::path::PathBuf); 22 | 23 | impl From for PathBuf { 24 | fn from(value: std::path::PathBuf) -> Self { 25 | Self(value) 26 | } 27 | } 28 | 29 | impl FromStr for PathBuf { 30 | type Err = ::Err; 31 | 32 | fn from_str(s: &str) -> Result { 33 | PathBuf(std::path::PathBuf::from_str(s)?).pipe(Ok) 34 | } 35 | } 36 | 37 | impl Display for PathBuf { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | write!(f, "{}", self.0.display()) 40 | } 41 | } 42 | 43 | #[derive(Parser, Debug, Bake)] 44 | #[clap(version, about)] 45 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 46 | #[baked(name = "Globals", derive(Debug))] 47 | pub struct Cli { 48 | #[clap(long, short)] 49 | #[baked(ignore)] 50 | /// Overwrites the dotfiles path set in the config file 51 | /// 52 | /// If no dotfiles path is provided in the config file the default "~/.dotfiles" is used 53 | pub(crate) dotfiles: Option, 54 | 55 | #[clap(long, short, default_value_t = { 56 | helpers::get_file_with_format(PROJECT_DIRS.config_dir(), "config") 57 | .map(|p| p.0) 58 | .unwrap_or_else(|| PROJECT_DIRS.config_dir().join(format!("config.{}", FILE_EXTENSIONS[0].0))) 59 | .into() 60 | })] 61 | #[baked(ignore)] 62 | /// Path to the config file 63 | pub(crate) config: PathBuf, 64 | 65 | #[clap(long, short = 'r')] 66 | /// When this switch is set no changes will be made. 67 | pub(crate) dry_run: bool, 68 | 69 | #[clap(subcommand)] 70 | #[baked(ignore)] 71 | pub(crate) command: Command, 72 | } 73 | 74 | #[derive(Debug, Args, Clone)] 75 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 76 | pub struct Dots { 77 | #[clap(default_value = "**")] 78 | /// All dots to process. Accepts glob patterns. 79 | pub(crate) dots: Vec, 80 | } 81 | 82 | impl Dots { 83 | #[cfg_attr(feature = "profiling", instrument)] 84 | fn add_root(&self) -> Self { 85 | Self { 86 | dots: self.dots.iter().map(|d| if d.starts_with('/') { d.to_string() } else { format!("/{d}") }).collect_vec(), 87 | } 88 | } 89 | } 90 | 91 | #[derive(Debug, Args, Bake, Clone)] 92 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 93 | #[baked(name = "Link", derive(Debug))] 94 | pub struct LinkRaw { 95 | #[clap(flatten)] 96 | #[baked(type = "Vec", map_fn(bake = "|l| l.dots.add_root().dots"))] 97 | pub(crate) dots: Dots, 98 | 99 | #[clap(long, short)] 100 | /// Force link creation if file already exists and was not created by rotz 101 | pub(crate) force: bool, 102 | 103 | #[clap(long, short)] 104 | #[baked(ignore)] 105 | /// Which link type to use for linking dotfiles 106 | link_type: Option, 107 | } 108 | 109 | #[derive(Debug, Args, Bake, Clone)] 110 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 111 | #[baked(name = "Install", derive(Debug))] 112 | #[allow(clippy::struct_excessive_bools)] 113 | pub struct InstallRaw { 114 | #[clap(flatten)] 115 | #[baked(type = "Vec", map_fn(bake = "|l| l.dots.add_root().dots"))] 116 | pub(crate) dots: Dots, 117 | 118 | /// Continues installation when an error occurs during installation 119 | #[clap(long, short)] 120 | pub(crate) continue_on_error: bool, 121 | 122 | /// Do not install dependencies 123 | #[clap(long, short = 'd')] 124 | pub(crate) skip_dependencies: bool, 125 | 126 | /// Do not install installation dependencies 127 | #[clap(long, short = 'i')] 128 | pub(crate) skip_installation_dependencies: bool, 129 | 130 | /// Do not install any dependencies 131 | #[clap(long, short = 'a')] 132 | pub(crate) skip_all_dependencies: bool, 133 | } 134 | 135 | #[derive(Subcommand, Debug, Clone)] 136 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 137 | pub enum Command { 138 | /// Clones a dotfiles git repository 139 | Clone { 140 | /// The url of the repository passed to the git clone command 141 | repo: String, 142 | }, 143 | 144 | /// Creates a dotfiles git repository and config 145 | Init { 146 | /// The url of the repository passed to the git init command 147 | repo: Option, 148 | }, 149 | 150 | /// Links dotfiles to the filesystem 151 | Link { 152 | #[clap(flatten)] 153 | link: LinkRaw, 154 | }, 155 | 156 | /// Installs applications using the provided commands 157 | Install { 158 | #[clap(flatten)] 159 | install: InstallRaw, 160 | }, 161 | 162 | #[clap(verbatim_doc_comment)] 163 | /// Adds completions to shell 164 | /// 165 | /// Depending on your shell, you'll have to look up where to place the completion file. 166 | /// The recommended filename is _rotz 167 | /// 168 | /// - In bash, if you have `bash-completion` installed, you can place the completion file in `~/.local/share/bash-completion` 169 | /// - In zsh, place it anywhere inside a directory in your `$fpath` variable 170 | /// - In fish, place it in `~/.config/fish/completions` 171 | /// - In powershell, source the file in your `$PROFILE` 172 | Completions { 173 | #[cfg_attr(test, dummy(default))] 174 | shell: Option, 175 | }, 176 | } 177 | 178 | impl Provider for Cli { 179 | fn metadata(&self) -> Metadata { 180 | Metadata::named("Cli") 181 | } 182 | 183 | #[cfg_attr(feature = "profiling", instrument)] 184 | fn data(&self) -> Result, Error> { 185 | let mut dict = Dict::new(); 186 | 187 | if let Some(dotfiles) = &self.dotfiles { 188 | dict.insert("dotfiles".to_owned(), Value::serialize(dotfiles.to_string())?); 189 | } 190 | 191 | if let Command::Link { 192 | link: LinkRaw { link_type: Some(link_type), .. }, 193 | } = &self.command 194 | { 195 | dict.insert("link_type".to_owned(), Value::serialize(link_type)?); 196 | } 197 | 198 | map! { 199 | Profile::Global => dict 200 | } 201 | .pipe(Ok) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/commands/clone.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | 3 | use crossterm::style::{Attribute, Stylize}; 4 | use miette::{Diagnostic, Result}; 5 | use tap::Pipe; 6 | #[cfg(feature = "profiling")] 7 | use tracing::instrument; 8 | 9 | use super::Command; 10 | use crate::{ 11 | config::{self, Config}, 12 | helpers, 13 | }; 14 | 15 | #[derive(thiserror::Error, Diagnostic, Debug)] 16 | enum Error { 17 | #[error("Clone command did not run successfully")] 18 | #[diagnostic(transparent, code(clone::command::run))] 19 | CloneExecute( 20 | #[from] 21 | #[diagnostic_source] 22 | helpers::RunError, 23 | ), 24 | } 25 | 26 | #[derive(Debug)] 27 | pub struct Clone { 28 | config: Config, 29 | } 30 | 31 | impl Clone { 32 | pub const fn new(config: Config) -> Self { 33 | Self { config } 34 | } 35 | } 36 | 37 | impl Command for Clone { 38 | type Args = (crate::cli::Cli, String); 39 | 40 | type Result = Result<()>; 41 | 42 | #[cfg_attr(feature = "profiling", instrument)] 43 | fn execute(&self, (cli, repo): Self::Args) -> Self::Result { 44 | if !cli.dry_run { 45 | config::create_config_file(cli.dotfiles.as_ref().map(|d| d.0.as_path()), &cli.config.0)?; 46 | } 47 | 48 | println!( 49 | "{}Cloning \"{}\" to \"{}\"{}\n", 50 | Attribute::Bold, 51 | repo.as_str().blue(), 52 | self.config.dotfiles.to_string_lossy().green(), 53 | Attribute::Reset 54 | ); 55 | 56 | helpers::run_command("git", &[OsStr::new("clone"), OsStr::new(&repo), self.config.dotfiles.as_os_str()], false, cli.dry_run)?; 57 | 58 | println!("\n{}Cloned repo{}", Attribute::Bold, Attribute::Reset); 59 | 60 | ().pipe(Ok) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/commands/completions.rs: -------------------------------------------------------------------------------- 1 | use clap::CommandFactory; 2 | use clap_complete::{Shell, generate}; 3 | 4 | use super::Command; 5 | use miette::{Diagnostic, Result}; 6 | 7 | #[derive(thiserror::Error, Diagnostic, Debug)] 8 | enum Error { 9 | #[error("Could not determine shell. Please add the shell option")] 10 | NoShell, 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct Completions {} 15 | 16 | impl Completions { 17 | pub const fn new() -> Self { 18 | Self {} 19 | } 20 | } 21 | 22 | impl Command for Completions { 23 | type Args = Option; 24 | type Result = Result<()>; 25 | 26 | #[cfg_attr(feature = "profiling", tracing::instrument)] 27 | fn execute(&self, shell: Self::Args) -> Self::Result { 28 | let mut command = crate::cli::Cli::command(); 29 | 30 | let shell = shell.or_else(Shell::from_env); 31 | 32 | let cmd_name = command.get_name().to_owned(); 33 | shell.map_or_else( 34 | || Err(Error::NoShell.into()), 35 | |shell| { 36 | generate(shell, &mut command, cmd_name, &mut std::io::stdout()); 37 | Ok(()) 38 | }, 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/init.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsStr, fmt::Debug, path::PathBuf}; 2 | 3 | use crossterm::style::{Attribute, Stylize}; 4 | use miette::{Diagnostic, Result}; 5 | use tap::Pipe; 6 | #[cfg(feature = "profiling")] 7 | use tracing::instrument; 8 | 9 | use super::Command; 10 | use crate::{ 11 | config::{self, Config}, 12 | helpers, 13 | }; 14 | 15 | #[derive(thiserror::Error, Diagnostic, Debug)] 16 | enum Error { 17 | #[error("Could not create dotfiles directory \"{0}\"")] 18 | #[diagnostic(code(init::dotfiles::create))] 19 | CreatingDir(PathBuf, #[source] std::io::Error), 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct Init { 24 | config: Config, 25 | } 26 | 27 | impl Init { 28 | pub const fn new(config: Config) -> Self { 29 | Self { config } 30 | } 31 | } 32 | 33 | impl Command for Init { 34 | type Args = (crate::cli::Cli, Option); 35 | 36 | type Result = Result<()>; 37 | 38 | #[cfg_attr(feature = "profiling", instrument)] 39 | fn execute(&self, (cli, repo): Self::Args) -> Self::Result { 40 | if !cli.dry_run { 41 | config::create_config_file(cli.dotfiles.as_ref().map(|d| d.0.as_path()), &cli.config.0)?; 42 | } 43 | 44 | std::fs::create_dir_all(&self.config.dotfiles).map_err(|err| Error::CreatingDir(self.config.dotfiles.clone(), err))?; 45 | 46 | println!("\n{}Initializing repo in \"{}\"{}\n", Attribute::Bold, self.config.dotfiles.to_string_lossy().green(), Attribute::Reset); 47 | 48 | helpers::run_command( 49 | "git", 50 | &[OsStr::new("-C"), self.config.dotfiles.as_os_str(), OsStr::new("init"), OsStr::new("-b"), OsStr::new("main")], 51 | false, 52 | cli.dry_run, 53 | )?; 54 | 55 | if let Some(repo) = repo.as_ref() { 56 | println!("\n{}Adding remote \"{}\"{}\n", Attribute::Bold, repo.as_str().blue(), Attribute::Reset); 57 | 58 | helpers::run_command( 59 | "git", 60 | &[ 61 | OsStr::new("-C"), 62 | self.config.dotfiles.as_os_str(), 63 | OsStr::new("remote"), 64 | OsStr::new("add"), 65 | OsStr::new("origin"), 66 | OsStr::new(repo), 67 | ], 68 | false, 69 | cli.dry_run, 70 | )?; 71 | 72 | helpers::run_command( 73 | "git", 74 | &[ 75 | OsStr::new("-C"), 76 | self.config.dotfiles.as_os_str(), 77 | OsStr::new("push"), 78 | OsStr::new("--set-upstream"), 79 | OsStr::new("origin"), 80 | OsStr::new("main"), 81 | ], 82 | false, 83 | cli.dry_run, 84 | )?; 85 | 86 | helpers::run_command( 87 | "git", 88 | &[OsStr::new("-C"), self.config.dotfiles.as_os_str(), OsStr::new("push"), OsStr::new("-u"), OsStr::new("origin")], 89 | false, 90 | cli.dry_run, 91 | )?; 92 | 93 | println!("\n{}Initialized repo{}", Attribute::Bold, Attribute::Reset); 94 | } 95 | ().pipe(Ok) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/commands/install.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | fmt::Debug, 4 | }; 5 | 6 | use crossterm::style::{Attribute, Stylize}; 7 | use indexmap::IndexSet; 8 | use miette::{Diagnostic, Report, Result}; 9 | use tap::Pipe; 10 | #[cfg(feature = "profiling")] 11 | use tracing::instrument; 12 | use velcro::hash_map; 13 | use wax::{Glob, Pattern}; 14 | 15 | use super::Command; 16 | use crate::{config::Config, dot::Installs, helpers, templating}; 17 | 18 | #[derive(thiserror::Error, Diagnostic, Debug)] 19 | enum Error { 20 | #[error("{name} has a cyclic dependency")] 21 | #[diagnostic(code(dependency::cyclic), help("{} depends on itsself through {}", name, through))] 22 | CyclicDependency { name: String, through: String }, 23 | 24 | #[error("{name} has a cyclic installation dependency")] 25 | #[diagnostic(code(dependency::cyclic::install), help("{} depends on itsself through {}", name, through))] 26 | CyclicInstallDependency { name: String, through: String }, 27 | 28 | #[error("Dependency {1} of {0} was not found")] 29 | #[diagnostic(code(dependency::not_found))] 30 | DependencyNotFound(String, String), 31 | 32 | #[error("Install command for {0} did not run successfully")] 33 | #[diagnostic(code(install::command::run))] 34 | InstallExecute( 35 | String, 36 | #[source] 37 | #[diagnostic_source] 38 | helpers::RunError, 39 | ), 40 | 41 | #[error("Could not render command templeate for {0}")] 42 | #[diagnostic(code(install::command::render))] 43 | RenderingTemplate(String, #[source] Box), 44 | 45 | #[error("Could not parse install command for {0}")] 46 | #[diagnostic(code(install::command::parse))] 47 | ParsingInstallCommand(String, #[source] shellwords::MismatchedQuotes), 48 | 49 | #[error("Could not spawl install command")] 50 | #[diagnostic(code(install::command::spawn), help("The shell_command in your config is set to \"{0}\" is that correct?"))] 51 | CouldNotSpawn(String), 52 | 53 | #[error("Could not parse dependency \"{0}\"")] 54 | #[diagnostic(code(glob::parse))] 55 | ParseGlob(String, #[source] Box), 56 | } 57 | 58 | pub(crate) struct Install<'a> { 59 | config: Config, 60 | engine: templating::Engine<'a>, 61 | } 62 | 63 | impl Debug for Install<'_> { 64 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 65 | f.debug_struct("Link").field("config", &self.config).finish() 66 | } 67 | } 68 | 69 | impl<'b> Install<'b> { 70 | pub const fn new(config: crate::config::Config, engine: templating::Engine<'b>) -> Self { 71 | Self { config, engine } 72 | } 73 | 74 | #[cfg_attr(feature = "profiling", instrument)] 75 | fn install<'a>( 76 | &self, 77 | dots: &'a HashMap, 78 | entry: (&'a String, &'a InstallsDots), 79 | installed: &mut HashSet<&'a str>, 80 | mut stack: IndexSet, 81 | (globals, install_command): (&crate::cli::Globals, &crate::cli::Install), 82 | ) -> Result<(), Error> { 83 | if installed.contains(entry.0.as_str()) { 84 | return ().pipe(Ok); 85 | } 86 | 87 | stack.insert(entry.0.clone()); 88 | 89 | macro_rules! recurse { 90 | ($depends:expr, $error:ident) => { 91 | for dependency in $depends { 92 | let dependency_glob = Glob::new(dependency).map_err(|e| Error::ParseGlob(dependency.clone(), e.into()))?; 93 | 94 | if stack.iter().any(|d| dependency_glob.is_match(&**d)) { 95 | return Error::$error { 96 | name: dependency.clone(), 97 | through: entry.0.clone(), 98 | } 99 | .pipe(Err); 100 | } 101 | 102 | self.install( 103 | dots, 104 | ( 105 | dependency, 106 | dots 107 | .iter() 108 | .find(|d| dependency_glob.is_match(&**d.0)) 109 | .map(|d| d.1) 110 | .ok_or_else(|| Error::DependencyNotFound(entry.0.clone(), dependency.clone()))?, 111 | ), 112 | installed, 113 | stack.clone(), 114 | (globals, install_command), 115 | )?; 116 | } 117 | }; 118 | } 119 | 120 | if let Some(installs) = &entry.1.0 { 121 | if !(install_command.skip_all_dependencies || install_command.skip_installation_dependencies) { 122 | recurse!(&installs.depends, CyclicInstallDependency); 123 | } 124 | 125 | println!("{}Installing {}{}\n", Attribute::Bold, entry.0.as_str().blue(), Attribute::Reset); 126 | 127 | let inner_cmd = installs.cmd.clone(); 128 | 129 | let cmd = if let Some(shell_command) = self.config.shell_command.as_ref() { 130 | self 131 | .engine 132 | .render_template(shell_command, &hash_map! { "cmd": &inner_cmd }) 133 | .map_err(|err| Error::RenderingTemplate(entry.0.clone(), err.pipe(Box::new)))? 134 | } else { 135 | #[allow(clippy::redundant_clone)] 136 | inner_cmd.clone() 137 | }; 138 | 139 | let cmd = shellwords::split(&cmd).map_err(|err| Error::ParsingInstallCommand(entry.0.clone(), err))?; 140 | 141 | println!("{}{inner_cmd}{}\n", Attribute::Italic, Attribute::Reset); 142 | 143 | if let Err(err) = helpers::run_command(&cmd[0], &cmd[1..], false, globals.dry_run) { 144 | if let helpers::RunError::Spawn(err) = &err { 145 | if err.kind() == std::io::ErrorKind::NotFound { 146 | eprintln!("\n Error: {:?}", Report::new(Error::CouldNotSpawn(format!("{:?}", self.config.shell_command)))); 147 | } 148 | } 149 | 150 | let error = Error::InstallExecute(entry.0.clone(), err); 151 | 152 | if install_command.continue_on_error { 153 | eprintln!("\n Error: {:?}", Report::new(error)); 154 | } else { 155 | return error.pipe(Err); 156 | } 157 | } 158 | 159 | installed.insert(entry.0.as_str()); 160 | } 161 | 162 | if !(install_command.skip_all_dependencies || install_command.skip_dependencies) { 163 | if let Some(depends) = &entry.1.1 { 164 | recurse!(depends, CyclicDependency); 165 | } 166 | } 167 | 168 | ().pipe(Ok) 169 | } 170 | } 171 | 172 | type InstallsDots = (Option, Option>); 173 | 174 | impl Command for Install<'_> { 175 | type Args = (crate::cli::Globals, crate::cli::Install); 176 | type Result = Result<()>; 177 | 178 | #[cfg_attr(feature = "profiling", instrument)] 179 | fn execute(&self, (globals, install_command): Self::Args) -> Self::Result { 180 | let dots = crate::dot::read_dots(&self.config.dotfiles, &["/**".to_owned()], &self.config, &self.engine)? 181 | .into_iter() 182 | .filter(|d| d.1.installs.is_some() || d.1.depends.is_some()) 183 | .map(|d| (d.0, (d.1.installs, d.1.depends))) 184 | .collect::>(); 185 | 186 | let mut installed: HashSet<&str> = HashSet::new(); 187 | let globs = helpers::glob_from_vec(&install_command.dots, None)?; 188 | for dot in &dots { 189 | if globs.is_match(dot.0.as_str()) { 190 | self.install(&dots, dot, &mut installed, IndexSet::new(), (&globals, &install_command))?; 191 | } 192 | } 193 | 194 | ().pipe(Ok) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/commands/link.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | fmt::Debug, 4 | fs, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use crossterm::style::{Attribute, Stylize}; 9 | use itertools::Itertools; 10 | use miette::{Diagnostic, Report, Result}; 11 | use tap::Pipe; 12 | #[cfg(feature = "profiling")] 13 | use tracing::instrument; 14 | use velcro::hash_map; 15 | use wax::Pattern; 16 | 17 | use super::Command; 18 | use crate::{ 19 | config::{Config, LinkType}, 20 | helpers, 21 | state::{self}, 22 | templating, 23 | }; 24 | 25 | #[derive(thiserror::Error, Diagnostic, Debug)] 26 | enum Error { 27 | #[error("Could not create link from \"{0}\" to \"{1}\"")] 28 | #[cfg_attr(windows, diagnostic(code(link::linking), help("You may need to run Rotz from an admin shell to create file links")))] 29 | #[cfg_attr(not(windows), diagnostic(code(link::linking),))] 30 | Symlink(PathBuf, PathBuf, #[source] std::io::Error), 31 | 32 | #[error("Could not remove orphaned link from \"{0}\" to \"{1}\"")] 33 | #[diagnostic(code(link::orphan::remove))] 34 | RemovingOrphan(PathBuf, PathBuf, #[source] std::io::Error), 35 | 36 | #[error("The file \"{0}\" already exists")] 37 | #[diagnostic(code(link::already_exists), help("Try using the --force flag"))] 38 | AlreadyExists(PathBuf), 39 | 40 | #[error("The link source file \"{0}\" does not exist exists")] 41 | #[diagnostic(code(link::does_not_exist), help("Maybe you have a typo in the filename?"))] 42 | LinkSourceDoesNotExist(PathBuf), 43 | } 44 | 45 | pub(crate) struct Link<'a> { 46 | config: Config, 47 | engine: templating::Engine<'a>, 48 | } 49 | 50 | impl Debug for Link<'_> { 51 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 52 | f.debug_struct("Link").field("config", &self.config).finish() 53 | } 54 | } 55 | 56 | impl<'a> Link<'a> { 57 | pub const fn new(config: crate::config::Config, engine: templating::Engine<'a>) -> Self { 58 | Self { config, engine } 59 | } 60 | } 61 | 62 | impl<'a> Command for Link<'a> { 63 | type Args = (crate::cli::Globals, crate::cli::Link, &'a state::Linked); 64 | type Result = Result; 65 | 66 | #[cfg_attr(feature = "profiling", instrument)] 67 | fn execute(&self, (globals, link_command, linked): Self::Args) -> Self::Result { 68 | let links = crate::dot::read_dots(&self.config.dotfiles, &link_command.dots, &self.config, &self.engine)? 69 | .into_iter() 70 | .filter_map(|d| d.1.links.map(|l| (d.0, l))) 71 | .collect_vec(); 72 | 73 | { 74 | let current_links = links.iter().flat_map(|l| l.1.iter().map(|h| h.1.iter())).flatten().map(helpers::resolve_home).collect::>(); 75 | 76 | let mut errors = Vec::new(); 77 | 78 | let dots = helpers::glob_from_vec(&link_command.dots, None)?; 79 | let linked = linked.0.iter().filter(|l| dots.is_match(l.0.as_str())); 80 | 81 | for (name, links) in linked { 82 | let mut printed = false; 83 | for (to, from) in links { 84 | if !current_links.contains(to) { 85 | let mut removed = true; 86 | if !globals.dry_run { 87 | if let Err(err) = fs::remove_file(to) { 88 | removed = false; 89 | 90 | if err.kind() != std::io::ErrorKind::NotFound { 91 | errors.push(Error::RemovingOrphan(from.clone(), to.clone(), err)); 92 | } 93 | } 94 | } 95 | 96 | if removed { 97 | if !printed { 98 | println!("{}Removing orphans for {}{}\n", Attribute::Bold, name.as_str().dark_blue(), Attribute::Reset); 99 | printed = true; 100 | } 101 | println!(" x {}", to.to_string_lossy().dark_green()); 102 | } 103 | } 104 | } 105 | 106 | if printed { 107 | println!(); 108 | } 109 | } 110 | 111 | helpers::join_err(errors)?; 112 | } 113 | 114 | let mut new_linked = hash_map!(); 115 | 116 | for (name, link) in links { 117 | println!("{}Linking {}{}\n", Attribute::Bold, name.as_str().dark_blue(), Attribute::Reset); 118 | 119 | let mut new_linked_inner = hash_map!(); 120 | 121 | let base_path = self.config.dotfiles.join(&name[1..]); 122 | for (from, tos) in link { 123 | for mut to in tos { 124 | println!(" {} -> {}", from.to_string_lossy().dark_green(), to.to_string_lossy().dark_green()); 125 | let from = base_path.join(&from); 126 | to = helpers::resolve_home(&to); 127 | 128 | if !globals.dry_run { 129 | if let Err(err) = create_link(&from, &to, &self.config.link_type, link_command.force, linked.0.get(&name)) { 130 | eprintln!("\n Error: {:?}", Report::new(err)); 131 | } else { 132 | new_linked_inner.insert(to.clone(), from.clone()); 133 | } 134 | } 135 | } 136 | } 137 | 138 | if !new_linked_inner.is_empty() { 139 | new_linked.insert(name, new_linked_inner); 140 | } 141 | 142 | println!(); 143 | } 144 | 145 | state::Linked(new_linked).pipe(Ok) 146 | } 147 | } 148 | 149 | #[cfg_attr(feature = "profiling", instrument)] 150 | fn create_link(from: &Path, to: &Path, link_type: &LinkType, force: bool, linked: Option<&HashMap>) -> std::result::Result<(), Error> { 151 | if !from.exists() { 152 | return Error::LinkSourceDoesNotExist(from.to_path_buf()).pipe(Err); 153 | } 154 | 155 | let create: fn(&Path, &Path) -> std::result::Result<(), std::io::Error> = if link_type.is_symbolic() { symlink } else { hardlink }; 156 | 157 | match create(from, to) { 158 | Ok(ok) => ok.pipe(Ok), 159 | Err(err) => match err.kind() { 160 | std::io::ErrorKind::AlreadyExists => { 161 | if force || linked.is_some_and(|l| l.contains_key(to)) { 162 | if to.is_dir() { fs::remove_dir_all(to) } else { fs::remove_file(to) }.map_err(|e| Error::Symlink(from.to_path_buf(), to.to_path_buf(), e))?; 163 | create(from, to) 164 | } else { 165 | return Error::AlreadyExists(to.to_path_buf()).pipe(Err); 166 | } 167 | } 168 | _ => err.pipe(Err), 169 | }, 170 | } 171 | .map_err(|e| Error::Symlink(from.to_path_buf(), to.to_path_buf(), e)) 172 | } 173 | 174 | #[cfg(windows)] 175 | #[cfg_attr(feature = "profiling", instrument)] 176 | fn symlink(from: &Path, to: &Path) -> std::io::Result<()> { 177 | use std::os::windows::fs; 178 | 179 | if let Some(parent) = to.parent() { 180 | std::fs::create_dir_all(parent)?; 181 | } 182 | 183 | if from.is_dir() { 184 | fs::symlink_dir(from, to)?; 185 | } else { 186 | fs::symlink_file(from, to)?; 187 | }; 188 | ().pipe(Ok) 189 | } 190 | 191 | #[cfg(unix)] 192 | #[cfg_attr(feature = "profiling", instrument)] 193 | fn symlink(from: &Path, to: &Path) -> std::io::Result<()> { 194 | use std::os::unix::fs; 195 | if let Some(parent) = to.parent() { 196 | std::fs::create_dir_all(parent)?; 197 | } 198 | fs::symlink(from, to)?; 199 | ().pipe(Ok) 200 | } 201 | 202 | #[cfg(windows)] 203 | #[cfg_attr(feature = "profiling", instrument)] 204 | fn hardlink(from: &Path, to: &Path) -> std::io::Result<()> { 205 | if let Some(parent) = to.parent() { 206 | std::fs::create_dir_all(parent)?; 207 | } 208 | 209 | if from.is_dir() { 210 | junction::create(from, to)?; 211 | } else { 212 | fs::hard_link(from, to)?; 213 | } 214 | ().pipe(Ok) 215 | } 216 | 217 | #[cfg(unix)] 218 | #[cfg_attr(feature = "profiling", instrument)] 219 | fn hardlink(from: &Path, to: &Path) -> std::io::Result<()> { 220 | if let Some(parent) = to.parent() { 221 | std::fs::create_dir_all(parent)?; 222 | } 223 | fs::hard_link(from, to)?; 224 | ().pipe(Ok) 225 | } 226 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clone; 2 | pub use clone::Clone; 3 | 4 | pub mod install; 5 | pub(crate) use install::Install; 6 | 7 | pub mod link; 8 | pub(crate) use link::Link; 9 | 10 | pub mod init; 11 | pub use init::Init; 12 | 13 | pub mod completions; 14 | pub use completions::Completions; 15 | 16 | pub trait Command { 17 | type Args; 18 | type Result; 19 | 20 | fn execute(&self, args: Self::Args) -> Self::Result; 21 | } 22 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fmt::Debug, 4 | fs, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use clap::ValueEnum; 9 | use crossterm::style::Stylize; 10 | #[cfg(test)] 11 | use fake::{Dummy, Fake}; 12 | use figment::{Metadata, Profile, Provider, providers::Serialized, value}; 13 | use miette::{Diagnostic, NamedSource, Result, SourceSpan}; 14 | use path_absolutize::Absolutize; 15 | use serde::{Deserialize, Serialize}; 16 | use strum::{Display, EnumIs}; 17 | use tap::{Pipe, TryConv}; 18 | #[cfg(feature = "profiling")] 19 | use tracing::instrument; 20 | 21 | use crate::{FileFormat, USER_DIRS, helpers}; 22 | 23 | #[derive(Debug, ValueEnum, Clone, Display, Deserialize, Serialize, EnumIs)] 24 | #[cfg_attr(test, derive(Dummy, PartialEq, Eq))] 25 | pub enum LinkType { 26 | /// Uses symbolic links for linking 27 | Symbolic, 28 | /// Uses hard links for linking 29 | Hard, 30 | } 31 | 32 | #[cfg(test)] 33 | struct ValueFaker; 34 | 35 | #[cfg(test)] 36 | #[allow(clippy::implicit_hasher)] 37 | impl Dummy for figment::value::Dict { 38 | fn dummy_with_rng(_: &ValueFaker, rng: &mut R) -> Self { 39 | let mut map = Self::new(); 40 | 41 | for _ in 0..((0..10).fake_with_rng(rng)) { 42 | map.insert((0..10).fake_with_rng(rng), (0..10).fake_with_rng::(rng).into()); 43 | } 44 | 45 | map 46 | } 47 | } 48 | 49 | #[derive(Deserialize, Serialize, Debug)] 50 | #[cfg_attr(test, derive(Dummy, PartialEq))] 51 | pub struct Config { 52 | /// Path to the local dotfiles 53 | pub(crate) dotfiles: PathBuf, 54 | 55 | /// Which link type to use for linking dotfiles 56 | pub(crate) link_type: LinkType, 57 | 58 | /// The command used to spawn processess. 59 | /// Use handlebars templates `{{ cmd }}` as placeholder for the cmd set in the dot. 60 | /// E.g. `"bash -c {{ quote "" cmd }}"`. 61 | pub(crate) shell_command: Option, 62 | 63 | /// Variables can be used for templating in dot.(yaml|toml|json) files. 64 | #[cfg_attr(test, dummy(faker = "ValueFaker"))] 65 | pub(crate) variables: figment::value::Dict, 66 | } 67 | 68 | impl Default for Config { 69 | fn default() -> Self { 70 | Self { 71 | dotfiles: USER_DIRS.home_dir().join(".dotfiles"), 72 | link_type: LinkType::Symbolic, 73 | #[cfg(windows)] 74 | shell_command: Some("powershell -NoProfile -C {{ quote \"\" cmd }}".to_owned()), 75 | #[cfg(all(not(target_os = "macos"), unix))] 76 | shell_command: Some("bash -c {{ quote \"\" cmd }}".to_owned()), 77 | #[cfg(target_os = "macos")] 78 | shell_command: Some("zsh -c {{ quote \"\" cmd }}".to_owned()), 79 | variables: figment::value::Dict::new(), 80 | } 81 | } 82 | } 83 | 84 | impl Provider for Config { 85 | fn metadata(&self) -> Metadata { 86 | Metadata::named("Library Config") 87 | } 88 | 89 | fn data(&self) -> Result, figment::Error> { 90 | Serialized::defaults(Config::default()).data() 91 | } 92 | 93 | fn profile(&self) -> Option { 94 | None 95 | } 96 | } 97 | 98 | #[cfg_attr(feature = "profiling", instrument)] 99 | fn deserialize_config(config: &str, format: FileFormat) -> Result { 100 | Ok(match format { 101 | #[cfg(feature = "yaml")] 102 | FileFormat::Yaml => serde_yaml::from_str(config)?, 103 | #[cfg(feature = "toml")] 104 | FileFormat::Toml => serde_toml::from_str(config)?, 105 | #[cfg(feature = "json")] 106 | FileFormat::Json => serde_json::from_str(config)?, 107 | }) 108 | } 109 | 110 | #[cfg_attr(feature = "profiling", instrument)] 111 | fn serialize_config(config: &(impl Serialize + Debug), format: FileFormat) -> Result { 112 | Ok(match format { 113 | #[cfg(feature = "yaml")] 114 | FileFormat::Yaml => serde_yaml::to_string(config)?, 115 | #[cfg(feature = "toml")] 116 | FileFormat::Toml => serde_toml::to_string(config)?, 117 | #[cfg(feature = "json")] 118 | FileFormat::Json => serde_json::to_string(config)?, 119 | }) 120 | } 121 | 122 | #[derive(thiserror::Error, Diagnostic, Debug)] 123 | #[error("{name} is already set")] 124 | #[diagnostic(code(config::exists::value))] 125 | pub struct AlreadyExistsError { 126 | name: String, 127 | #[label("{name} is set here")] 128 | span: SourceSpan, 129 | } 130 | 131 | impl AlreadyExistsError { 132 | #[cfg_attr(feature = "profiling", instrument)] 133 | pub fn new(name: &str, content: &str) -> Self { 134 | let pat = format!("{name}: "); 135 | let span: SourceSpan = if content.starts_with(&pat) { 136 | (0, pat.len()).into() 137 | } else { 138 | let starts = content.match_indices(&format!("\n{pat}")).collect::>(); 139 | if starts.len() == 1 { (starts[0].0 + 1, pat.len()).into() } else { (0, content.len()).into() } 140 | }; 141 | 142 | Self { name: name.to_owned(), span } 143 | } 144 | } 145 | 146 | #[derive(thiserror::Error, Diagnostic, Debug)] 147 | pub enum Error { 148 | #[error("Could not serialize config")] 149 | #[diagnostic(code(config::serialize))] 150 | SerializingConfig( 151 | #[source] 152 | #[diagnostic_source] 153 | helpers::ParseError, 154 | ), 155 | 156 | #[error("Could not write config")] 157 | #[diagnostic(code(config::write))] 158 | WritingConfig(PathBuf, #[source] std::io::Error), 159 | 160 | #[error("Could not get absolute path")] 161 | #[diagnostic(code(config::canonicalize))] 162 | Canonicalize(#[source] std::io::Error), 163 | 164 | #[error("Config file already exists")] 165 | #[diagnostic(code(config::exists))] 166 | AlreadyExists(#[label] Option, #[source_code] NamedSource, #[related] Vec), 167 | 168 | #[error("Could not parse dotfiles directory \"{0}\"")] 169 | #[diagnostic(code(config::filename::parse), help("Did you enter a valid file?"))] 170 | PathParse(PathBuf), 171 | 172 | #[error(transparent)] 173 | #[diagnostic(transparent)] 174 | InvalidFileFormat( 175 | #[from] 176 | #[diagnostic_source] 177 | crate::Error, 178 | ), 179 | } 180 | 181 | #[cfg_attr(feature = "profiling", instrument)] 182 | pub fn create_config_file(dotfiles: Option<&Path>, config_file: &Path) -> Result<(), Error> { 183 | let format = config_file.try_conv::()?; 184 | 185 | if let Ok(existing_config_str) = fs::read_to_string(config_file) { 186 | if let Ok(existing_config) = deserialize_config(&existing_config_str, format) { 187 | let mut errors: Vec = vec![]; 188 | 189 | if let Some(dotfiles) = dotfiles { 190 | if existing_config.dotfiles != dotfiles { 191 | errors.push(AlreadyExistsError::new("dotfiles", &existing_config_str)); 192 | } 193 | } 194 | 195 | return Error::AlreadyExists( 196 | errors.is_empty().then(|| (0, existing_config_str.len()).into()), 197 | NamedSource::new(config_file.to_string_lossy(), existing_config_str), 198 | errors, 199 | ) 200 | .pipe(Err); 201 | } 202 | } 203 | 204 | let mut map = HashMap::new(); 205 | 206 | if let Some(dotfiles) = dotfiles { 207 | map.insert( 208 | "dotfiles", 209 | dotfiles 210 | .absolutize() 211 | .map_err(Error::Canonicalize)? 212 | .to_str() 213 | .ok_or_else(|| Error::PathParse(dotfiles.to_path_buf()))? 214 | .to_owned(), 215 | ); 216 | } 217 | 218 | fs::write(config_file, serialize_config(&map, format).map_err(Error::SerializingConfig)?).map_err(|e| Error::WritingConfig(config_file.to_path_buf(), e))?; 219 | 220 | println!("Created config file at {}", config_file.to_string_lossy().green()); 221 | 222 | ().pipe(Ok) 223 | } 224 | 225 | pub struct MappedProfileProvider { 226 | pub mapping: HashMap, 227 | pub provider: P, 228 | } 229 | 230 | impl Provider for MappedProfileProvider

{ 231 | fn metadata(&self) -> Metadata { 232 | self.provider.metadata() 233 | } 234 | 235 | fn data(&self) -> Result, figment::Error> { 236 | let data = self.provider.data()?; 237 | let mut mapped = value::Map::::new(); 238 | 239 | for (profile, data) in data { 240 | mapped.insert(self.mapping.get(&profile).map_or(profile, Clone::clone), data); 241 | } 242 | 243 | mapped.pipe(Ok) 244 | } 245 | } 246 | 247 | #[cfg(test)] 248 | mod tests { 249 | use fake::{Fake, Faker}; 250 | use rstest::rstest; 251 | use speculoos::prelude::*; 252 | 253 | use super::Config; 254 | use crate::FileFormat; 255 | 256 | #[rstest] 257 | fn ser_de(#[values(Faker.fake::(), Config::default())] config: Config, #[values(FileFormat::Yaml, FileFormat::Toml, FileFormat::Json)] format: FileFormat) { 258 | let serialized = super::serialize_config(&config, format); 259 | let serialized = assert_that!(&serialized).is_ok().subject; 260 | 261 | let deserialized = super::deserialize_config(serialized, format); 262 | assert_that!(&deserialized).is_ok().is_equal_to(config); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/dot/defaults.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fs, path::Path}; 2 | 3 | use tap::Pipe; 4 | #[cfg(feature = "profiling")] 5 | use tracing::instrument; 6 | use walkdir::WalkDir; 7 | use wax::Pattern; 8 | 9 | use super::Error; 10 | use crate::{FILE_EXTENSIONS_GLOB, FileFormat, helpers}; 11 | 12 | #[derive(Debug)] 13 | pub struct Defaults(HashMap); 14 | 15 | impl Defaults { 16 | pub fn for_path(&self, path: impl AsRef) -> Option<&(String, FileFormat)> { 17 | for path in Path::new(path.as_ref()).ancestors() { 18 | if let Some(defaults) = self.0.get(helpers::absolutize_virtually(path).unwrap().as_str()) { 19 | return Some(defaults); 20 | } 21 | } 22 | 23 | None 24 | } 25 | 26 | #[cfg_attr(feature = "profiling", instrument)] 27 | pub fn from_path(dotfiles_path: &Path) -> Result> { 28 | let defaults = helpers::glob_from_vec(&["**".to_owned()], format!("/defaults.{FILE_EXTENSIONS_GLOB}").as_str().pipe(Some)).unwrap(); 29 | 30 | let paths = WalkDir::new(dotfiles_path) 31 | .into_iter() 32 | .collect::, _>>() 33 | .map_err(Error::WalkingDotfiles) 34 | .map_err(Box::new)?; 35 | 36 | let absolutized = paths 37 | .into_iter() 38 | .filter(|e| !e.file_type().is_dir()) 39 | .map(|d| { 40 | let path = d.path().strip_prefix(dotfiles_path).map(Path::to_path_buf).map_err(Error::PathStrip)?; 41 | let absolutized = helpers::absolutize_virtually(&path).map_err(|e| Error::ParseName(path.to_string_lossy().to_string(), e))?; 42 | let absolutized_dir = helpers::absolutize_virtually(path.parent().unwrap()).map_err(|e| Error::ParseName(path.to_string_lossy().to_string(), e))?; 43 | Ok::<_, Error>((absolutized, absolutized_dir, path)) 44 | }) 45 | .collect::, _>>() 46 | .map_err(Box::new)?; 47 | 48 | absolutized 49 | .into_iter() 50 | .filter(|e| defaults.is_match(e.0.as_str())) 51 | .map(|e| (e.1, e.2)) 52 | .map(|e| { 53 | ( 54 | e.0, 55 | ( 56 | fs::read_to_string(dotfiles_path.join(&e.1)).map_err(|err| Error::ReadingDot(e.1.clone(), err))?, 57 | FileFormat::try_from(e.1.as_path()).unwrap(), 58 | ), 59 | ) 60 | .pipe(Ok) 61 | }) 62 | .collect::, _>>() 63 | .map(Defaults) 64 | .map_err(Box::new) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/dot/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use miette::{Diagnostic, NamedSource, SourceSpan}; 4 | 5 | use crate::{helpers, templating}; 6 | 7 | #[derive(thiserror::Error, Diagnostic, Debug)] 8 | pub enum Error { 9 | #[error("Could not get relative dot directory")] 10 | #[diagnostic(code(dotfiles::filename::strip))] 11 | PathStrip(#[source] std::path::StripPrefixError), 12 | 13 | #[error("Could not read file {0}")] 14 | #[diagnostic(code(dot::read))] 15 | ReadingDot(PathBuf, #[source] std::io::Error), 16 | 17 | #[error("Error walking dotfiles")] 18 | #[diagnostic(code(dotfiles::walk))] 19 | WalkingDotfiles(#[source] walkdir::Error), 20 | 21 | #[error("Could not parse dot")] 22 | #[diagnostic(code(dot::parse))] 23 | ParseDot(#[source_code] NamedSource, #[label] SourceSpan, #[related] Vec), 24 | 25 | #[error("Could not render template for dot")] 26 | #[diagnostic(code(dot::render))] 27 | RenderDot( 28 | #[source_code] NamedSource, 29 | #[label] SourceSpan, 30 | #[source] 31 | #[diagnostic_source] 32 | templating::Error, 33 | ), 34 | 35 | #[error("Io Error on file \"{0}\"")] 36 | #[diagnostic(code(io::generic))] 37 | Io(PathBuf, #[source] std::io::Error), 38 | 39 | #[error("Could not parse dependency path \"{0}\"")] 40 | #[diagnostic(code(glob::parse))] 41 | ParseDependency(PathBuf, #[source] std::io::Error), 42 | 43 | #[error("Could not parse dot name \"{0}\"")] 44 | #[diagnostic(code(glob::parse))] 45 | ParseName(String, #[source] std::io::Error), 46 | 47 | #[error(transparent)] 48 | #[diagnostic(transparent)] 49 | MultipleErrors( 50 | #[from] 51 | #[diagnostic_source] 52 | helpers::MultipleErrors, 53 | ), 54 | } 55 | -------------------------------------------------------------------------------- /src/dot/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | fs, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use crossterm::style::Stylize; 8 | use itertools::Itertools; 9 | use miette::NamedSource; 10 | use path_slash::PathBufExt; 11 | use tap::{Pipe, TryConv}; 12 | #[cfg(feature = "profiling")] 13 | use tracing::instrument; 14 | use walkdir::WalkDir; 15 | use wax::Pattern; 16 | 17 | use self::{ 18 | defaults::Defaults, 19 | repr::{CapabilitiesCanonical, Merge}, 20 | }; 21 | use crate::{ 22 | FILE_EXTENSIONS_GLOB, FileFormat, 23 | config::Config, 24 | helpers, 25 | templating::{self, Engine, Parameters}, 26 | }; 27 | 28 | mod defaults; 29 | mod error; 30 | mod repr; 31 | pub use error::Error; 32 | 33 | #[derive(Clone, Debug)] 34 | pub struct Installs { 35 | pub(crate) cmd: String, 36 | pub(crate) depends: HashSet, 37 | } 38 | 39 | impl From for Option { 40 | fn from(from: repr::InstallsCanonical) -> Self { 41 | match from { 42 | repr::InstallsCanonical::None(_) => None, 43 | repr::InstallsCanonical::Full { cmd, depends } => Installs { cmd, depends }.pipe(Some), 44 | } 45 | } 46 | } 47 | 48 | #[derive(Default, Clone, Debug)] 49 | pub struct Dot { 50 | pub(crate) links: Option>>, 51 | pub(crate) installs: Option, 52 | pub(crate) depends: Option>, 53 | } 54 | 55 | #[cfg_attr(feature = "profiling", instrument(skip(engine)))] 56 | fn from_str_with_defaults(s: &str, format: FileFormat, defaults: Option<&CapabilitiesCanonical>, engine: &Engine<'_>, parameters: &Parameters<'_>) -> Result> { 57 | let capabilities: Option = defaults 58 | .cloned() 59 | .merge(CapabilitiesCanonical::from(repr::DotCanonical::parse(s, format)?, engine, parameters).map_err(|e| vec![e])?.pipe(Some)); 60 | if let Some(capabilities) = capabilities { 61 | Dot { 62 | links: capabilities.links, 63 | installs: capabilities.installs.and_then(Into::into), 64 | depends: capabilities.depends, 65 | } 66 | } else { 67 | Dot::default() 68 | } 69 | .pipe(Ok) 70 | } 71 | 72 | #[cfg_attr(feature = "profiling", instrument(skip(engine)))] 73 | pub(crate) fn read_dots(dotfiles_path: &Path, dots: &[String], config: &Config, engine: &templating::Engine<'_>) -> miette::Result> { 74 | let defaults = Defaults::from_path(dotfiles_path).map_err(|e| *e)?; 75 | 76 | let dots = helpers::glob_from_vec(dots, format!("/dot.{FILE_EXTENSIONS_GLOB}").as_str().pipe(Some))?; 77 | 78 | let paths = WalkDir::new(dotfiles_path) 79 | .into_iter() 80 | .filter(|e| e.as_ref().map_or(true, |e| !e.file_type().is_dir())) 81 | .map(|d| -> Result<(std::string::String, std::path::PathBuf), Error> { 82 | let d = d.map_err(Error::WalkingDotfiles)?; 83 | let path = d.path().strip_prefix(dotfiles_path).map(Path::to_path_buf).map_err(Error::PathStrip)?; 84 | let absolutized = helpers::absolutize_virtually(&path).map_err(|e| Error::ParseName(path.to_string_lossy().to_string(), e))?; 85 | Ok((absolutized, path)) 86 | }) 87 | .filter(|e| e.as_ref().map_or(true, |e| dots.is_match(e.0.as_str()))) 88 | .map(|e| match e { 89 | Ok(e) => { 90 | let format = e.1.as_path().try_conv::().unwrap(); 91 | (e.1, format).pipe(Ok) 92 | } 93 | Err(err) => err.pipe(Err), 94 | }); 95 | 96 | let dotfiles = crate::helpers::join_err_result(paths.collect())?.into_iter().map(|p| { 97 | let name = p.0.parent().unwrap().to_path_buf().to_slash_lossy().to_string(); 98 | (name, fs::read_to_string(dotfiles_path.join(&p.0)).map(|d| (d, p.1)).map_err(|e| Error::Io(dotfiles_path.join(p.0), e))) 99 | }); 100 | 101 | let dots = dotfiles.filter_map(|f| match f { 102 | (name, Ok((text, format))) => { 103 | let parameters = Parameters { config, name: &name }; 104 | let text = match engine.render(&text, ¶meters) { 105 | Ok(text) => text, 106 | Err(err) => { 107 | return Error::RenderDot(NamedSource::new(format!("{name}/dot.{format}"), text.clone()), (0, text.len()).into(), err) 108 | .pipe(Err) 109 | .into(); 110 | } 111 | }; 112 | 113 | let defaults = if let Some((defaults, format)) = defaults.for_path(&name) { 114 | match engine.render(defaults, ¶meters) { 115 | Ok(rendered) => match repr::DotCanonical::parse(&rendered, *format) { 116 | Ok(parsed) => match CapabilitiesCanonical::from(parsed, engine, ¶meters) { 117 | Ok(ok) => ok, 118 | Err(err) => { 119 | return Error::ParseDot(NamedSource::new(defaults, defaults.to_string()), (0, defaults.len()).into(), vec![err]) 120 | .pipe(Err) 121 | .into(); 122 | } 123 | } 124 | .into(), 125 | Err(err) => { 126 | return Error::ParseDot(NamedSource::new(defaults, defaults.to_string()), (0, defaults.len()).into(), err).pipe(Err).into(); 127 | } 128 | }, 129 | Err(err) => { 130 | return Error::RenderDot(NamedSource::new(format!("{name}/dot.{format}"), defaults.to_string()), (0, defaults.len()).into(), err) 131 | .pipe(Err) 132 | .into(); 133 | } 134 | } 135 | } else { 136 | None 137 | }; 138 | 139 | match from_str_with_defaults(&text, format, defaults.as_ref(), engine, ¶meters) { 140 | Ok(dot) => (name.clone(), dot).pipe(Ok).into(), 141 | Err(err) => Error::ParseDot(NamedSource::new(format!("{name}/dot.{format}"), text.clone()), (0, text.len()).into(), err) 142 | .pipe(Err) 143 | .into(), 144 | } 145 | } 146 | (_, Err(Error::Io(file, err))) => match err.kind() { 147 | std::io::ErrorKind::NotFound => None, 148 | _ => Error::Io(file, err).pipe(Err).into(), 149 | }, 150 | (_, Err(err)) => err.pipe(Err).into(), 151 | }); 152 | let dots = canonicalize_dots(crate::helpers::join_err_result(dots.collect())?)?; 153 | 154 | if dots.is_empty() { 155 | println!("Warning: {}", "No dots found".dark_yellow()); 156 | return vec![].pipe(Ok); 157 | } 158 | 159 | dots.pipe(Ok) 160 | } 161 | 162 | #[cfg_attr(feature = "profiling", instrument)] 163 | fn canonicalize_dots(dots: Vec<(String, Dot)>) -> Result, helpers::MultipleErrors> { 164 | let dots = dots.into_iter().map(|mut dot| { 165 | let name = helpers::absolutize_virtually(Path::new(&dot.0)).map_err(|e| Error::ParseName(dot.0.clone(), e))?; 166 | 167 | if let Some(installs) = &mut dot.1.installs { 168 | let depends = installs.depends.iter().map(|dependency| { 169 | let dependency_base = Path::new(&name).parent().unwrap_or_else(|| Path::new("")).join(dependency); 170 | 171 | let dependency_base = helpers::absolutize_virtually(&dependency_base).map_err(|e| Error::ParseDependency(dependency_base, e))?; 172 | dependency_base.pipe(Ok::<_, Error>) 173 | }); 174 | installs.depends = helpers::join_err_result(depends.collect_vec())?.into_iter().collect::>(); 175 | } 176 | 177 | if let Some(depends) = &dot.1.depends { 178 | let depends_mapped = depends.iter().map(|dependency| { 179 | let dependency_base = Path::new(&name).parent().unwrap_or_else(|| Path::new("")).join(dependency); 180 | 181 | let dependency_base = helpers::absolutize_virtually(&dependency_base).map_err(|e| Error::ParseDependency(dependency_base, e))?; 182 | dependency_base.pipe(Ok::<_, Error>) 183 | }); 184 | dot.1.depends = Some(helpers::join_err_result(depends_mapped.collect_vec())?.into_iter().collect::>()); 185 | } 186 | 187 | (name, dot.1).pipe(Ok::<_, Error>) 188 | }); 189 | 190 | helpers::join_err_result(dots.collect_vec()) 191 | } 192 | 193 | #[cfg(test)] 194 | mod test; 195 | -------------------------------------------------------------------------------- /src/dot/repr/capabilities_canonical.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | path::PathBuf, 4 | }; 5 | 6 | #[cfg(test)] 7 | use fake::Dummy; 8 | use itertools::{Either, Itertools}; 9 | use serde::Deserialize; 10 | use tap::Pipe; 11 | #[cfg(feature = "profiling")] 12 | use tracing::instrument; 13 | use velcro::hash_set; 14 | 15 | use crate::{ 16 | helpers::{self, MultipleErrors}, 17 | templating::{Engine, Parameters}, 18 | }; 19 | 20 | use super::{CapabilitiesComplex, DotCanonical, InstallsCanonical, LinksComplex, Merge}; 21 | 22 | #[derive(Deserialize, Clone, Default, Debug)] 23 | #[cfg_attr(test, derive(Dummy))] 24 | #[serde(deny_unknown_fields)] 25 | pub struct CapabilitiesCanonical { 26 | pub links: Option>>, 27 | pub installs: Option, 28 | pub depends: Option>, 29 | } 30 | 31 | impl From for CapabilitiesCanonical { 32 | #[cfg_attr(feature = "profiling", instrument)] 33 | fn from(value: CapabilitiesComplex) -> Self { 34 | Self { 35 | links: value.links.map(|links| { 36 | links 37 | .into_iter() 38 | .map(|l| { 39 | ( 40 | l.0, 41 | match l.1 { 42 | LinksComplex::One(o) => hash_set!(o), 43 | LinksComplex::Many(m) => m, 44 | }, 45 | ) 46 | }) 47 | .collect::>() 48 | }), 49 | installs: value.installs.map(Into::into), 50 | depends: value.depends, 51 | } 52 | } 53 | } 54 | 55 | impl CapabilitiesCanonical { 56 | #[cfg_attr(feature = "profiling", instrument(skip(engine)))] 57 | pub fn from(DotCanonical { selectors }: DotCanonical, engine: &Engine<'_>, parameters: &Parameters<'_>) -> Result { 58 | let selectors = selectors 59 | .into_iter() 60 | .map(|(selector, capabilities)| (selector.applies(engine, parameters), selector, capabilities)) 61 | .collect_vec(); 62 | if selectors.iter().any(|(a, _, _)| a.is_err()) { 63 | return selectors 64 | .into_iter() 65 | .filter_map(|(applies, _, _)| applies.err()) 66 | .flatten() 67 | .collect::>() 68 | .pipe(|e| Err(helpers::ParseError::Selector(MultipleErrors::from(e)))); 69 | } 70 | let selectors = selectors 71 | .into_iter() 72 | .filter_map(|(applies, selector, capabilities)| if applies.unwrap() { Some((selector, capabilities)) } else { None }); 73 | let (globals, selectors): (Vec<_>, Vec<_>) = selectors.partition_map(|(selector, capability)| if selector.is_global() { Either::Left } else { Either::Right }(capability)); 74 | let mut capabilities = None::; 75 | 76 | for capability in globals { 77 | capabilities = capabilities.merge(capability.into()); 78 | } 79 | 80 | for capability in selectors { 81 | capabilities = capabilities.merge(capability.into()); 82 | } 83 | 84 | capabilities.unwrap_or_default().pipe(Ok) 85 | } 86 | } 87 | 88 | impl Merge> for Option { 89 | #[cfg_attr(feature = "profiling", instrument)] 90 | fn merge(self, merge: Option) -> Self { 91 | if let Some(s) = self { 92 | if let Some(merge) = merge { s.merge(merge) } else { s }.into() 93 | } else { 94 | merge 95 | } 96 | } 97 | } 98 | 99 | impl Merge for CapabilitiesCanonical { 100 | #[cfg_attr(feature = "profiling", instrument)] 101 | fn merge(mut self, Self { mut links, installs, depends }: Self) -> Self { 102 | if let Some(self_links) = &mut self.links { 103 | if let Some(merge_links) = &mut links { 104 | for l in &mut *merge_links { 105 | if self_links.contains_key(l.0) { 106 | let self_links_value = self_links.get_mut(l.0).unwrap(); 107 | self_links_value.extend(l.1.clone()); 108 | } else { 109 | self_links.insert(l.0.clone(), l.1.clone()); 110 | } 111 | } 112 | } 113 | } else { 114 | self.links = links; 115 | } 116 | 117 | if let Some(i) = &mut self.installs { 118 | if let Some(installs) = installs { 119 | if installs.is_none() { 120 | self.installs = None; 121 | } else { 122 | let cmd_outer: String; 123 | let mut depends_outer; 124 | 125 | match installs { 126 | InstallsCanonical::Full { cmd, depends } => { 127 | cmd_outer = cmd; 128 | depends_outer = depends; 129 | } 130 | InstallsCanonical::None(_) => unreachable!(), 131 | } 132 | 133 | *i = match i { 134 | InstallsCanonical::None(_) => InstallsCanonical::Full { 135 | cmd: cmd_outer, 136 | depends: depends_outer, 137 | }, 138 | InstallsCanonical::Full { depends, .. } => { 139 | depends_outer.extend(depends.clone()); 140 | InstallsCanonical::Full { 141 | cmd: cmd_outer, 142 | depends: depends_outer, 143 | } 144 | } 145 | }; 146 | } 147 | } 148 | } else { 149 | self.installs = installs; 150 | } 151 | 152 | if let Some(d) = &mut self.depends { 153 | if let Some(depends) = depends { 154 | d.extend(depends); 155 | } 156 | } else { 157 | self.depends = depends; 158 | } 159 | 160 | self 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/dot/repr/capabilities_complex.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | path::PathBuf, 4 | }; 5 | 6 | #[cfg(test)] 7 | use fake::Dummy; 8 | use serde::Deserialize; 9 | 10 | use super::{DotSimplified, InstallsComplex, LinksComplex}; 11 | 12 | #[derive(Deserialize, Clone, Default, Debug)] 13 | #[cfg_attr(test, derive(Dummy))] 14 | #[serde(deny_unknown_fields)] 15 | pub(super) struct CapabilitiesComplex { 16 | pub(super) links: Option>, 17 | pub(super) installs: Option, 18 | pub(super) depends: Option>, 19 | } 20 | 21 | impl From for CapabilitiesComplex { 22 | fn from(from: DotSimplified) -> Self { 23 | Self { 24 | depends: from.depends, 25 | installs: from.installs, 26 | links: from.links, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/dot/repr/dot_canonical.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | #[cfg(test)] 4 | use fake::Dummy; 5 | use indexmap::IndexMap; 6 | use tap::TryConv; 7 | #[cfg(feature = "profiling")] 8 | use tracing::instrument; 9 | 10 | #[cfg(test)] 11 | use super::IndexMapFaker; 12 | use crate::{FileFormat, helpers}; 13 | 14 | use super::{CapabilitiesCanonical, DotComplex, Selectors}; 15 | 16 | #[derive(Debug, Default, Clone)] 17 | #[cfg_attr(test, derive(Dummy))] 18 | pub struct DotCanonical { 19 | #[cfg_attr(test, dummy(faker = "IndexMapFaker"))] 20 | pub selectors: IndexMap, 21 | } 22 | 23 | impl TryFrom for DotCanonical { 24 | type Error = Vec; 25 | #[cfg_attr(feature = "profiling", instrument)] 26 | fn try_from(value: DotComplex) -> Result { 27 | let mut errors = Self::Error::new(); 28 | let mut selectors = IndexMap::new(); 29 | for (selector, dot) in value.selectors { 30 | match Selectors::from_str(&selector) { 31 | Ok(f) => { 32 | selectors.insert(f, dot.into()); 33 | } 34 | Err(e) => { 35 | errors.push(helpers::ParseError::Selector(e)); 36 | } 37 | } 38 | } 39 | if !errors.is_empty() { 40 | return Err(errors); 41 | } 42 | Ok(Self { selectors }) 43 | } 44 | } 45 | 46 | impl DotCanonical { 47 | #[cfg_attr(feature = "profiling", instrument)] 48 | pub(crate) fn parse(value: &str, format: FileFormat) -> Result> { 49 | DotComplex::parse(value, format)?.try_conv::() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/dot/repr/dot_complex.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | use super::IndexMapFaker; 3 | use super::{CapabilitiesComplex, DotSimplified, parse_inner}; 4 | use crate::{FileFormat, helpers}; 5 | #[cfg(test)] 6 | use fake::Dummy; 7 | use indexmap::IndexMap; 8 | use serde::Deserialize; 9 | use tap::{Conv, Pipe}; 10 | #[cfg(feature = "profiling")] 11 | use tracing::instrument; 12 | 13 | #[derive(Deserialize, Debug, Default, Clone)] 14 | #[cfg_attr(test, derive(Dummy))] 15 | pub(super) struct DotComplex { 16 | #[cfg_attr(test, dummy(faker = "IndexMapFaker"))] 17 | #[serde(flatten)] 18 | pub selectors: IndexMap, 19 | } 20 | 21 | impl DotComplex { 22 | #[cfg_attr(feature = "profiling", instrument)] 23 | pub(super) fn parse(value: &str, format: FileFormat) -> Result> { 24 | match parse_inner::(value, format) { 25 | Ok(parsed) => parsed.pipe(Ok), 26 | Err(err) => Self { 27 | selectors: IndexMap::from([( 28 | "global".to_owned(), 29 | parse_inner::(value, format).map_err(|e| vec![err, e])?.conv::(), 30 | )]), 31 | } 32 | .pipe(Ok), 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/dot/repr/dot_simplified.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | path::PathBuf, 4 | }; 5 | 6 | #[cfg(test)] 7 | use fake::Dummy; 8 | use serde::Deserialize; 9 | 10 | use super::{InstallsComplex, LinksComplex}; 11 | 12 | #[derive(Deserialize, Debug, Default)] 13 | #[cfg_attr(test, derive(Dummy))] 14 | #[serde(deny_unknown_fields)] 15 | pub(super) struct DotSimplified { 16 | pub(super) links: Option>, 17 | pub(super) installs: Option, 18 | pub(super) depends: Option>, 19 | } 20 | -------------------------------------------------------------------------------- /src/dot/repr/installs_canonical.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | #[cfg(test)] 4 | use fake::Dummy; 5 | use serde::Deserialize; 6 | use strum::EnumIs; 7 | use velcro::hash_set; 8 | 9 | use super::InstallsComplex; 10 | 11 | #[derive(Deserialize, Clone, Debug, EnumIs)] 12 | #[serde(untagged)] 13 | #[cfg_attr(test, derive(Dummy))] 14 | #[serde(deny_unknown_fields)] 15 | pub enum InstallsCanonical { 16 | None(bool), 17 | Full { 18 | cmd: String, 19 | #[serde(default)] 20 | depends: HashSet, 21 | }, 22 | } 23 | 24 | impl From for InstallsCanonical { 25 | fn from(value: InstallsComplex) -> Self { 26 | match value { 27 | InstallsComplex::None(t) => InstallsCanonical::None(t), 28 | InstallsComplex::Simple(cmd) => InstallsCanonical::Full { cmd, depends: hash_set!() }, 29 | InstallsComplex::Full { cmd, depends } => InstallsCanonical::Full { cmd, depends }, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/dot/repr/installs_complex.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | #[cfg(test)] 4 | use fake::Dummy; 5 | use serde::Deserialize; 6 | use strum::EnumIs; 7 | 8 | #[derive(Deserialize, Clone, Debug, EnumIs)] 9 | #[serde(untagged)] 10 | #[cfg_attr(test, derive(Dummy))] 11 | #[serde(deny_unknown_fields)] 12 | pub(super) enum InstallsComplex { 13 | None(bool), 14 | Simple(String), 15 | Full { 16 | cmd: String, 17 | #[serde(default)] 18 | depends: HashSet, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/dot/repr/links_complex.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, path::PathBuf}; 2 | 3 | #[cfg(test)] 4 | use fake::Dummy; 5 | use serde::Deserialize; 6 | 7 | #[derive(Deserialize, Clone, Debug)] 8 | #[serde(untagged)] 9 | #[cfg_attr(test, derive(Dummy))] 10 | #[serde(deny_unknown_fields)] 11 | pub(super) enum LinksComplex { 12 | One(PathBuf), 13 | Many(HashSet), 14 | } 15 | -------------------------------------------------------------------------------- /src/dot/repr/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | use fake::{Dummy, Fake, Faker}; 3 | use serde::Deserialize; 4 | use tap::Pipe; 5 | #[cfg(feature = "profiling")] 6 | use tracing::instrument; 7 | 8 | use crate::{FileFormat, helpers}; 9 | 10 | mod dot_simplified; 11 | #[cfg(test)] 12 | mod test; 13 | use dot_simplified::DotSimplified; 14 | mod dot_complex; 15 | use dot_complex::DotComplex; 16 | mod dot_canonical; 17 | pub use dot_canonical::*; 18 | mod capabilities_complex; 19 | use capabilities_complex::CapabilitiesComplex; 20 | mod capabilities_canonical; 21 | pub use capabilities_canonical::*; 22 | mod links_complex; 23 | use links_complex::LinksComplex; 24 | mod installs_complex; 25 | use installs_complex::InstallsComplex; 26 | mod installs_canonical; 27 | pub use installs_canonical::*; 28 | mod selector; 29 | use selector::Selectors; 30 | 31 | #[cfg(feature = "toml")] 32 | fn parse_inner_toml Deserialize<'de>>(value: &str) -> Result { 33 | serde_toml::from_str::(value)?.pipe(Ok) 34 | } 35 | 36 | #[cfg(feature = "yaml")] 37 | fn parse_inner_yaml Deserialize<'de> + Default>(value: &str) -> Result { 38 | match serde_yaml::from_str::(value) { 39 | Ok(ok) => ok.pipe(Ok), 40 | Err(err) => match err.location() { 41 | Some(_) => err.pipe(Err)?, 42 | None => T::default().pipe(Ok), 43 | }, 44 | } 45 | } 46 | 47 | #[cfg(feature = "json")] 48 | fn parse_inner_json Deserialize<'de>>(value: &str) -> Result { 49 | serde_json::from_str::(value)?.pipe(Ok) 50 | } 51 | 52 | #[cfg_attr(feature = "profiling", instrument)] 53 | fn parse_inner Deserialize<'de> + Default>(value: &str, format: FileFormat) -> Result { 54 | match format { 55 | #[cfg(feature = "yaml")] 56 | FileFormat::Yaml => parse_inner_yaml::(value), 57 | #[cfg(feature = "toml")] 58 | FileFormat::Toml => parse_inner_toml::(value), 59 | #[cfg(feature = "json")] 60 | FileFormat::Json => parse_inner_json::(value), 61 | } 62 | } 63 | 64 | pub trait Merge { 65 | fn merge(self, merge: T) -> Self; 66 | } 67 | 68 | #[cfg(test)] 69 | struct IndexMapFaker; 70 | 71 | #[cfg(test)] 72 | #[allow(clippy::implicit_hasher)] 73 | impl Dummy for indexmap::IndexMap 74 | where 75 | K: std::hash::Hash + std::cmp::Eq + Dummy, 76 | V: Dummy, 77 | { 78 | fn dummy_with_rng(_: &IndexMapFaker, rng: &mut R) -> Self { 79 | let mut map = Self::new(); 80 | 81 | for _ in 0..((0..10).fake_with_rng(rng)) { 82 | map.insert(Faker.fake::(), Faker.fake::()); 83 | } 84 | 85 | map 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/dot/repr/selector.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use chumsky::{Parser, prelude::*}; 4 | #[cfg(test)] 5 | use fake::Dummy; 6 | use miette::{Diagnostic, LabeledSpan}; 7 | use strum::EnumString; 8 | use tap::Pipe; 9 | 10 | use crate::helpers::{MultipleErrors, os}; 11 | use crate::templating::{self, Engine, Parameters}; 12 | use thiserror::Error; 13 | 14 | #[derive(Debug, EnumString, Hash, PartialEq, Eq, Clone)] 15 | #[cfg_attr(test, derive(Dummy))] 16 | pub(super) enum Operator { 17 | #[strum(serialize = "=")] 18 | Eq, 19 | #[strum(serialize = "^=")] 20 | StartsWith, 21 | #[strum(serialize = "$=")] 22 | EndsWith, 23 | #[strum(serialize = "*=")] 24 | Contains, 25 | #[strum(serialize = "!=")] 26 | NotEq, 27 | } 28 | 29 | #[derive(Debug, Hash, PartialEq, Eq, Clone)] 30 | #[cfg_attr(test, derive(Dummy))] 31 | pub(super) struct Attribute { 32 | key: String, 33 | operator: Operator, 34 | value: String, 35 | } 36 | 37 | #[derive(Debug, Hash, PartialEq, Eq, Clone)] 38 | #[cfg_attr(test, derive(Dummy))] 39 | pub(super) struct Selector { 40 | pub os: os::Os, 41 | pub attributes: Vec, 42 | } 43 | 44 | fn string<'src>() -> impl Parser<'src, &'src str, String, extra::Err>> { 45 | let escape = just('\\').ignore_then( 46 | just('\\') 47 | .or(just('/')) 48 | .or(just('"')) 49 | .or(just('b').to('\x08')) 50 | .or(just('f').to('\x0C')) 51 | .or(just('n').to('\n')) 52 | .or(just('r').to('\r')) 53 | .or(just('t').to('\t')) 54 | .or( 55 | just('u').ignore_then(any().filter(|c: &char| c.is_ascii_hexdigit()).repeated().exactly(4).collect::().validate(|digits, e, emitter| { 56 | char::from_u32(u32::from_str_radix(&digits, 16).unwrap()).unwrap_or_else(|| { 57 | emitter.emit(Rich::custom(e.span(), "invalid unicode character")); 58 | '\u{FFFD}' // unicode replacement character 59 | }) 60 | })), 61 | ), 62 | ); 63 | 64 | just('"') 65 | .ignore_then(any().filter(|c| *c != '\\' && *c != '"').or(escape).repeated().collect::>()) 66 | .then_ignore(just('"')) 67 | .map(|s| s.into_iter().collect::()) 68 | } 69 | 70 | fn operator<'src>() -> impl Parser<'src, &'src str, Operator, extra::Err>> { 71 | just("=") 72 | .map(|_| Operator::Eq) 73 | .or(just("$=").map(|_| Operator::EndsWith)) 74 | .or(just("^=").map(|_| Operator::StartsWith)) 75 | .or(just("*=").map(|_| Operator::Contains)) 76 | .or(just("!=").map(|_| Operator::NotEq)) 77 | } 78 | fn path<'src>() -> impl Parser<'src, &'src str, String, extra::Err>> { 79 | text::ident().separated_by(just('.')).at_least(1).collect::>().map(|k| k.join(".")) 80 | } 81 | fn attribute<'src>() -> impl Parser<'src, &'src str, Attribute, extra::Err>> { 82 | path().then(operator().padded()).then(string()).map(|((key, operator), value)| Attribute { key, operator, value }) 83 | } 84 | fn attributes<'src>() -> impl Parser<'src, &'src str, Vec, extra::Err>> { 85 | attribute().delimited_by(just('['), just(']')).padded().repeated().collect::>() 86 | } 87 | fn os<'src>() -> impl Parser<'src, &'src str, os::Os, extra::Err>> { 88 | text::ident().try_map(|i: &str, span| os::Os::try_from(i).map_err(|e| Rich::custom(span, e))) 89 | } 90 | fn selector<'src>() -> impl Parser<'src, &'src str, Selector, extra::Err>> { 91 | os().then(attributes()).map(|(os, attributes)| Selector { os, attributes }) 92 | } 93 | 94 | #[derive(Debug, Hash, PartialEq, Eq, Clone)] 95 | #[cfg_attr(test, derive(Dummy))] 96 | pub struct Selectors(Vec); 97 | 98 | impl FromStr for Selectors { 99 | type Err = MultipleErrors; 100 | fn from_str(s: &str) -> Result { 101 | selector() 102 | .separated_by(just('|').padded()) 103 | .collect::>() 104 | .then_ignore(end()) 105 | .try_map(|selectors, span| { 106 | let selectors = Selectors(selectors); 107 | if selectors.is_global() && selectors.0.len() > 1 { 108 | Rich::custom(span, "global can not be mixed with an operating system").pipe(Err) 109 | } else { 110 | selectors.pipe(Ok) 111 | } 112 | }) 113 | .parse(s) 114 | .into_result() 115 | .map_err(|e| MultipleErrors::from_chumsky(s, e)) 116 | } 117 | } 118 | 119 | impl Selectors { 120 | pub fn is_global(&self) -> bool { 121 | self.0.iter().any(|f| f.os == os::Os::Global) 122 | } 123 | 124 | pub fn applies(&self, engine: &Engine, parameters: &Parameters) -> Result> { 125 | let mut errors = Vec::::new(); 126 | let mut applies = false; 127 | for selector in &self.0 { 128 | if selector.os.is_global() || os::OS == selector.os { 129 | let mut all = true; 130 | for attribute in &selector.attributes { 131 | let value = match engine.render(&format!("{{{{ {} }}}}", &attribute.key), parameters) { 132 | Ok(v) => v, 133 | Err(e) => { 134 | errors.push(e); 135 | continue; 136 | } 137 | }; 138 | 139 | if !match attribute.operator { 140 | Operator::Eq => value == attribute.value, 141 | Operator::StartsWith => value.starts_with(&attribute.value), 142 | Operator::EndsWith => value.ends_with(&attribute.value), 143 | Operator::Contains => value.contains(&attribute.value), 144 | Operator::NotEq => value != attribute.value, 145 | } { 146 | all = false; 147 | } 148 | } 149 | if all { 150 | applies = true; 151 | } 152 | } 153 | } 154 | 155 | if !errors.is_empty() { 156 | return Err(errors); 157 | } 158 | 159 | Ok(applies) 160 | } 161 | } 162 | 163 | #[derive(Error, Debug, Diagnostic)] 164 | #[error("{reason}")] 165 | #[diagnostic(code(parsing::selector::error))] 166 | struct SelectorError { 167 | #[source_code] 168 | src: String, 169 | #[label(collection, "error happened here")] 170 | labels: Vec, 171 | reason: String, 172 | } 173 | 174 | impl MultipleErrors { 175 | fn from_chumsky(selector: &str, errors: Vec>) -> Self { 176 | MultipleErrors::from( 177 | errors 178 | .into_iter() 179 | .map(|e| { 180 | let (reason, labels) = match e.reason() { 181 | chumsky::error::RichReason::ExpectedFound { expected, found } => ( 182 | found.map_or_else(|| "Selector ended unexpectedly".to_owned(), |f| format!("unexpected input: {f:?}")), 183 | expected 184 | .iter() 185 | .map(|p| LabeledSpan::new_with_span(Some(format!("expected one of: {p}")), e.span().into_range())) 186 | .collect(), 187 | ), 188 | chumsky::error::RichReason::Custom(c) => (c.clone(), vec![LabeledSpan::new_with_span(None, e.span().into_range())]), 189 | }; 190 | 191 | SelectorError { 192 | src: selector.to_owned(), 193 | reason, 194 | labels, 195 | } 196 | }) 197 | .collect::>(), 198 | ) 199 | } 200 | } 201 | 202 | #[cfg(test)] 203 | mod test { 204 | use super::{Attribute, Operator, Selector, Selectors}; 205 | use crate::helpers::os; 206 | use crate::os::Os; 207 | use chumsky::{Parser, prelude::end}; 208 | use rstest::rstest; 209 | use speculoos::assert_that; 210 | 211 | #[rstest] 212 | #[case("\"test\"", "test")] 213 | #[case("\"tes444t\"", "tes444t")] 214 | #[case("\"tes44 sf sdf \\\"sdf\\n dfg \\t g \\b \\f \\r \\/ \\\\4t\"", "tes44 sf sdf \"sdf\n dfg \t g \x08 \x0C \r / \\4t")] 215 | fn string_parser(#[case] from: &str, #[case] expected: &str) { 216 | let parsed = super::string().then_ignore(end()).parse(from).unwrap(); 217 | assert_that!(parsed.as_str()).is_equal_to(expected); 218 | } 219 | 220 | #[rstest] 221 | #[case("test")] 222 | #[case("test.tt")] 223 | #[case("test.t04.e")] 224 | fn path_parser(#[case] from: &str) { 225 | let parsed = super::path().then_ignore(end()).parse(from).unwrap(); 226 | assert_that!(parsed.as_str()).is_equal_to(from); 227 | } 228 | 229 | #[rstest] 230 | #[case("=", Operator::Eq)] 231 | #[case("^=", Operator::StartsWith)] 232 | #[case("$=", Operator::EndsWith)] 233 | #[case("*=", Operator::Contains)] 234 | #[case("!=", Operator::NotEq)] 235 | fn operator_parser(#[case] from: &str, #[case] expected: Operator) { 236 | let parsed = super::operator().then_ignore(end()).parse(from).unwrap(); 237 | assert_that!(parsed).is_equal_to(expected); 238 | } 239 | 240 | #[rstest] 241 | #[case("test=\"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })] 242 | #[case("test.test=\"value\"", Attribute { key: "test.test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })] 243 | #[case("test =\"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })] 244 | #[case("test= \"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })] 245 | #[case("test = \"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })] 246 | #[case("test^=\"value\"", Attribute { key: "test".to_owned(), operator: Operator::StartsWith, value: "value".to_owned() })] 247 | #[case("test $=\"value\"", Attribute { key: "test".to_owned(), operator: Operator::EndsWith, value: "value".to_owned() })] 248 | #[case("test*= \"value\"", Attribute { key: "test".to_owned(), operator: Operator::Contains, value: "value".to_owned() })] 249 | #[case("test != \"value\"", Attribute { key: "test".to_owned(), operator: Operator::NotEq, value: "value".to_owned() })] 250 | fn attribute_parser(#[case] from: &str, #[case] expected: Attribute) { 251 | let parsed = super::attribute().then_ignore(end()).parse(from).unwrap(); 252 | assert_that!(parsed).is_equal_to(expected); 253 | } 254 | 255 | #[rstest] 256 | #[case("[test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])] 257 | #[case("[test=\"value\"][test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }, Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])] 258 | #[case("[test=\"value\"][test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }, Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])] 259 | #[case("[test=\"value\"][test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }, Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])] 260 | fn attributes_parser(#[case] from: &str, #[case] expected: Vec) { 261 | let parsed = super::attributes().then_ignore(end()).parse(from).unwrap(); 262 | assert_that!(parsed).is_equal_to(expected); 263 | } 264 | 265 | #[rstest] 266 | #[case("windows", Os::Windows)] 267 | #[case("linux", Os::Linux)] 268 | #[case("darwin", Os::Darwin)] 269 | #[case("global", Os::Global)] 270 | fn os_parser(#[case] from: &str, #[case] expected: os::Os) { 271 | let parsed = super::os().then_ignore(end()).parse(from).unwrap(); 272 | assert_that!(parsed).is_equal_to(expected); 273 | } 274 | 275 | #[rstest] 276 | #[case("windows", Selector { os: Os::Windows, attributes: vec![] })] 277 | #[case("global[test=\"some\"][test=\"some\"]", Selector { os: Os::Global, attributes: vec![ 278 | Attribute { 279 | key: String::from("test"), 280 | operator: Operator::Eq, 281 | value: String::from("some") 282 | }, 283 | Attribute { 284 | key: String::from("test"), 285 | operator: Operator::Eq, 286 | value: String::from("some") 287 | } 288 | ]})] 289 | #[case("linux[whoami.distribution^=\"some\"]", Selector { os: Os::Linux, attributes: vec![ 290 | Attribute { 291 | key: String::from("whoami.distribution"), 292 | operator: Operator::StartsWith, 293 | value: String::from("some") 294 | } 295 | ]})] 296 | fn selector_parser(#[case] from: &str, #[case] expected: Selector) { 297 | let parsed = super::selector().then_ignore(end()).parse(from).unwrap(); 298 | assert_that!(parsed).named(from).is_equal_to(expected); 299 | } 300 | 301 | #[rstest] 302 | #[case("windows", vec![Selector { os: Os::Windows, attributes: vec![] }])] 303 | #[case("windows|linux", vec![Selector { os: Os::Windows, attributes: vec![] }, Selector { os: Os::Linux, attributes: vec![] }])] 304 | #[case("darwin|linux", vec![Selector { os: Os::Darwin, attributes: vec![] }, Selector { os: Os::Linux, attributes: vec![] }])] 305 | #[case("linux[whoami.distribution^=\"some\"]", vec![Selector { os: Os::Linux, attributes: vec![ 306 | Attribute { 307 | key: String::from("whoami.distribution"), 308 | operator: Operator::StartsWith, 309 | value: String::from("some") 310 | } 311 | ]}])] 312 | #[case("global[whoami.distribution$=\"some\"][test=\"other\"]", vec![Selector { os: Os::Global, attributes: vec![ 313 | Attribute { 314 | key: String::from("whoami.distribution"), 315 | operator: Operator::EndsWith, 316 | value: String::from("some") 317 | }, 318 | Attribute { 319 | key: String::from("test"), 320 | operator: Operator::Eq, 321 | value: String::from("other") 322 | } 323 | ] }])] 324 | #[case("linux[whoami.distribution^=\"some\"]|windows[whoami.distribution$=\"some\"][test=\"other\"]", vec![Selector { os: Os::Linux, attributes: vec![ 325 | Attribute { 326 | key: String::from("whoami.distribution"), 327 | operator: Operator::StartsWith, 328 | value: String::from("some") 329 | } 330 | ] },Selector { os: Os::Windows, attributes: vec![ 331 | Attribute { 332 | key: String::from("whoami.distribution"), 333 | operator: Operator::EndsWith, 334 | value: String::from("some") 335 | }, 336 | Attribute { 337 | key: String::from("test"), 338 | operator: Operator::Eq, 339 | value: String::from("other") 340 | } 341 | ] }])] 342 | fn selector_deserialization(#[case] from: &str, #[case] selector: Vec) { 343 | use std::str::FromStr; 344 | 345 | let parsed = Selectors::from_str(from).unwrap(); 346 | 347 | assert_that!(parsed).named(from).is_equal_to(Selectors(selector)); 348 | } 349 | 350 | #[rstest] 351 | #[case("windows[")] 352 | #[case("windows[]")] 353 | #[case("windows[test=]")] 354 | #[case("windows[test=\"test\"")] 355 | #[case("windows[test=\"]")] 356 | #[case("windows[999=\"\"]")] 357 | #[case("windows[999##=\"\"]")] 358 | #[case("windows test=\"\"]")] 359 | fn errors(#[case] from: &str) { 360 | use std::str::FromStr; 361 | 362 | Selectors::from_str(from).unwrap_err(); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/dot/repr/test.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use super::{DotComplex, InstallsComplex}; 4 | 5 | #[test] 6 | fn parse_dot_complex() { 7 | let dot_string = r" 8 | global: 9 | installs: test 10 | "; 11 | 12 | let dot = DotComplex::parse(dot_string, crate::FileFormat::Yaml).unwrap(); 13 | assert_that!(dot.selectors.contains_key("global")).is_true(); 14 | assert_that!(dot.selectors.get("global").unwrap().installs) 15 | .is_some() 16 | .matches(|i| matches!(i, InstallsComplex::Simple(s) if s == "test")); 17 | } 18 | -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test01/dot.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volllly/rotz/72dbd5b6c7eed8f77921aa983de68836120d7c74/src/dot/test/data/directory_structure/test01/dot.yaml -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test02/dot.yaml: -------------------------------------------------------------------------------- 1 | depends: [test01] 2 | -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test03/defaults.yaml: -------------------------------------------------------------------------------- 1 | installs: test03 2 | -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test03/test04/defaults.yaml: -------------------------------------------------------------------------------- 1 | installs: test04 2 | -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test03/test04/dot.yaml: -------------------------------------------------------------------------------- 1 | depends: [../test02] 2 | -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test03/test05/dot.yaml: -------------------------------------------------------------------------------- 1 | depends: [/test03/test04] 2 | -------------------------------------------------------------------------------- /src/dot/test/data/directory_structure/test03/test06/dot.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volllly/rotz/72dbd5b6c7eed8f77921aa983de68836120d7c74/src/dot/test/data/directory_structure/test03/test06/dot.yaml -------------------------------------------------------------------------------- /src/dot/test/data/file_formats/defaults.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volllly/rotz/72dbd5b6c7eed8f77921aa983de68836120d7c74/src/dot/test/data/file_formats/defaults.toml -------------------------------------------------------------------------------- /src/dot/test/data/file_formats/test01/dot.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volllly/rotz/72dbd5b6c7eed8f77921aa983de68836120d7c74/src/dot/test/data/file_formats/test01/dot.yaml -------------------------------------------------------------------------------- /src/dot/test/data/file_formats/test02/dot.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volllly/rotz/72dbd5b6c7eed8f77921aa983de68836120d7c74/src/dot/test/data/file_formats/test02/dot.toml -------------------------------------------------------------------------------- /src/dot/test/data/file_formats/test03/dot.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/dot/test/data/mod.rs: -------------------------------------------------------------------------------- 1 | mod selector; 2 | mod structure; 3 | 4 | #[macro_export] 5 | macro_rules! parse { 6 | ($format:literal, $engine:expr, $parameters:expr) => { 7 | $crate::dot::from_str_with_defaults( 8 | std::fs::read_to_string(std::path::Path::new(file!()).parent().unwrap().join(format!("dot.{}", $format))) 9 | .unwrap() 10 | .as_str(), 11 | $crate::FileFormat::try_from($format).unwrap(), 12 | None, 13 | $engine, 14 | $parameters, 15 | ) 16 | .unwrap() 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use crate::{ 4 | cli::{Cli, Command}, 5 | config::{Config, LinkType}, 6 | templating::{Engine, Parameters}, 7 | }; 8 | use figment::{util::map, value}; 9 | 10 | static CONFIG: LazyLock = LazyLock::new(|| Config { 11 | dotfiles: "dotfiles".into(), 12 | link_type: LinkType::Hard, 13 | shell_command: "shell_command".to_owned().into(), 14 | variables: map! { 15 | "test".to_owned() => "test".into(), 16 | "nested".to_owned() => map!{ 17 | "nest" => value::Value::from("nest") 18 | }.into() 19 | }, 20 | }); 21 | 22 | pub(crate) fn get_parameters<'a>() -> Parameters<'a> { 23 | Parameters { config: &CONFIG, name: "name" } 24 | } 25 | 26 | pub(crate) fn get_handlebars<'a>() -> Engine<'a> { 27 | let cli = Cli { 28 | dry_run: true, 29 | dotfiles: None, 30 | config: crate::cli::PathBuf("".into()), 31 | command: Command::Clone { repo: String::new() }, 32 | }; 33 | 34 | Engine::new(&Config::default(), &cli) 35 | } 36 | 37 | mod s01; 38 | mod s02; 39 | mod s03; 40 | mod s04; 41 | mod s05; 42 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s01/dot.yaml: -------------------------------------------------------------------------------- 1 | global[config.variables.test="test"]: 2 | installs: i01 3 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s01/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use crate::helpers::Select; 4 | 5 | use super::{get_handlebars, get_parameters}; 6 | 7 | #[test] 8 | fn selectors() { 9 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 10 | 11 | assert_that!(dot.installs).is_some().select(|i| &i.cmd).is_equal_to("i01".to_owned()); 12 | } 13 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s02/dot.yaml: -------------------------------------------------------------------------------- 1 | global[config.variables.test!="test"]: 2 | installs: i01 3 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s02/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use super::{get_handlebars, get_parameters}; 4 | 5 | #[test] 6 | fn selectors() { 7 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 8 | 9 | assert_that!(dot.installs).is_none(); 10 | } 11 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s03/dot.yaml: -------------------------------------------------------------------------------- 1 | global[config.variables.test="test"]: 2 | installs: i01 3 | 4 | global[config.variables.test!="test"]: 5 | installs: i02 6 | 7 | global[config.variables.test^="test"]: 8 | installs: i03 9 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s03/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use crate::helpers::Select; 4 | 5 | use super::{get_handlebars, get_parameters}; 6 | 7 | #[test] 8 | fn selectors() { 9 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 10 | 11 | assert_that!(dot.installs).is_some().select(|i| &i.cmd).is_equal_to("i03".to_owned()); 12 | } 13 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s04/dot.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | installs: i01 3 | 4 | windows[config.variables.test!="test"]: 5 | installs: i02 6 | 7 | linux[config.variables.test!="test"]: 8 | installs: i02 9 | 10 | darwin[config.variables.test!="test"]: 11 | installs: i02 12 | 13 | windows[config.variables.test="test"]: 14 | installs: i02 15 | 16 | linux[config.variables.test="test"]: 17 | installs: i02 18 | 19 | darwin[config.variables.test="test"]: 20 | installs: i02 21 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s04/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use crate::helpers::Select; 4 | 5 | use super::{get_handlebars, get_parameters}; 6 | 7 | #[test] 8 | fn selectors() { 9 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 10 | 11 | assert_that!(dot.installs).is_some().select(|i| &i.cmd).is_equal_to("i02".to_owned()); 12 | } 13 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s05/dot.yaml: -------------------------------------------------------------------------------- 1 | global[config.variables.test="test"][config.variables.test^="test"]: 2 | installs: i01 3 | -------------------------------------------------------------------------------- /src/dot/test/data/selector/s05/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use crate::helpers::Select; 4 | 5 | use super::{get_handlebars, get_parameters}; 6 | 7 | #[test] 8 | fn selectors() { 9 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 10 | 11 | assert_that!(dot.installs).is_some().select(|i| &i.cmd).is_equal_to("i01".to_owned()); 12 | } 13 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use crate::{ 4 | cli::{Cli, Command, PathBuf}, 5 | config::{Config, LinkType}, 6 | templating::{Engine, Parameters}, 7 | }; 8 | use figment::{util::map, value}; 9 | 10 | static CONFIG: LazyLock = LazyLock::new(|| Config { 11 | dotfiles: "dotfiles".into(), 12 | link_type: LinkType::Hard, 13 | shell_command: "shell_command".to_owned().into(), 14 | variables: map! { 15 | "test".to_owned() => "test".into(), 16 | "nested".to_owned() => map!{ 17 | "nest" => value::Value::from("nest") 18 | }.into() 19 | }, 20 | }); 21 | 22 | pub(crate) fn get_parameters<'a>() -> Parameters<'a> { 23 | Parameters { config: &CONFIG, name: "name" } 24 | } 25 | 26 | pub(crate) fn get_handlebars<'a>() -> Engine<'a> { 27 | let cli = Cli { 28 | dry_run: true, 29 | dotfiles: None, 30 | config: PathBuf("".into()), 31 | command: Command::Clone { repo: String::new() }, 32 | }; 33 | 34 | Engine::new(&Config::default(), &cli) 35 | } 36 | 37 | mod s01; 38 | mod s02; 39 | mod s03; 40 | mod s04; 41 | mod s05; 42 | mod s06; 43 | mod s07; 44 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s01/dot.yaml: -------------------------------------------------------------------------------- 1 | links: 2 | k01: v01 3 | k02: v02 4 | installs: i01 5 | depends: [d01] 6 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s01/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use speculoos::{assert_that, prelude::*}; 4 | use tap::Tap; 5 | use velcro::hash_set; 6 | 7 | use crate::helpers::Select; 8 | 9 | use super::{get_handlebars, get_parameters}; 10 | 11 | #[test] 12 | fn structure() { 13 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 14 | 15 | assert_that!(dot.links) 16 | .is_some() 17 | .tap_mut(|l| l.contains_entry(PathBuf::from("k01"), &hash_set![PathBuf::from("v01")])) 18 | .tap_mut(|l| l.contains_entry(PathBuf::from("k02"), &hash_set![PathBuf::from("v02")])); 19 | 20 | assert_that!(dot.installs).is_some().select(|i| &i.cmd).is_equal_to("i01".to_owned()); 21 | 22 | assert_that!(dot.depends).is_some().contains("d01".to_owned()); 23 | } 24 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s02/dot.yaml: -------------------------------------------------------------------------------- 1 | links: 2 | k01: [v01a, v01b] 3 | k02: [v02a, v02b] 4 | installs: 5 | cmd: i01 6 | depends: [d01] 7 | depends: [d02] 8 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s02/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use speculoos::{assert_that, prelude::*}; 4 | use tap::Tap; 5 | use velcro::hash_set; 6 | 7 | use crate::helpers::Select; 8 | 9 | use super::{get_handlebars, get_parameters}; 10 | 11 | #[test] 12 | fn structure() { 13 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 14 | 15 | assert_that!(dot.links) 16 | .is_some() 17 | .tap_mut(|l| l.contains_entry(PathBuf::from("k01"), &hash_set![PathBuf::from("v01a"), PathBuf::from("v01b")])) 18 | .tap_mut(|l| l.contains_entry(PathBuf::from("k02"), &hash_set![PathBuf::from("v02a"), PathBuf::from("v02b")])); 19 | 20 | assert_that!(dot.installs) 21 | .is_some() 22 | .select_and(|i| &i.cmd, |mut c| c.is_equal_to("i01".to_owned())) 23 | .select_and(|i| &i.depends, |mut d| d.contains("d01".to_owned())); 24 | 25 | assert_that!(dot.depends).is_some().contains("d02".to_owned()); 26 | } 27 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s03/dot.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | links: 3 | k01: v01 4 | 5 | windows: 6 | links: 7 | k02: v02 8 | 9 | linux: 10 | links: 11 | k02: v02 12 | 13 | darwin: 14 | links: 15 | k02: v02 16 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s03/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use speculoos::{assert_that, prelude::*}; 4 | use tap::Tap; 5 | use velcro::hash_set; 6 | 7 | use super::{get_handlebars, get_parameters}; 8 | 9 | #[test] 10 | fn structure() { 11 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 12 | 13 | assert_that!(dot.links) 14 | .is_some() 15 | .tap_mut(|l| l.contains_entry(PathBuf::from("k02"), &hash_set![PathBuf::from("v02")])) 16 | .tap_mut(|l| l.contains_entry(PathBuf::from("k01"), &hash_set![PathBuf::from("v01")])); 17 | } 18 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s04/dot.yaml: -------------------------------------------------------------------------------- 1 | links: 2 | k01: v01 3 | k02: [v02a, v02b] 4 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s04/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use speculoos::{assert_that, prelude::*}; 4 | use tap::Tap; 5 | use velcro::hash_set; 6 | 7 | use super::{get_handlebars, get_parameters}; 8 | 9 | #[test] 10 | fn structure() { 11 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 12 | 13 | assert_that!(dot.links) 14 | .is_some() 15 | .tap_mut(|l| l.contains_entry(PathBuf::from("k01"), &hash_set![PathBuf::from("v01")])) 16 | .tap_mut(|l| l.contains_entry(PathBuf::from("k02"), &hash_set![PathBuf::from("v02a"), PathBuf::from("v02b")])); 17 | } 18 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s05/dot.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | installs: i01 3 | 4 | windows: 5 | installs: false 6 | 7 | linux: 8 | installs: false 9 | 10 | darwin: 11 | installs: false 12 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s05/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use super::{get_handlebars, get_parameters}; 4 | 5 | #[test] 6 | fn structure() { 7 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 8 | 9 | assert_that!(dot.installs).is_none(); 10 | } 11 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s06/dot.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | installs: i01 3 | 4 | windows: 5 | installs: 6 | 7 | linux: 8 | installs: 9 | 10 | darwin: 11 | installs: 12 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s06/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use crate::helpers::Select; 4 | 5 | use super::{get_handlebars, get_parameters}; 6 | 7 | #[test] 8 | fn structure() { 9 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 10 | 11 | assert_that!(dot.installs).is_some().select_and(|i| &i.cmd, |mut c| c.is_equal_to("i01".to_owned())); 12 | } 13 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s07/dot.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | installs: i01 3 | 4 | windows: 5 | installs: i02 6 | 7 | linux: 8 | installs: i02 9 | 10 | darwin: 11 | installs: i02 12 | -------------------------------------------------------------------------------- /src/dot/test/data/structure/s07/mod.rs: -------------------------------------------------------------------------------- 1 | use speculoos::{assert_that, prelude::*}; 2 | 3 | use crate::helpers::Select; 4 | 5 | use super::{get_handlebars, get_parameters}; 6 | 7 | #[test] 8 | fn structure() { 9 | let dot = crate::parse!("yaml", &get_handlebars(), &get_parameters()); 10 | 11 | assert_that!(dot.installs).is_some().select_and(|i| &i.cmd, |mut c| c.is_equal_to("i02".to_owned())); 12 | } 13 | -------------------------------------------------------------------------------- /src/dot/test/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use speculoos::prelude::*; 4 | use tap::Tap; 5 | 6 | use super::{defaults::Defaults, read_dots}; 7 | use crate::{helpers::Select, templating::test::get_handlebars}; 8 | 9 | mod data; 10 | 11 | #[test] 12 | fn read_all_dots() { 13 | let dots = read_dots( 14 | Path::new(file!()).parent().unwrap().join("data/directory_structure").as_path(), 15 | &["/**".to_owned()], 16 | &Default::default(), 17 | &get_handlebars(), 18 | ) 19 | .unwrap(); 20 | 21 | assert_that!(dots) 22 | .tap_mut(|d| d.has_length(5)) 23 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test01")) 24 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test02")) 25 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test03/test04")) 26 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test03/test05")) 27 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test03/test06")); 28 | 29 | assert_that!(dots.iter().find(|d| d.0 == "/test02")) 30 | .is_some() 31 | .select(|d| &d.1.depends) 32 | .is_some() 33 | .contains("/test01".to_owned()); 34 | 35 | assert_that!(dots.iter().find(|d| d.0 == "/test03/test04")) 36 | .is_some() 37 | .select(|d| &d.1.depends) 38 | .is_some() 39 | .contains("/test02".to_owned()); 40 | 41 | assert_that!(dots.iter().find(|d| d.0 == "/test03/test05")) 42 | .is_some() 43 | .select(|d| &d.1.depends) 44 | .is_some() 45 | .contains("/test03/test04".to_owned()); 46 | } 47 | 48 | #[test] 49 | fn read_sub_dots() { 50 | let dots = read_dots( 51 | Path::new(file!()).parent().unwrap().join("data/directory_structure").as_path(), 52 | &["/test03/*".to_owned()], 53 | &Default::default(), 54 | &get_handlebars(), 55 | ) 56 | .unwrap(); 57 | 58 | assert_that!(dots) 59 | .tap_mut(|d| d.has_length(3)) 60 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test03/test04")) 61 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test03/test05")) 62 | .tap_mut(|d| d.mapped_contains(|d| &d.0, &"/test03/test06")); 63 | } 64 | 65 | #[test] 66 | fn read_non_sub_dots() { 67 | let dots = read_dots( 68 | Path::new(file!()).parent().unwrap().join("data/directory_structure").as_path(), 69 | &["/*".to_owned()], 70 | &Default::default(), 71 | &get_handlebars(), 72 | ) 73 | .unwrap(); 74 | 75 | assert_that!(dots).has_length(2); 76 | assert_that!(dots).mapped_contains(|d| &d.0, &"/test01"); 77 | assert_that!(dots).mapped_contains(|d| &d.0, &"/test02"); 78 | } 79 | 80 | #[test] 81 | fn read_defaults() { 82 | let defaults = Defaults::from_path(Path::new(file!()).parent().unwrap().join("data/directory_structure").as_path()).unwrap(); 83 | 84 | assert_that!(defaults.for_path("/test03/test05")).is_some(); 85 | assert_that!(defaults.for_path("/test03/test04")).is_some(); 86 | assert_that!(defaults.for_path("/test03")).is_some(); 87 | } 88 | 89 | #[test] 90 | fn read_sub_dots_with_defaults() { 91 | let dots = read_dots( 92 | Path::new(file!()).parent().unwrap().join("data/directory_structure").as_path(), 93 | &["/test03/*".to_owned()], 94 | &Default::default(), 95 | &get_handlebars(), 96 | ) 97 | .unwrap(); 98 | 99 | assert_that!(dots) 100 | .tap_mut(|d| d.has_length(3)) 101 | .select_and( 102 | |d| d.iter().find(|d| d.0 == "/test03/test04").unwrap(), 103 | |d| { 104 | d.map(|d| &d.1).matches(|i| i.installs.as_ref().unwrap().cmd == "test04"); 105 | }, 106 | ) 107 | .select_and( 108 | |d| d.iter().find(|d| d.0 == "/test03/test05").unwrap(), 109 | |d| { 110 | d.map(|d| &d.1).matches(|i| i.installs.as_ref().unwrap().cmd == "test03"); 111 | }, 112 | ) 113 | .select_and( 114 | |d| d.iter().find(|d| d.0 == "/test03/test06").unwrap(), 115 | |d| { 116 | d.map(|d| &d.1).matches(|i| i.installs.as_ref().unwrap().cmd == "test03"); 117 | }, 118 | ); 119 | } 120 | 121 | #[test] 122 | fn read_all_file_formats() { 123 | let dots = read_dots( 124 | Path::new(file!()).parent().unwrap().join("data/file_formats").as_path(), 125 | &["/**".to_owned()], 126 | &Default::default(), 127 | &get_handlebars(), 128 | ) 129 | .unwrap(); 130 | 131 | assert_that!(dots).has_length(3); 132 | assert_that!(dots).mapped_contains(|d| &d.0, &"/test01"); 133 | assert_that!(dots).mapped_contains(|d| &d.0, &"/test02"); 134 | assert_that!(dots).mapped_contains(|d| &d.0, &"/test03"); 135 | } 136 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsStr, 3 | fmt::Debug, 4 | io::{self, Write}, 5 | path::{Path, PathBuf}, 6 | process, 7 | }; 8 | 9 | use itertools::Itertools; 10 | use miette::{Diagnostic, Result}; 11 | use path_absolutize::Absolutize; 12 | use path_slash::PathExt; 13 | #[cfg(test)] 14 | use speculoos::assert_that; 15 | use tap::Pipe; 16 | #[cfg(feature = "profiling")] 17 | use tracing::instrument; 18 | use wax::{Any, Glob}; 19 | 20 | use crate::{FILE_EXTENSIONS, FileFormat}; 21 | 22 | #[derive(thiserror::Error, Diagnostic, Debug)] 23 | #[error("Encountered multiple errors")] 24 | pub struct MultipleErrors(#[related] Vec>); 25 | 26 | impl MultipleErrors { 27 | pub fn from(errors: Vec) -> Self { 28 | Self(errors.into_iter().map(Box::::from).collect()) 29 | } 30 | } 31 | 32 | #[cfg_attr(feature = "profiling", instrument)] 33 | pub fn join_err_result(result: Vec>) -> Result, MultipleErrors> 34 | where 35 | T: Debug, 36 | E: miette::Diagnostic + Send + Sync + 'static, 37 | { 38 | if result.iter().any(std::result::Result::is_err) { 39 | MultipleErrors(result.into_iter().filter_map(Result::err).map(Box::::from).collect_vec()).pipe(Err) 40 | } else { 41 | Ok(result.into_iter().map(Result::unwrap).collect()) 42 | } 43 | } 44 | 45 | #[cfg_attr(feature = "profiling", instrument)] 46 | pub fn join_err(result: Vec) -> Result<(), MultipleErrors> 47 | where 48 | E: miette::Diagnostic + Send + Sync + 'static, 49 | { 50 | if result.is_empty() { 51 | return ().pipe(Ok); 52 | } 53 | 54 | MultipleErrors(result.into_iter().map(Into::into).collect_vec()).pipe(Err) 55 | } 56 | 57 | pub mod os { 58 | #[cfg(test)] 59 | use fake::Dummy; 60 | use strum::{Display, EnumIs, EnumString}; 61 | 62 | #[derive(EnumIs, Display, Debug, EnumString, Hash, PartialEq, Eq, Clone)] 63 | #[cfg_attr(test, derive(Dummy))] 64 | #[strum(ascii_case_insensitive)] 65 | pub enum Os { 66 | Global, 67 | Windows, 68 | Linux, 69 | Darwin, 70 | } 71 | 72 | #[cfg(windows)] 73 | pub const OS: Os = Os::Windows; 74 | #[cfg(all(not(target_os = "macos"), unix))] 75 | pub const OS: Os = Os::Linux; 76 | #[cfg(target_os = "macos")] 77 | pub const OS: Os = Os::Darwin; 78 | } 79 | 80 | #[derive(thiserror::Error, Diagnostic, Debug)] 81 | pub enum RunError { 82 | #[error("Could not spawn command")] 83 | #[diagnostic(code(process::command::spawn))] 84 | Spawn(#[source] io::Error), 85 | 86 | #[error("Command did not complete successfully. (Exitcode {0:?})")] 87 | #[diagnostic(code(process::command::execute))] 88 | Execute(Option), 89 | 90 | #[error("Could not write output")] 91 | #[diagnostic(code(process::command::output))] 92 | Write(#[from] io::Error), 93 | } 94 | 95 | #[cfg_attr(feature = "profiling", instrument)] 96 | pub fn run_command(cmd: &str, args: &[impl AsRef + Debug], silent: bool, dry_run: bool) -> Result { 97 | if dry_run { 98 | return String::new().pipe(Ok); 99 | } 100 | 101 | let output = process::Command::new(cmd).args(args).stdin(process::Stdio::null()).output().map_err(RunError::Spawn)?; 102 | 103 | if !silent { 104 | std::io::stdout().write_all(&output.stdout)?; 105 | std::io::stdout().write_all(&output.stderr)?; 106 | } 107 | 108 | if !output.status.success() { 109 | if silent { 110 | std::io::stdout().write_all(&output.stdout)?; 111 | std::io::stdout().write_all(&output.stderr)?; 112 | } 113 | RunError::Execute(output.status.code()).pipe(Err)?; 114 | } 115 | 116 | String::from_utf8_lossy(&output.stdout).to_string().pipe(Ok) 117 | } 118 | 119 | #[derive(thiserror::Error, Diagnostic, Debug)] 120 | pub enum GlobError { 121 | #[error("Could not build GlobSet")] 122 | #[diagnostic(code(glob::set::parse))] 123 | Build(#[from] wax::BuildError), 124 | } 125 | 126 | #[cfg_attr(feature = "profiling", instrument)] 127 | pub fn glob_from_vec(from: &[String], postfix: Option<&str>) -> miette::Result> { 128 | from 129 | .iter() 130 | .map(|g| postfix.map_or_else(|| g.to_string(), |postfix| format!("{g}{postfix}"))) 131 | .map(|g| Glob::new(&g).map(Glob::into_owned).map_err(GlobError::Build)) 132 | .collect_vec() 133 | .pipe(join_err_result)? 134 | .pipe(|g| wax::any::<_>(g).unwrap().pipe(Ok)) 135 | } 136 | 137 | #[allow(clippy::redundant_pub_crate)] 138 | #[cfg_attr(feature = "profiling", instrument)] 139 | pub(crate) fn get_file_with_format(path: impl AsRef + Debug, base_name: impl AsRef + Debug) -> Option<(PathBuf, FileFormat)> { 140 | FILE_EXTENSIONS.iter().map(|e| (path.as_ref().join(base_name.as_ref().with_extension(e.0)), e.1)).find(|e| e.0.exists()) 141 | } 142 | 143 | #[cfg(test)] 144 | pub trait Select<'s, O: 's, N: 's> { 145 | fn select(self, selector: F) -> speculoos::Spec<'s, N> 146 | where 147 | F: FnOnce(&'s O) -> &'s N; 148 | 149 | fn select_and(&self, selector: S, with: W) -> &Self 150 | where 151 | S: FnOnce(&'s O) -> &'s N, 152 | W: FnOnce(speculoos::Spec<'s, N>); 153 | } 154 | 155 | #[cfg(test)] 156 | impl<'s, O: 's, N: 's> Select<'s, O, N> for speculoos::Spec<'s, O> { 157 | fn select(self, selector: F) -> speculoos::Spec<'s, N> 158 | where 159 | F: FnOnce(&'s O) -> &'s N, 160 | { 161 | assert_that!(*selector(self.subject)) 162 | } 163 | 164 | fn select_and(&self, selector: S, with: W) -> &Self 165 | where 166 | S: FnOnce(&'s O) -> &'s N, 167 | W: FnOnce(speculoos::Spec<'s, N>), 168 | { 169 | with(assert_that!(*selector(self.subject))); 170 | self 171 | } 172 | } 173 | 174 | #[cfg_attr(feature = "profiling", instrument)] 175 | pub fn absolutize_virtually(path: &Path) -> Result { 176 | path 177 | .absolutize_virtually("/")? 178 | .to_slash_lossy() 179 | .to_string() 180 | .pipe(|name| name.find('/').map_or(name.as_str(), |root_index| &name[root_index..]).to_owned().pipe(Ok)) 181 | } 182 | 183 | #[derive(thiserror::Error, Diagnostic, Debug)] 184 | pub enum ParseError { 185 | #[error(transparent)] 186 | #[diagnostic(code(parsing::toml::de))] 187 | #[cfg(feature = "toml")] 188 | TomlDe(#[from] serde_toml::de::Error), 189 | 190 | #[error(transparent)] 191 | #[diagnostic(code(parsing::toml::ser))] 192 | #[cfg(feature = "toml")] 193 | TomlSer(#[from] serde_toml::ser::Error), 194 | 195 | #[error(transparent)] 196 | #[diagnostic(code(parsing::yaml))] 197 | #[cfg(feature = "yaml")] 198 | Yaml(#[from] serde_yaml::Error), 199 | 200 | #[error(transparent)] 201 | #[diagnostic(code(parsing::json))] 202 | #[cfg(feature = "json")] 203 | Json(#[from] serde_json::Error), 204 | 205 | #[error("Encountered errors while parsing selectors")] 206 | #[diagnostic(transparent, code(parsing::selector))] 207 | Selector( 208 | #[from] 209 | #[diagnostic_source] 210 | MultipleErrors, 211 | ), 212 | } 213 | 214 | pub fn resolve_home(path: impl AsRef) -> PathBuf { 215 | let path = path.as_ref(); 216 | 217 | if path.starts_with("~/") { 218 | let mut iter = path.iter(); 219 | iter.next(); 220 | crate::USER_DIRS.home_dir().iter().chain(iter).collect() 221 | } else { 222 | path.to_owned() 223 | } 224 | } 225 | 226 | #[cfg(test)] 227 | mod tests { 228 | use miette::Diagnostic; 229 | use speculoos::prelude::*; 230 | 231 | use crate::helpers::join_err_result; 232 | 233 | #[derive(thiserror::Error, Debug, Diagnostic)] 234 | #[error("")] 235 | struct Error; 236 | 237 | #[test] 238 | fn join_err_result_none() { 239 | let joined = join_err_result(vec![Ok::<(), Error>(()), Ok::<(), Error>(())]); 240 | assert_that!(&joined).is_ok().has_length(2); 241 | } 242 | 243 | #[test] 244 | fn join_err_result_some() { 245 | let joined = join_err_result(vec![Ok::<(), Error>(()), Err::<(), Error>(Error), Err::<(), Error>(Error), Ok::<(), Error>(())]); 246 | 247 | assert_that!(&joined).is_err().map(|e| &e.0).has_length(2); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | convert::TryFrom, 4 | fs::{self, File}, 5 | path::{Path, PathBuf}, 6 | sync::LazyLock, 7 | }; 8 | 9 | use clap::Parser; 10 | use commands::Command; 11 | use directories::{ProjectDirs, UserDirs}; 12 | #[cfg(feature = "json")] 13 | use figment::providers::Json; 14 | #[cfg(feature = "toml")] 15 | use figment::providers::Toml; 16 | #[cfg(feature = "yaml")] 17 | use figment::providers::Yaml; 18 | use figment::{ 19 | Figment, Profile, 20 | providers::{Env, Format}, 21 | }; 22 | use helpers::os; 23 | use miette::{Diagnostic, Result, SourceSpan}; 24 | use strum::Display; 25 | 26 | mod helpers; 27 | 28 | mod cli; 29 | use cli::Cli; 30 | 31 | mod config; 32 | use config::{Config, MappedProfileProvider}; 33 | use state::State; 34 | use tap::Pipe; 35 | #[cfg(feature = "profiling")] 36 | use tracing::instrument; 37 | use velcro::hash_map; 38 | 39 | mod commands; 40 | mod dot; 41 | mod state; 42 | mod templating; 43 | 44 | #[cfg(not(any(feature = "toml", feature = "yaml", feature = "json")))] 45 | compile_error!("At least one file format features needs to be enabled"); 46 | 47 | #[derive(thiserror::Error, Diagnostic, Debug)] 48 | pub enum Error { 49 | #[error("Unknown file extension")] 50 | #[diagnostic(code(parse::extension))] 51 | UnknownExtension(#[source_code] String, #[label] SourceSpan), 52 | 53 | #[error("Could not get \"{0}\" directory")] 54 | #[diagnostic(code(project_dirs::not_found))] 55 | GettingDirs(&'static str), 56 | 57 | #[error("Could parse config file directory \"{0}\"")] 58 | #[diagnostic(code(config::parent_dir))] 59 | ParsingConfigDir(PathBuf), 60 | 61 | #[error("Could not create config file directory \"{0}\"")] 62 | #[diagnostic(code(config::create))] 63 | CreatingConfig(PathBuf, #[source] std::io::Error), 64 | 65 | #[error("Could not read config file \"{0}\"")] 66 | #[diagnostic(code(config::read), help("Do you have access to the config file?"))] 67 | ReadingConfig(PathBuf, #[source] std::io::Error), 68 | 69 | #[error("Cloud not parse config")] 70 | #[diagnostic(code(config::parse), help("Is the config file in the correct format?"))] 71 | ParsingConfig(#[source] figment::Error), 72 | 73 | #[error("Cloud not parse config")] 74 | #[diagnostic(code(config::local::parse), help("Did you provide a top level \"global\" key in the repo level config?"))] 75 | RepoConfigProfile(#[source] figment::Error), 76 | 77 | #[error("Default profile not allowed in config")] 78 | #[diagnostic(code(config::local::default), help("Change the top level \"default\" key in the repo level config to \"global\""))] 79 | RepoConfigDefaultProfile, 80 | } 81 | 82 | pub(crate) static PROJECT_DIRS: LazyLock = LazyLock::new(|| ProjectDirs::from("com", "", "rotz").ok_or(Error::GettingDirs("application data")).expect("Could not read project dirs")); 83 | pub(crate) static USER_DIRS: LazyLock = LazyLock::new(|| UserDirs::new().ok_or(Error::GettingDirs("user")).expect("Could not read user dirs")); 84 | pub(crate) const FILE_EXTENSIONS_GLOB: &str = "{yml,toml,json}"; 85 | pub(crate) const FILE_EXTENSIONS: &[(&str, FileFormat)] = &[ 86 | #[cfg(feature = "yaml")] 87 | ("yaml", FileFormat::Yaml), 88 | #[cfg(feature = "yaml")] 89 | ("yml", FileFormat::Yaml), 90 | #[cfg(feature = "toml")] 91 | ("toml", FileFormat::Toml), 92 | #[cfg(feature = "json")] 93 | ("json", FileFormat::Json), 94 | ]; 95 | 96 | #[derive(Debug, Display, Clone, Copy)] 97 | pub(crate) enum FileFormat { 98 | #[cfg(feature = "yaml")] 99 | #[strum(to_string = "yaml")] 100 | Yaml, 101 | #[cfg(feature = "toml")] 102 | #[strum(to_string = "toml")] 103 | Toml, 104 | #[cfg(feature = "json")] 105 | #[strum(to_string = "json")] 106 | Json, 107 | } 108 | 109 | impl TryFrom<&str> for FileFormat { 110 | type Error = Error; 111 | 112 | fn try_from(value: &str) -> Result { 113 | FILE_EXTENSIONS 114 | .iter() 115 | .find(|e| e.0 == value) 116 | .map(|e| e.1) 117 | .ok_or_else(|| Error::UnknownExtension(value.to_owned(), (0, value.len()).into())) 118 | } 119 | } 120 | 121 | impl TryFrom<&Path> for FileFormat { 122 | type Error = Error; 123 | 124 | fn try_from(value: &Path) -> Result { 125 | value.extension().map_or_else( 126 | || Error::UnknownExtension(value.to_string_lossy().to_string(), (0, 0).into()).pipe(Err), 127 | |extension| { 128 | FILE_EXTENSIONS 129 | .iter() 130 | .find(|e| e.0 == extension) 131 | .map(|e| e.1) 132 | .ok_or_else(|| Error::UnknownExtension(extension.to_string_lossy().to_string(), (0, extension.len()).into())) 133 | }, 134 | ) 135 | } 136 | } 137 | 138 | #[cfg(feature = "profiling")] 139 | fn main() -> Result<(), miette::Report> { 140 | use tracing_subscriber::prelude::*; 141 | use tracing_tracy::TracyLayer; 142 | 143 | let tracy_layer = TracyLayer::default(); 144 | tracing_subscriber::registry().with(tracy_layer).init(); 145 | 146 | let result = run(); 147 | 148 | std::thread::sleep(std::time::Duration::from_secs(2)); 149 | 150 | result 151 | } 152 | 153 | #[cfg(not(feature = "profiling"))] 154 | fn main() -> Result<(), miette::Report> { 155 | miette::set_hook(Box::new(|_| Box::new(miette::MietteHandlerOpts::new().show_related_errors_as_nested().with_cause_chain().build()))).unwrap(); 156 | run() 157 | } 158 | 159 | #[cfg_attr(feature = "profiling", instrument)] 160 | fn run() -> Result<(), miette::Report> { 161 | let cli = Cli::parse(); 162 | 163 | if !cli.config.0.exists() { 164 | fs::create_dir_all(cli.config.0.parent().ok_or_else(|| Error::ParsingConfigDir(cli.config.0.clone()))?).map_err(|e| Error::CreatingConfig(cli.config.0.clone(), e))?; 165 | File::create(&cli.config.0).map_err(|e| Error::CreatingConfig(cli.config.0.clone(), e))?; 166 | } 167 | 168 | let config = read_config(&cli)?; 169 | 170 | let engine = templating::Engine::new(&config, &cli); 171 | let mut state = State::read()?; 172 | match cli.command.clone() { 173 | cli::Command::Link { link } => commands::Link::new(config, engine) 174 | .execute((cli.bake(), link.bake(), &state.linked)) 175 | .map(|linked| state.linked = linked), 176 | cli::Command::Clone { repo } => commands::Clone::new(config).execute((cli, repo)), 177 | cli::Command::Install { install } => commands::Install::new(config, engine).execute((cli.bake(), install.bake())), 178 | cli::Command::Init { repo } => commands::Init::new(config).execute((cli, repo)), 179 | cli::Command::Completions { shell } => commands::Completions::new().execute(shell), 180 | }?; 181 | 182 | state.write().map_err(Into::into) 183 | } 184 | 185 | fn read_config(cli: &Cli) -> Result { 186 | let env_config = Env::prefixed("ROTZ_"); 187 | 188 | let config_path = helpers::resolve_home(&cli.config.0); 189 | 190 | let mut figment = Figment::new().merge_from_path(&config_path, false)?.merge(env_config).merge(cli); 191 | 192 | let config: Config = figment.clone().join(Config::default()).extract().map_err(Error::ParsingConfig)?; 193 | 194 | let dotfiles = helpers::resolve_home(&config.dotfiles); 195 | 196 | if let Some((config, _)) = helpers::get_file_with_format(dotfiles, "config") { 197 | figment = figment.join_from_path(config, true, hash_map!( "global".into(): "default".into(), "force".into(): "global".into() ))?; 198 | } 199 | 200 | let mut config: Config = figment 201 | .join(Config::default()) 202 | .select(os::OS.to_string().to_ascii_lowercase()) 203 | .extract() 204 | .map_err(Error::RepoConfigProfile)?; 205 | 206 | config.dotfiles = helpers::resolve_home(&config.dotfiles); 207 | 208 | config.pipe(Ok) 209 | } 210 | 211 | trait FigmentExt { 212 | fn merge_from_path(self, path: impl AsRef, nested: bool) -> Result 213 | where 214 | Self: std::marker::Sized; 215 | fn join_from_path(self, path: impl AsRef, nested: bool, mapping: HashMap) -> Result 216 | where 217 | Self: std::marker::Sized; 218 | } 219 | 220 | trait DataExt { 221 | fn set_nested(self, nested: bool) -> Self 222 | where 223 | Self: std::marker::Sized; 224 | } 225 | 226 | impl DataExt for figment::providers::Data { 227 | fn set_nested(self, nested: bool) -> Self 228 | where 229 | Self: std::marker::Sized, 230 | { 231 | if nested { self.nested() } else { self } 232 | } 233 | } 234 | 235 | #[derive(strum::Display, strum::EnumString)] 236 | #[strum(ascii_case_insensitive)] 237 | enum Profiles { 238 | Force, 239 | Global, 240 | } 241 | 242 | impl FigmentExt for Figment { 243 | fn merge_from_path(self, path: impl AsRef, nested: bool) -> Result { 244 | let config_str = fs::read_to_string(&path).map_err(|e| Error::ReadingConfig(path.as_ref().to_path_buf(), e))?; 245 | if !config_str.is_empty() { 246 | let file_extension = &*path.as_ref().extension().unwrap().to_string_lossy(); 247 | return match file_extension { 248 | #[cfg(feature = "yaml")] 249 | "yaml" | "yml" => self.merge(Yaml::string(&config_str).set_nested(nested)), 250 | #[cfg(feature = "toml")] 251 | "toml" => self.merge(Toml::string(&config_str).set_nested(nested)), 252 | #[cfg(feature = "json")] 253 | "json" => self.merge(Json::string(&config_str).set_nested(nested)), 254 | _ => { 255 | let file_name = path.as_ref().file_name().unwrap().to_string_lossy().to_string(); 256 | return Error::UnknownExtension(file_name.clone(), (file_name.rfind(file_extension).unwrap(), file_extension.len()).into()).pipe(Err); 257 | } 258 | } 259 | .pipe(Ok); 260 | } 261 | 262 | self.pipe(Ok) 263 | } 264 | 265 | fn join_from_path(self, path: impl AsRef, mut nested: bool, mapping: HashMap) -> Result 266 | where 267 | Self: std::marker::Sized, 268 | { 269 | let config_str = fs::read_to_string(&path).map_err(|e| Error::ReadingConfig(path.as_ref().to_path_buf(), e))?; 270 | if !config_str.is_empty() { 271 | let file_extension = &*path.as_ref().extension().unwrap().to_string_lossy(); 272 | 273 | if nested { 274 | let profiles = match file_extension { 275 | #[cfg(feature = "yaml")] 276 | "yaml" | "yml" => serde_yaml::from_str::>(&config_str).unwrap().pipe(Ok), 277 | #[cfg(feature = "toml")] 278 | "toml" => serde_toml::from_str::>(&config_str).unwrap().pipe(Ok), 279 | #[cfg(feature = "json")] 280 | "json" => serde_json::from_str::>(&config_str).unwrap().pipe(Ok), 281 | _ => { 282 | let file_name = path.as_ref().file_name().unwrap().to_string_lossy().to_string(); 283 | return Error::UnknownExtension(file_name.clone(), (file_name.rfind(file_extension).unwrap(), file_extension.len()).into()).pipe(Err); 284 | } 285 | }? 286 | .into_iter() 287 | .map(|(k, _)| k) 288 | .collect::>(); 289 | 290 | if profiles.contains(&Profile::Default.to_string().to_lowercase()) { 291 | Error::RepoConfigDefaultProfile.pipe(Err)?; 292 | } 293 | 294 | nested = profiles.iter().any(|p| Profiles::try_from(p.as_str()).is_ok() || os::Os::try_from(p.as_str()).is_ok()); 295 | } 296 | 297 | match file_extension { 298 | #[cfg(feature = "yaml")] 299 | "yaml" | "yml" => { 300 | return self 301 | .join(MappedProfileProvider { 302 | mapping, 303 | provider: Yaml::string(&config_str).set_nested(nested), 304 | }) 305 | .pipe(Ok); 306 | } 307 | #[cfg(feature = "toml")] 308 | "toml" => { 309 | return self 310 | .join(MappedProfileProvider { 311 | mapping, 312 | provider: Toml::string(&config_str).set_nested(nested), 313 | }) 314 | .pipe(Ok); 315 | } 316 | #[cfg(feature = "json")] 317 | "json" => { 318 | return self 319 | .join(MappedProfileProvider { 320 | mapping, 321 | provider: Json::string(&config_str).set_nested(nested), 322 | }) 323 | .pipe(Ok); 324 | } 325 | _ => { 326 | let file_name = path.as_ref().file_name().unwrap().to_string_lossy().to_string(); 327 | return Error::UnknownExtension(file_name.clone(), (file_name.rfind(file_extension).unwrap(), file_extension.len()).into()).pipe(Err); 328 | } 329 | } 330 | } 331 | 332 | self.pipe(Ok) 333 | } 334 | } 335 | 336 | #[cfg(test)] 337 | mod test; 338 | -------------------------------------------------------------------------------- /src/state/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Debug, fs, path::PathBuf}; 2 | 3 | use miette::Diagnostic; 4 | use serde::{Deserialize, Serialize}; 5 | use tap::Pipe; 6 | #[cfg(feature = "profiling")] 7 | use tracing::instrument; 8 | 9 | use crate::{FILE_EXTENSIONS, FileFormat, PROJECT_DIRS, helpers}; 10 | 11 | #[derive(thiserror::Error, Diagnostic, Debug)] 12 | pub(crate) enum Error { 13 | #[error("Could not read state file")] 14 | #[diagnostic(code(state::read))] 15 | Reading(#[source] std::io::Error), 16 | 17 | #[error("Could not write state file")] 18 | #[diagnostic(code(state::write))] 19 | Writing(#[source] std::io::Error), 20 | 21 | #[error("Could not serialize state")] 22 | #[diagnostic(code(state::serialize))] 23 | Serializing( 24 | #[source] 25 | #[diagnostic_source] 26 | helpers::ParseError, 27 | ), 28 | 29 | #[error("Could not deserialize state")] 30 | #[diagnostic(code(state::deserialize))] 31 | Deserializing( 32 | #[source] 33 | #[diagnostic_source] 34 | helpers::ParseError, 35 | ), 36 | } 37 | 38 | #[derive(Serialize, Deserialize, Default, Debug)] 39 | #[serde(transparent)] 40 | pub(crate) struct Linked(pub HashMap>); 41 | 42 | impl From>> for Linked { 43 | fn from(value: HashMap>) -> Self { 44 | Self(value) 45 | } 46 | } 47 | 48 | #[derive(Serialize, Deserialize, Default, Debug)] 49 | pub(crate) struct State { 50 | pub linked: Linked, 51 | } 52 | 53 | impl State { 54 | #[cfg_attr(feature = "profiling", instrument)] 55 | pub fn read() -> Result { 56 | let state_file = helpers::get_file_with_format(PROJECT_DIRS.data_local_dir(), "state"); 57 | 58 | if let Some((state_file, format)) = state_file { 59 | deserialize_state(&fs::read_to_string(state_file).map_err(Error::Reading)?, format).map_err(Error::Deserializing)? 60 | } else { 61 | State::default() 62 | } 63 | .pipe(Ok) 64 | } 65 | 66 | #[cfg_attr(feature = "profiling", instrument)] 67 | pub fn write(&self) -> Result<(), Error> { 68 | let state_file = 69 | helpers::get_file_with_format(PROJECT_DIRS.data_local_dir(), "state").unwrap_or_else(|| (PROJECT_DIRS.data_local_dir().join(format!("state.{}", FILE_EXTENSIONS[0].0)), FILE_EXTENSIONS[0].1)); 70 | 71 | fs::create_dir_all(PROJECT_DIRS.data_local_dir()).map_err(Error::Writing)?; 72 | fs::write(state_file.0, serialize_state(self, state_file.1).map_err(Error::Serializing)?).map_err(Error::Writing) 73 | } 74 | } 75 | 76 | #[cfg_attr(feature = "profiling", instrument)] 77 | fn deserialize_state(state: &str, format: FileFormat) -> Result { 78 | Ok(match format { 79 | #[cfg(feature = "yaml")] 80 | FileFormat::Yaml => serde_yaml::from_str(state)?, 81 | #[cfg(feature = "toml")] 82 | FileFormat::Toml => serde_toml::from_str(state)?, 83 | #[cfg(feature = "json")] 84 | FileFormat::Json => serde_json::from_str(state)?, 85 | }) 86 | } 87 | 88 | #[cfg_attr(feature = "profiling", instrument)] 89 | fn serialize_state(state: &(impl Serialize + Debug), format: FileFormat) -> Result { 90 | Ok(match format { 91 | #[cfg(feature = "yaml")] 92 | FileFormat::Yaml => serde_yaml::to_string(state)?, 93 | #[cfg(feature = "toml")] 94 | FileFormat::Toml => serde_toml::to_string(state)?, 95 | #[cfg(feature = "json")] 96 | FileFormat::Json => serde_json::to_string(state)?, 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /src/templating/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Debug, path::PathBuf, sync::LazyLock}; 2 | 3 | use directories::BaseDirs; 4 | use handlebars::{Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderError, RenderErrorReason, Renderable, ScopedJson}; 5 | use itertools::Itertools; 6 | use miette::Diagnostic; 7 | use serde::Serialize; 8 | use tap::{Conv, Pipe}; 9 | #[cfg(feature = "profiling")] 10 | use tracing::instrument; 11 | use velcro::hash_map; 12 | 13 | use crate::{ 14 | USER_DIRS, 15 | cli::Cli, 16 | config::Config, 17 | helpers::{self, os}, 18 | }; 19 | 20 | pub static ENV: LazyLock> = LazyLock::new(|| std::env::vars().collect()); 21 | 22 | #[derive(thiserror::Error, Diagnostic, Debug)] 23 | pub enum Error { 24 | #[error("Could not render templeate")] 25 | #[diagnostic(code(template::render))] 26 | RenderingTemplate(#[source] handlebars::RenderError), 27 | 28 | #[error("Could not parse eval command")] 29 | #[diagnostic(code(template::eval::parse))] 30 | ParseEvalCommand(#[source] shellwords::MismatchedQuotes), 31 | 32 | #[error("Eval command did not run successfully")] 33 | #[diagnostic(code(template::eval::run))] 34 | RunEvalCommand( 35 | #[source] 36 | #[diagnostic_source] 37 | helpers::RunError, 38 | ), 39 | } 40 | 41 | #[derive(Serialize, Debug)] 42 | pub struct Parameters<'a> { 43 | pub config: &'a Config, 44 | pub name: &'a str, 45 | } 46 | 47 | #[derive(Serialize, Debug)] 48 | pub struct WhoamiPrameters { 49 | pub realname: String, 50 | pub username: String, 51 | pub lang: Vec, 52 | pub devicename: String, 53 | pub hostname: Option, 54 | pub platform: String, 55 | pub distro: String, 56 | pub desktop_env: String, 57 | pub arch: String, 58 | } 59 | 60 | pub static WHOAMI_PRAMETERS: LazyLock = LazyLock::new(|| WhoamiPrameters { 61 | realname: whoami::realname(), 62 | username: whoami::username(), 63 | lang: whoami::langs().map(|l| l.map(|l| l.to_string()).collect_vec()).unwrap_or_default(), 64 | devicename: whoami::devicename(), 65 | hostname: whoami::fallible::hostname().ok(), 66 | platform: whoami::platform().to_string(), 67 | distro: whoami::distro(), 68 | desktop_env: whoami::desktop_env().to_string(), 69 | arch: whoami::arch().to_string(), 70 | }); 71 | 72 | #[derive(Serialize, Debug)] 73 | pub struct DirectoryPrameters { 74 | pub base: HashMap<&'static str, PathBuf>, 75 | pub user: HashMap<&'static str, PathBuf>, 76 | } 77 | 78 | pub static DIRECTORY_PRAMETERS: LazyLock = LazyLock::new(|| { 79 | let mut base: HashMap<&'static str, PathBuf> = HashMap::new(); 80 | 81 | if let Some(dirs) = BaseDirs::new() { 82 | base.insert("cache", dirs.cache_dir().to_path_buf()); 83 | base.insert("config", dirs.config_dir().to_path_buf()); 84 | base.insert("data", dirs.data_dir().to_path_buf()); 85 | base.insert("data_local", dirs.data_local_dir().to_path_buf()); 86 | base.insert("home", dirs.home_dir().to_path_buf()); 87 | base.insert("preference", dirs.preference_dir().to_path_buf()); 88 | if let Some(dir) = dirs.executable_dir() { 89 | base.insert("executable", dir.to_path_buf()); 90 | } 91 | if let Some(dir) = dirs.runtime_dir() { 92 | base.insert("runtime", dir.to_path_buf()); 93 | } 94 | if let Some(dir) = dirs.state_dir() { 95 | base.insert("state", dir.to_path_buf()); 96 | } 97 | } 98 | 99 | let mut user: HashMap<&'static str, PathBuf> = HashMap::new(); 100 | 101 | user.insert("home", USER_DIRS.home_dir().to_path_buf()); 102 | if let Some(dir) = USER_DIRS.audio_dir() { 103 | user.insert("audio", dir.to_path_buf()); 104 | } 105 | if let Some(dir) = USER_DIRS.desktop_dir() { 106 | user.insert("desktop", dir.to_path_buf()); 107 | } 108 | if let Some(dir) = USER_DIRS.document_dir() { 109 | user.insert("document", dir.to_path_buf()); 110 | } 111 | if let Some(dir) = USER_DIRS.download_dir() { 112 | user.insert("download", dir.to_path_buf()); 113 | } 114 | if let Some(dir) = USER_DIRS.font_dir() { 115 | user.insert("font", dir.to_path_buf()); 116 | } 117 | if let Some(dir) = USER_DIRS.picture_dir() { 118 | user.insert("picture", dir.to_path_buf()); 119 | } 120 | if let Some(dir) = USER_DIRS.public_dir() { 121 | user.insert("public", dir.to_path_buf()); 122 | } 123 | if let Some(dir) = USER_DIRS.template_dir() { 124 | user.insert("template", dir.to_path_buf()); 125 | } 126 | if let Some(dir) = USER_DIRS.video_dir() { 127 | user.insert("video", dir.to_path_buf()); 128 | } 129 | 130 | DirectoryPrameters { base, user } 131 | }); 132 | 133 | #[derive(Serialize, Debug)] 134 | struct CompleteParameters<'a, T> { 135 | #[serde(flatten)] 136 | pub parameters: &'a T, 137 | pub env: &'a HashMap, 138 | pub os: &'a str, 139 | pub whoami: &'static WhoamiPrameters, 140 | pub dirs: &'static DirectoryPrameters, 141 | } 142 | 143 | pub(crate) struct Engine<'a>(Handlebars<'a>); 144 | 145 | impl<'b> Engine<'b> { 146 | #[cfg_attr(feature = "profiling", instrument)] 147 | pub fn new<'a>(config: &'a Config, cli: &'a Cli) -> Engine<'b> { 148 | let mut hb = handlebars_misc_helpers::new_hbs::<'b>(); 149 | hb.set_strict_mode(false); 150 | 151 | hb.register_helper("windows", WindowsHelper.conv::>()); 152 | hb.register_helper("linux", LinuxHelper.conv::>()); 153 | hb.register_helper("darwin", DarwinHelper.conv::>()); 154 | 155 | hb.register_helper( 156 | "eval", 157 | EvalHelper { 158 | shell_command: config.shell_command.clone(), 159 | dry_run: cli.dry_run, 160 | } 161 | .pipe(Box::new), 162 | ); 163 | 164 | Self(hb) 165 | } 166 | 167 | #[cfg_attr(feature = "profiling", instrument(skip(self)))] 168 | pub fn render(&self, template: &str, parameters: &(impl Serialize + Debug)) -> Result { 169 | let complete = CompleteParameters { 170 | parameters, 171 | env: &ENV, 172 | whoami: &WHOAMI_PRAMETERS, 173 | os: &helpers::os::OS.to_string().to_ascii_lowercase(), 174 | dirs: &DIRECTORY_PRAMETERS, 175 | }; 176 | self.render_template(template, &complete).map_err(Error::RenderingTemplate) 177 | } 178 | 179 | #[cfg_attr(feature = "profiling", instrument(skip(self)))] 180 | pub fn render_template(&self, template_string: &str, data: &(impl Serialize + Debug)) -> Result { 181 | self.0.render_template(template_string, data) 182 | } 183 | } 184 | 185 | pub struct WindowsHelper; 186 | 187 | impl HelperDef for WindowsHelper { 188 | #[cfg_attr(feature = "profiling", instrument(skip(self, out)))] 189 | fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult { 190 | if os::OS.is_windows() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r) 191 | } 192 | } 193 | 194 | pub struct LinuxHelper; 195 | 196 | impl HelperDef for LinuxHelper { 197 | #[cfg_attr(feature = "profiling", instrument(skip(self, out)))] 198 | fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult { 199 | if os::OS.is_linux() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r) 200 | } 201 | } 202 | 203 | pub struct DarwinHelper; 204 | 205 | impl HelperDef for DarwinHelper { 206 | #[cfg_attr(feature = "profiling", instrument(skip(self, out)))] 207 | fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult { 208 | if os::OS.is_darwin() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r) 209 | } 210 | } 211 | 212 | pub struct EvalHelper { 213 | shell_command: Option, 214 | dry_run: bool, 215 | } 216 | 217 | impl HelperDef for EvalHelper { 218 | #[cfg_attr(feature = "profiling", instrument(skip(self)))] 219 | fn call_inner<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, _: &'rc Context, _: &mut RenderContext<'reg, 'rc>) -> Result, RenderError> { 220 | let cmd = h 221 | .param(0) 222 | .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("eval", 0))? 223 | .value() 224 | .as_str() 225 | .ok_or_else(|| RenderErrorReason::InvalidParamType("String"))?; 226 | 227 | if self.dry_run { 228 | format!("{{{{ eval \"{cmd}\" }}}}").conv::().conv::().pipe(Ok) 229 | } else { 230 | let cmd = if let Some(shell_command) = self.shell_command.as_ref() { 231 | r.render_template(shell_command, &hash_map! { "cmd": &cmd })? 232 | } else { 233 | cmd.to_owned() 234 | }; 235 | 236 | let cmd = shellwords::split(&cmd).map_err(|e| RenderErrorReason::NestedError(Box::new(Error::ParseEvalCommand(e))))?; 237 | 238 | match helpers::run_command(&cmd[0], &cmd[1..], true, false) { 239 | Err(err) => RenderErrorReason::NestedError(Box::new(Error::RunEvalCommand(err))).conv::().pipe(Err), 240 | Ok(result) => result.trim().conv::().conv::().pipe(Ok), 241 | } 242 | } 243 | } 244 | } 245 | 246 | #[cfg(test)] 247 | pub mod test; 248 | -------------------------------------------------------------------------------- /src/templating/test/data/dotfiles01/test01/dot.yaml: -------------------------------------------------------------------------------- 1 | installs: {{ file_name name }} -------------------------------------------------------------------------------- /src/templating/test/data/dotfiles01/test01/test02/dot.yaml: -------------------------------------------------------------------------------- 1 | installs: {{ file_name name }} -------------------------------------------------------------------------------- /src/templating/test/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::needless_borrows_for_generic_args)] 2 | use std::path::Path; 3 | 4 | use figment::{util::map, value}; 5 | use rstest::rstest; 6 | use speculoos::prelude::*; 7 | 8 | use super::{Engine, Parameters}; 9 | use crate::{ 10 | cli::{Cli, Command, PathBuf}, 11 | config::{Config, LinkType}, 12 | dot::read_dots, 13 | helpers::os, 14 | }; 15 | 16 | pub(crate) fn get_handlebars<'a>() -> Engine<'a> { 17 | let cli = Cli { 18 | dry_run: true, 19 | dotfiles: None, 20 | config: PathBuf("".into()), 21 | command: Command::Clone { repo: String::new() }, 22 | }; 23 | 24 | Engine::new(&Config::default(), &cli) 25 | } 26 | 27 | #[rstest] 28 | #[case("{{ config.variables.test }}", "test")] 29 | #[case("{{ config.variables.nested.nest }}", "nest")] 30 | #[case("{{ whoami.username }}", &whoami::username())] 31 | #[case("{{ dirs.user.home }}", &directories::UserDirs::new().unwrap().home_dir().to_string_lossy().to_string())] 32 | #[case("{{ os }}", &crate::helpers::os::OS.to_string().to_ascii_lowercase())] 33 | fn templating(#[case] template: &str, #[case] expected: &str) { 34 | let config = Config { 35 | dotfiles: "dotfiles".into(), 36 | link_type: LinkType::Hard, 37 | shell_command: "shell_command".to_owned().into(), 38 | variables: map! { 39 | "test".to_owned() => "test".into(), 40 | "nested".to_owned() => map!{ 41 | "nest" => value::Value::from("nest") 42 | }.into() 43 | }, 44 | }; 45 | 46 | let cli = Cli { 47 | dry_run: true, 48 | dotfiles: None, 49 | config: PathBuf("".into()), 50 | command: Command::Clone { repo: String::new() }, 51 | }; 52 | 53 | assert_that!(Engine::new(&config, &cli).render(template, &Parameters { config: &config, name: "name" }).unwrap()).is_equal_to(expected.to_owned()); 54 | } 55 | 56 | #[test] 57 | fn name() { 58 | let dots = read_dots( 59 | Path::new(file!()).parent().unwrap().join("data/dotfiles01").as_path(), 60 | &["/**".to_owned()], 61 | &Default::default(), 62 | &get_handlebars(), 63 | ) 64 | .unwrap(); 65 | assert_that!(dots.iter().find(|d| d.0 == "/test01/test02")) 66 | .is_some() 67 | .map(|d| &d.1.installs) 68 | .is_some() 69 | .map(|i| &i.cmd) 70 | .is_equal_to(&"test02".into()); 71 | } 72 | 73 | #[test] 74 | fn os_helpers() { 75 | let config = Config::default(); 76 | 77 | assert_that!( 78 | get_handlebars() 79 | .render( 80 | "{{ #windows }}windows{{ /windows }}{{ #linux }}linux{{ /linux }}{{ #darwin }}darwin{{ /darwin }}", 81 | &Parameters { config: &config, name: "" } 82 | ) 83 | .unwrap() 84 | ) 85 | .is_equal_to(os::OS.to_string().to_ascii_lowercase()); 86 | } 87 | 88 | #[test] 89 | fn os_else_helpers() { 90 | let config = Config::default(); 91 | 92 | let mut expected = String::new(); 93 | if !os::OS.is_windows() { 94 | expected += "else_windows"; 95 | } 96 | if !os::OS.is_linux() { 97 | expected += "else_linux"; 98 | } 99 | if !os::OS.is_darwin() { 100 | expected += "else_darwin"; 101 | } 102 | assert_that!( 103 | get_handlebars() 104 | .render( 105 | "{{ #windows }}{{ else }}else_windows{{ /windows }}{{ #linux }}{{ else }}else_linux{{ /linux }}{{ #darwin }}{{ else }}else_darwin{{ /darwin }}", 106 | &Parameters { config: &config, name: "" } 107 | ) 108 | .unwrap() 109 | ) 110 | .is_equal_to(expected); 111 | } 112 | 113 | #[test] 114 | fn eval_helper() { 115 | let config = Config::default(); 116 | 117 | let cli = Cli { 118 | dry_run: false, 119 | dotfiles: None, 120 | config: PathBuf("".into()), 121 | command: Command::Clone { repo: String::new() }, 122 | }; 123 | 124 | assert_that!(Engine::new(&config, &cli).render("{{ eval \"echo 'test'\" }}", &Parameters { config: &config, name: "" }).unwrap()).is_equal_to("test".to_owned()); 125 | } 126 | -------------------------------------------------------------------------------- /src/test/data/config/config.yaml: -------------------------------------------------------------------------------- 1 | variables: 2 | test01: yaml 3 | -------------------------------------------------------------------------------- /src/test/data/dotfiles01/config.toml: -------------------------------------------------------------------------------- 1 | [global.variables] 2 | test01 = "toml" 3 | test02 = "toml" 4 | -------------------------------------------------------------------------------- /src/test/data/dotfiles02/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "global": { 3 | "variables": { 4 | "test01": "json", 5 | "test02": "json" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/test/data/dotfiles03/config.yaml: -------------------------------------------------------------------------------- 1 | force: 2 | variables: 3 | test02: force 4 | -------------------------------------------------------------------------------- /src/test/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::needless_borrows_for_generic_args)] 2 | use std::path::Path; 3 | 4 | use rstest::rstest; 5 | use speculoos::assert_that; 6 | 7 | use crate::cli::Cli; 8 | 9 | #[rstest] 10 | #[case("dotfiles01", "toml")] 11 | #[case("dotfiles02", "json")] 12 | #[case("dotfiles03", "force")] 13 | fn read_config_formats(#[case] dotfiles_path: &str, #[case] expexted: &str) { 14 | let mut cli = Cli { 15 | dry_run: true, 16 | command: crate::cli::Command::Init { repo: None }, 17 | config: Path::new(file!()).parent().unwrap().join("data/config/config.yaml").into(), 18 | dotfiles: Some(Path::new(file!()).parent().unwrap().join("data").into()), 19 | }; 20 | 21 | let config = super::read_config(&cli).unwrap(); 22 | 23 | assert_that!(config.variables["test01"]).is_equal_to(&"yaml".into()); 24 | 25 | cli.dotfiles = Some(Path::new(file!()).parent().unwrap().join("data").join(dotfiles_path).into()); 26 | 27 | let config = super::read_config(&cli).unwrap(); 28 | 29 | assert_that!(config.variables["test01"]).is_equal_to(&"yaml".into()); 30 | assert_that!(config.variables["test02"]).is_equal_to(&expexted.into()); 31 | } 32 | --------------------------------------------------------------------------------