├── .github ├── dependabot.yml └── workflows │ ├── cargo-audit.yml │ ├── cargo-bloat.yml │ ├── ci.yml │ ├── gh-pages.yml │ └── releases.yml ├── .gitignore ├── .yamllint ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── RELEASING.md ├── bors.toml ├── examples ├── exec.toml ├── simple.toml └── template.toml ├── hack ├── cargo-release-hook.sh ├── check-license-headers.sh ├── deploy-github-pages.sh ├── extract-current-changelog.py └── git │ └── hooks │ └── pre-commit ├── release.toml ├── resources └── project_template.toml ├── src ├── cli.rs ├── configfiles.rs ├── errors.rs ├── layouts.rs ├── lib.rs ├── main.rs ├── projects.rs ├── shlex.rs └── types.rs └── tests ├── projects.rs └── types.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: '04:00' 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/cargo-audit.yml: -------------------------------------------------------------------------------- 1 | name: cargo-audit 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**/Cargo.toml' 7 | - '**/Cargo.lock' 8 | 9 | jobs: 10 | cargo-audit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: cargo audit 16 | # TODO: reenable the "offical" audit-check once its core-version is upgraded 17 | # uses: actions-rs/audit-check@v1 18 | uses: pitkley/actions-rs-audit-check@temp/use-own-core 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/cargo-bloat.yml: -------------------------------------------------------------------------------- 1 | name: cargo-bloat 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - '**/Cargo.toml' 12 | - '**/Cargo.lock' 13 | 14 | jobs: 15 | cargo-bloat: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: Retrieve cache 21 | uses: actions/cache@v2 22 | with: 23 | path: | 24 | ~/.cargo/registry 25 | ~/.cargo/git 26 | target 27 | key: ${{ runner.os }}-cargo-bloat-${{ hashFiles('**/Cargo.lock') }} 28 | 29 | - name: Install Rust toolchain 30 | uses: actions-rs/toolchain@v1 31 | with: 32 | profile: minimal 33 | toolchain: stable 34 | override: true 35 | 36 | - name: cargo bloat 37 | uses: orf/cargo-bloat-action@v1 38 | with: 39 | token: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - staging 8 | - trying 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | check-license-headers: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: Check license headers 21 | run: | 22 | ./hack/check-license-headers.sh 23 | 24 | rustfmt: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | - name: Install Rust toolchain 31 | uses: actions-rs/toolchain@v1 32 | with: 33 | profile: minimal 34 | toolchain: stable 35 | override: true 36 | components: rustfmt 37 | 38 | - name: cargo fmt 39 | uses: actions-rs/cargo@v1 40 | with: 41 | command: fmt 42 | args: --all -- --check 43 | 44 | clippy: 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v2 50 | - name: Install Rust toolchain 51 | uses: actions-rs/toolchain@v1 52 | with: 53 | profile: minimal 54 | toolchain: stable 55 | override: true 56 | components: clippy 57 | 58 | - name: cargo clippy 59 | uses: actions-rs/clippy-check@v1 60 | with: 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | args: --lib --bins --tests --all-targets -- -Dwarnings 63 | 64 | yamllint: 65 | runs-on: ubuntu-latest 66 | 67 | steps: 68 | - name: Checkout 69 | uses: actions/checkout@v2 70 | - name: yamllint 71 | uses: ibiqlik/action-yamllint@v1.0.0 72 | 73 | build-and-test: 74 | runs-on: ubuntu-latest 75 | continue-on-error: ${{ matrix.continue-on-error }} 76 | 77 | strategy: 78 | fail-fast: false 79 | matrix: 80 | rust: 81 | - stable 82 | - beta 83 | - # MSRV 84 | 1.56.1 85 | target: 86 | - "" 87 | continue-on-error: 88 | - false 89 | 90 | include: 91 | - rust: stable 92 | target: "x86_64-unknown-linux-musl" 93 | continue-on-error: false 94 | - rust: nightly 95 | target: "" 96 | continue-on-error: true 97 | 98 | steps: 99 | - name: Checkout 100 | uses: actions/checkout@v2 101 | - name: Retrieve cache 102 | uses: actions/cache@v2 103 | with: 104 | path: | 105 | ~/.cargo/registry 106 | ~/.cargo/git 107 | target 108 | key: ${{ runner.os }}-cargo-ci-${{ hashFiles('**/Cargo.lock') }} 109 | - name: Install Rust toolchain 110 | uses: actions-rs/toolchain@v1 111 | with: 112 | profile: minimal 113 | toolchain: ${{ matrix.rust }} 114 | override: true 115 | 116 | - name: cargo build 117 | uses: actions-rs/cargo@v1 118 | with: 119 | command: build 120 | env: 121 | TARGET: ${{ matrix.target }} 122 | - name: cargo test 123 | uses: actions-rs/cargo@v1 124 | with: 125 | command: test 126 | args: -- --nocapture 127 | env: 128 | TARGET: ${{ matrix.target }} 129 | - name: cargo test --features sequential-tests 130 | uses: actions-rs/cargo@v1 131 | with: 132 | command: test 133 | args: --features sequential-tests -- --nocapture 134 | env: 135 | RUST_TEST_THREADS: 1 136 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | docs: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 1 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | - name: Retrieve cache 20 | uses: actions/cache@v2 21 | with: 22 | path: | 23 | ~/.cargo/registry 24 | ~/.cargo/git 25 | target 26 | key: ${{ runner.os }}-cargo-doc-${{ hashFiles('**/Cargo.lock') }} 27 | - name: Checkout existing gh-pages branch 28 | uses: actions/checkout@v2 29 | with: 30 | ref: gh-pages 31 | path: gh-pages 32 | 33 | - name: Get latest tag 34 | id: tag 35 | uses: jimschubert/query-tag-action@v1 36 | with: 37 | commit-ish: HEAD 38 | - name: Get a pretty-printed version of the current branch or tag 39 | id: branch-or-tag 40 | run: | 41 | REF="$( 42 | git symbolic-ref -q --short HEAD \ 43 | || git describe --tags --exact-match 44 | )" 45 | if [ -z "${REF}" ]; then 46 | echo "No ref available" >&2 47 | exit 1 48 | fi 49 | echo "::set-output name=value::${REF}" 50 | - name: Identify if tag is a prerelease 51 | id: tag-prerelease 52 | run: | 53 | if [[ "${{ steps.branch-or-tag.outputs.value }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 54 | echo "::set-output name=value::false" 55 | else 56 | echo "::set-output name=value::true" 57 | fi 58 | 59 | - name: Create/update symlink for `/latest` to latest tag 60 | if: ${{ steps.tag-prerelease.outputs.value == 'false' }} 61 | run: | 62 | rm gh-pages/latest || : 63 | ln -sf "${{ steps.tag.outputs.tag }}" gh-pages/latest 64 | 65 | - name: Install Rust toolchain 66 | uses: actions-rs/toolchain@v1 67 | with: 68 | profile: minimal 69 | toolchain: stable 70 | override: true 71 | - name: cargo doc 72 | uses: actions-rs/cargo@v1 73 | with: 74 | command: doc 75 | args: --no-deps 76 | - name: Move generated docs to the correct location 77 | run: | 78 | rm -rf "gh-pages/${{ steps.branch-or-tag.outputs.value }}" || : 79 | mv target/doc "gh-pages/${{ steps.branch-or-tag.outputs.value }}" 80 | 81 | - name: Deploy documentation to GitHub pages 82 | if: success() 83 | working-directory: ./gh-pages 84 | run: ../hack/deploy-github-pages.sh 85 | env: 86 | COMMIT_MESSAGE: "Deploy documentation for ${{ steps.branch-or-tag.outputs.value }} to GitHub pages" 87 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: Prepare release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | - '[0-9]+.[0-9]+.[0-9]+-*' 8 | 9 | jobs: 10 | build-and-publish: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Retrieve cache 17 | uses: actions/cache@v2 18 | with: 19 | path: | 20 | ~/.cargo/registry 21 | ~/.cargo/git 22 | target 23 | key: ${{ runner.os }}-cargo-ci-${{ hashFiles('**/Cargo.lock') }} 24 | - name: Install Rust toolchain 25 | uses: actions-rs/toolchain@v1 26 | with: 27 | profile: minimal 28 | toolchain: stable 29 | override: true 30 | 31 | - name: cargo build --release 32 | uses: actions-rs/cargo@v1 33 | with: 34 | command: build 35 | args: --release 36 | env: 37 | TARGET: x86_64-unknown-linux-musl 38 | - name: Create checksum 39 | run: | 40 | sha256sum target/release/i3nator > target/release/i3nator.sha256 41 | 42 | - name: Identify if tag is a prerelease 43 | id: tag 44 | run: | 45 | if [[ "${{ github.ref }}" =~ ^refs/tags/(.+)$ ]]; then 46 | echo "::set-output name=value::${BASH_REMATCH[1]}" 47 | else 48 | echo "::error ::Expected a tag" 49 | exit 1 50 | fi 51 | 52 | if [[ "${{ github.ref }}" =~ ^refs/tags/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 53 | echo "::set-output name=is-prerelease::false" 54 | else 55 | echo "::set-output name=is-prerelease::true" 56 | fi 57 | - name: Extract current changelog 58 | id: changelog 59 | run: 60 | | 61 | changelog="$(hack/extract-current-changelog.py CHANGELOG.md)" 62 | # https://github.community/t/set-output-truncates-multiline-strings/16852/3 63 | changelog="${changelog//'%'/'%25'}" 64 | changelog="${changelog//$'\n'/'%0A'}" 65 | changelog="${changelog//$'\r'/'%0D'}" 66 | 67 | echo "::set-output name=value::$changelog" 68 | 69 | - name: Prepare release 70 | id: prepare-release 71 | uses: actions/create-release@v1 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | with: 75 | tag_name: ${{ github.ref }} 76 | release_name: v${{ github.ref }} 77 | draft: true 78 | prerelease: ${{ steps.tag.outputs.is-prerelease }} 79 | body: | 80 | # Summary 81 | 82 | TODO! 83 | 84 | ## Changes 85 | 86 | ${{ steps.changelog.outputs.value }} 87 | 88 | ## Installation 89 | 90 | You have multiple options to install i3nator: 91 | 92 | 1. Download the static binary from this release. This will work without any additional dependencies. 93 | 94 | 2. If you have at least Rust 1.56.1 with Cargo installed, you can install i3nator directly from crates.io: 95 | 96 | ```console 97 | $ cargo install i3nator 98 | ``` 99 | 100 | If you are updating i3nator through cargo, just use: 101 | 102 | ```console 103 | $ cargo install --force i3nator 104 | ``` 105 | 106 | In case you want to install this specific version, you can specify the `--version` argument: 107 | 108 | ```console 109 | $ cargo install --force --version ${{ steps.tag.outputs.value }} i3nator 110 | ``` 111 | 112 | 3. Another option is to install from directly from source (this requires at least Rust 1.56.1): 113 | 114 | ```console 115 | $ git clone https://github.com/pitkley/i3nator.git 116 | $ cd i3nator 117 | $ cargo install 118 | ``` 119 | 120 | **Note:** If you want to be able to use the automatic command execution feature, you will need to install [`xdotool`][xdotool]. 121 | 122 | [xdotool]: https://github.com/jordansissel/xdotool 123 | 124 | - name: Upload static i3nator binary 125 | uses: actions/upload-release-asset@v1 126 | env: 127 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 128 | with: 129 | upload_url: ${{ steps.prepare-release.outputs.upload_url }} 130 | asset_path: target/release/i3nator 131 | asset_name: i3nator 132 | asset_content_type: application/octet-stream 133 | - name: Upload static i3nator binary checksum 134 | uses: actions/upload-release-asset@v1 135 | env: 136 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 137 | with: 138 | upload_url: ${{ steps.prepare-release.outputs.upload_url }} 139 | asset_path: target/release/i3nator.sha256 140 | asset_name: i3nator.sha256 141 | asset_content_type: application/octet-stream 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | 4 | target 5 | 6 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | extends: default 2 | 3 | rules: 4 | comments: disable 5 | comments-indentation: disable 6 | document-start: disable 7 | indentation: 8 | spaces: 2 9 | indent-sequences: false 10 | line-length: 11 | max: 999 12 | level: warning 13 | truthy: 14 | ignore: | 15 | .github/workflows/*.yml 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## Unreleased 6 | 7 | * You can now interact with projects through the `i3nator project` subcommand. 8 | 9 | The commands available are identical to the "root"-commands, e.g. instead of `i3nator copy` you can now use `i3nator project copy`. 10 | Both styles of invocation are fully supported, you can decide which fits you better! 11 | 12 | * Compatibility: the minimum supported Rust version is now 1.56.1, you will not be able to compile i3nator with older versions. 13 | 14 | (Please note that this does not affect how or where you can run the pre-built binary.) 15 | 16 | Internal changes: dependency updates. 17 | 18 | ## 1.2.0 (2020-07-13) 19 | 20 | * Compatibility: the minimum supported Rust version is now 1.38.0, you will not be able to compile i3nator with older versions. 21 | 22 | (Please note that this does not affect how or where you can run the pre-built binary.) 23 | 24 | Internal changes: dependency updates, move CI to GitHub Actions. 25 | 26 | ## 1.1.0 (2017-06-08) 27 | 28 | * Feature: Verify paths in configuration exist 29 | * Feature: Added layout managing 30 | * Fix: Expand tilde for layout-path 31 | 32 | ## 1.0.0 (2017-05-31) 33 | 34 | This release is fully featured, everything that is mentioned in the README is implemented. Additionally, the command line interface is considered stable and will only have breaking changes with either a major or a minor version bump (still to be determined). 35 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.17.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "atty" 22 | version = "0.2.14" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 25 | dependencies = [ 26 | "hermit-abi", 27 | "libc", 28 | "winapi", 29 | ] 30 | 31 | [[package]] 32 | name = "autocfg" 33 | version = "1.1.0" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 36 | 37 | [[package]] 38 | name = "backtrace" 39 | version = "0.3.64" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "5e121dee8023ce33ab248d9ce1493df03c3b38a659b240096fcbd7048ff9c31f" 42 | dependencies = [ 43 | "addr2line", 44 | "cc", 45 | "cfg-if", 46 | "libc", 47 | "miniz_oxide", 48 | "object", 49 | "rustc-demangle", 50 | ] 51 | 52 | [[package]] 53 | name = "bitflags" 54 | version = "1.3.2" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 57 | 58 | [[package]] 59 | name = "byteorder" 60 | version = "1.4.3" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 63 | 64 | [[package]] 65 | name = "cc" 66 | version = "1.0.73" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" 69 | 70 | [[package]] 71 | name = "cfg-if" 72 | version = "1.0.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 75 | 76 | [[package]] 77 | name = "clap" 78 | version = "3.2.15" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "44bbe24bbd31a185bc2c4f7c2abe80bea13a20d57ee4e55be70ac512bdc76417" 81 | dependencies = [ 82 | "atty", 83 | "bitflags", 84 | "clap_derive", 85 | "clap_lex", 86 | "indexmap", 87 | "once_cell", 88 | "strsim", 89 | "termcolor", 90 | "textwrap", 91 | ] 92 | 93 | [[package]] 94 | name = "clap_complete" 95 | version = "3.1.4" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "da92e6facd8d73c22745a5d3cbb59bdf8e46e3235c923e516527d8e81eec14a4" 98 | dependencies = [ 99 | "clap", 100 | ] 101 | 102 | [[package]] 103 | name = "clap_derive" 104 | version = "3.2.15" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "9ba52acd3b0a5c33aeada5cdaa3267cdc7c594a98731d4268cdc1532f4264cb4" 107 | dependencies = [ 108 | "heck", 109 | "proc-macro-error", 110 | "proc-macro2", 111 | "quote", 112 | "syn", 113 | ] 114 | 115 | [[package]] 116 | name = "clap_lex" 117 | version = "0.2.4" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" 120 | dependencies = [ 121 | "os_str_bytes", 122 | ] 123 | 124 | [[package]] 125 | name = "dirs" 126 | version = "4.0.0" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" 129 | dependencies = [ 130 | "dirs-sys", 131 | ] 132 | 133 | [[package]] 134 | name = "dirs-next" 135 | version = "2.0.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 138 | dependencies = [ 139 | "cfg-if", 140 | "dirs-sys-next", 141 | ] 142 | 143 | [[package]] 144 | name = "dirs-sys" 145 | version = "0.3.7" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" 148 | dependencies = [ 149 | "libc", 150 | "redox_users", 151 | "winapi", 152 | ] 153 | 154 | [[package]] 155 | name = "dirs-sys-next" 156 | version = "0.1.2" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 159 | dependencies = [ 160 | "libc", 161 | "redox_users", 162 | "winapi", 163 | ] 164 | 165 | [[package]] 166 | name = "error-chain" 167 | version = "0.12.4" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" 170 | dependencies = [ 171 | "backtrace", 172 | "version_check", 173 | ] 174 | 175 | [[package]] 176 | name = "fastrand" 177 | version = "1.7.0" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" 180 | dependencies = [ 181 | "instant", 182 | ] 183 | 184 | [[package]] 185 | name = "fuchsia-cprng" 186 | version = "0.1.1" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" 189 | 190 | [[package]] 191 | name = "getch" 192 | version = "0.3.1" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "13990e2d5b29e1770ddf7fc000afead4acb9bd8f8a9602de63bf189e261b1ba8" 195 | dependencies = [ 196 | "libc", 197 | "termios", 198 | ] 199 | 200 | [[package]] 201 | name = "getrandom" 202 | version = "0.2.5" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" 205 | dependencies = [ 206 | "cfg-if", 207 | "libc", 208 | "wasi", 209 | ] 210 | 211 | [[package]] 212 | name = "gimli" 213 | version = "0.26.1" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" 216 | 217 | [[package]] 218 | name = "hashbrown" 219 | version = "0.11.2" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 222 | 223 | [[package]] 224 | name = "heck" 225 | version = "0.4.0" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" 228 | 229 | [[package]] 230 | name = "hermit-abi" 231 | version = "0.1.19" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 234 | dependencies = [ 235 | "libc", 236 | ] 237 | 238 | [[package]] 239 | name = "i3ipc" 240 | version = "0.10.1" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "63f3dac00c473fae88cb3114f35312204469a32ffb20874264a5214d6c8c927e" 243 | dependencies = [ 244 | "byteorder", 245 | "log", 246 | "serde", 247 | "serde_json", 248 | ] 249 | 250 | [[package]] 251 | name = "i3nator" 252 | version = "1.3.0-alpha.0" 253 | dependencies = [ 254 | "clap", 255 | "clap_complete", 256 | "dirs-next", 257 | "error-chain", 258 | "getch", 259 | "i3ipc", 260 | "lazy_static", 261 | "serde", 262 | "tempdir", 263 | "tempfile", 264 | "toml", 265 | "wait-timeout", 266 | "xdg", 267 | ] 268 | 269 | [[package]] 270 | name = "indexmap" 271 | version = "1.8.0" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" 274 | dependencies = [ 275 | "autocfg", 276 | "hashbrown", 277 | ] 278 | 279 | [[package]] 280 | name = "instant" 281 | version = "0.1.12" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 284 | dependencies = [ 285 | "cfg-if", 286 | ] 287 | 288 | [[package]] 289 | name = "itoa" 290 | version = "1.0.1" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" 293 | 294 | [[package]] 295 | name = "lazy_static" 296 | version = "1.4.0" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 299 | 300 | [[package]] 301 | name = "libc" 302 | version = "0.2.121" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" 305 | 306 | [[package]] 307 | name = "log" 308 | version = "0.4.14" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 311 | dependencies = [ 312 | "cfg-if", 313 | ] 314 | 315 | [[package]] 316 | name = "memchr" 317 | version = "2.4.1" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 320 | 321 | [[package]] 322 | name = "miniz_oxide" 323 | version = "0.4.4" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" 326 | dependencies = [ 327 | "adler", 328 | "autocfg", 329 | ] 330 | 331 | [[package]] 332 | name = "object" 333 | version = "0.27.1" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "67ac1d3f9a1d3616fd9a60c8d74296f22406a238b6a72f5cc1e6f314df4ffbf9" 336 | dependencies = [ 337 | "memchr", 338 | ] 339 | 340 | [[package]] 341 | name = "once_cell" 342 | version = "1.13.0" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" 345 | 346 | [[package]] 347 | name = "os_str_bytes" 348 | version = "6.0.0" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" 351 | 352 | [[package]] 353 | name = "proc-macro-error" 354 | version = "1.0.4" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 357 | dependencies = [ 358 | "proc-macro-error-attr", 359 | "proc-macro2", 360 | "quote", 361 | "syn", 362 | "version_check", 363 | ] 364 | 365 | [[package]] 366 | name = "proc-macro-error-attr" 367 | version = "1.0.4" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 370 | dependencies = [ 371 | "proc-macro2", 372 | "quote", 373 | "version_check", 374 | ] 375 | 376 | [[package]] 377 | name = "proc-macro2" 378 | version = "1.0.36" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" 381 | dependencies = [ 382 | "unicode-xid", 383 | ] 384 | 385 | [[package]] 386 | name = "quote" 387 | version = "1.0.16" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "b4af2ec4714533fcdf07e886f17025ace8b997b9ce51204ee69b6da831c3da57" 390 | dependencies = [ 391 | "proc-macro2", 392 | ] 393 | 394 | [[package]] 395 | name = "rand" 396 | version = "0.4.6" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" 399 | dependencies = [ 400 | "fuchsia-cprng", 401 | "libc", 402 | "rand_core 0.3.1", 403 | "rdrand", 404 | "winapi", 405 | ] 406 | 407 | [[package]] 408 | name = "rand_core" 409 | version = "0.3.1" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" 412 | dependencies = [ 413 | "rand_core 0.4.2", 414 | ] 415 | 416 | [[package]] 417 | name = "rand_core" 418 | version = "0.4.2" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" 421 | 422 | [[package]] 423 | name = "rdrand" 424 | version = "0.4.0" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" 427 | dependencies = [ 428 | "rand_core 0.3.1", 429 | ] 430 | 431 | [[package]] 432 | name = "redox_syscall" 433 | version = "0.2.11" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "8380fe0152551244f0747b1bf41737e0f8a74f97a14ccefd1148187271634f3c" 436 | dependencies = [ 437 | "bitflags", 438 | ] 439 | 440 | [[package]] 441 | name = "redox_users" 442 | version = "0.4.2" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "7776223e2696f1aa4c6b0170e83212f47296a00424305117d013dfe86fb0fe55" 445 | dependencies = [ 446 | "getrandom", 447 | "redox_syscall", 448 | "thiserror", 449 | ] 450 | 451 | [[package]] 452 | name = "remove_dir_all" 453 | version = "0.5.3" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 456 | dependencies = [ 457 | "winapi", 458 | ] 459 | 460 | [[package]] 461 | name = "rustc-demangle" 462 | version = "0.1.21" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" 465 | 466 | [[package]] 467 | name = "ryu" 468 | version = "1.0.9" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" 471 | 472 | [[package]] 473 | name = "serde" 474 | version = "1.0.140" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "fc855a42c7967b7c369eb5860f7164ef1f6f81c20c7cc1141f2a604e18723b03" 477 | dependencies = [ 478 | "serde_derive", 479 | ] 480 | 481 | [[package]] 482 | name = "serde_derive" 483 | version = "1.0.140" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "6f2122636b9fe3b81f1cb25099fcf2d3f542cdb1d45940d56c713158884a05da" 486 | dependencies = [ 487 | "proc-macro2", 488 | "quote", 489 | "syn", 490 | ] 491 | 492 | [[package]] 493 | name = "serde_json" 494 | version = "1.0.79" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" 497 | dependencies = [ 498 | "itoa", 499 | "ryu", 500 | "serde", 501 | ] 502 | 503 | [[package]] 504 | name = "strsim" 505 | version = "0.10.0" 506 | source = "registry+https://github.com/rust-lang/crates.io-index" 507 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 508 | 509 | [[package]] 510 | name = "syn" 511 | version = "1.0.92" 512 | source = "registry+https://github.com/rust-lang/crates.io-index" 513 | checksum = "7ff7c592601f11445996a06f8ad0c27f094a58857c2f89e97974ab9235b92c52" 514 | dependencies = [ 515 | "proc-macro2", 516 | "quote", 517 | "unicode-xid", 518 | ] 519 | 520 | [[package]] 521 | name = "tempdir" 522 | version = "0.3.7" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" 525 | dependencies = [ 526 | "rand", 527 | "remove_dir_all", 528 | ] 529 | 530 | [[package]] 531 | name = "tempfile" 532 | version = "3.3.0" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" 535 | dependencies = [ 536 | "cfg-if", 537 | "fastrand", 538 | "libc", 539 | "redox_syscall", 540 | "remove_dir_all", 541 | "winapi", 542 | ] 543 | 544 | [[package]] 545 | name = "termcolor" 546 | version = "1.1.3" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 549 | dependencies = [ 550 | "winapi-util", 551 | ] 552 | 553 | [[package]] 554 | name = "termios" 555 | version = "0.3.3" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" 558 | dependencies = [ 559 | "libc", 560 | ] 561 | 562 | [[package]] 563 | name = "textwrap" 564 | version = "0.15.0" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" 567 | 568 | [[package]] 569 | name = "thiserror" 570 | version = "1.0.30" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" 573 | dependencies = [ 574 | "thiserror-impl", 575 | ] 576 | 577 | [[package]] 578 | name = "thiserror-impl" 579 | version = "1.0.30" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" 582 | dependencies = [ 583 | "proc-macro2", 584 | "quote", 585 | "syn", 586 | ] 587 | 588 | [[package]] 589 | name = "toml" 590 | version = "0.5.9" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" 593 | dependencies = [ 594 | "serde", 595 | ] 596 | 597 | [[package]] 598 | name = "unicode-xid" 599 | version = "0.2.2" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 602 | 603 | [[package]] 604 | name = "version_check" 605 | version = "0.9.4" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 608 | 609 | [[package]] 610 | name = "wait-timeout" 611 | version = "0.2.0" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" 614 | dependencies = [ 615 | "libc", 616 | ] 617 | 618 | [[package]] 619 | name = "wasi" 620 | version = "0.10.2+wasi-snapshot-preview1" 621 | source = "registry+https://github.com/rust-lang/crates.io-index" 622 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 623 | 624 | [[package]] 625 | name = "winapi" 626 | version = "0.3.9" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 629 | dependencies = [ 630 | "winapi-i686-pc-windows-gnu", 631 | "winapi-x86_64-pc-windows-gnu", 632 | ] 633 | 634 | [[package]] 635 | name = "winapi-i686-pc-windows-gnu" 636 | version = "0.4.0" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 639 | 640 | [[package]] 641 | name = "winapi-util" 642 | version = "0.1.5" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 645 | dependencies = [ 646 | "winapi", 647 | ] 648 | 649 | [[package]] 650 | name = "winapi-x86_64-pc-windows-gnu" 651 | version = "0.4.0" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 654 | 655 | [[package]] 656 | name = "xdg" 657 | version = "2.4.1" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "0c4583db5cbd4c4c0303df2d15af80f0539db703fa1c68802d4cbbd2dd0f88f6" 660 | dependencies = [ 661 | "dirs", 662 | ] 663 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "i3nator" 3 | version = "1.3.0-alpha.0" 4 | edition = "2021" 5 | authors = ["Pit Kleyersburg "] 6 | license = "MIT/Apache-2.0" 7 | description = "i3nator is Tmuxinator for the i3 window manager" 8 | homepage = "https://github.com/pitkley/i3nator" 9 | repository = "https://github.com/pitkley/i3nator.git" 10 | readme = "README.md" 11 | 12 | categories = ["command-line-interface", "command-line-utilities", "gui"] 13 | keywords = ["cli", "i3", "unix", "xdg"] 14 | 15 | include = [ 16 | "**/*.rs", 17 | "resources/**/*", 18 | "Cargo.toml", 19 | "LICENSE-*", 20 | ] 21 | 22 | [badges] 23 | maintenance = { status = "actively-developed" } 24 | 25 | [dependencies] 26 | clap = { version = "3.2.15", features = ["cargo", "derive"] } 27 | clap_complete = "3.1.4" 28 | dirs-next = "2.0.0" 29 | error-chain = "0.12.4" 30 | getch = "0.3.1" 31 | i3ipc = "0.10.1" 32 | lazy_static = "1.4.0" 33 | serde = { version = "1.0.140", features = ["derive"] } 34 | tempfile = "3.3.0" 35 | toml = "0.5.9" 36 | wait-timeout = "0.2.0" 37 | xdg = "2.4.1" 38 | 39 | [dev-dependencies] 40 | tempdir = "0.3.7" 41 | 42 | [[bin]] 43 | name = "i3nator" 44 | path = "src/main.rs" 45 | doc = false 46 | 47 | [features] 48 | sequential-tests = [] 49 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Pit Kleyersburg 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # i3nator 2 | 3 | i3nator is [Tmuxinator][gh-tmuxinator] for the [i3 window manager][i3wm]. 4 | 5 | It allows you to manage what are called "projects", which are used to easily restore saved i3 6 | layouts (see [Layout saving in i3][i3wm-layout-saving]) and extending i3's base functionality 7 | by allowing you to automatically start applications too. 8 | 9 | * [Documentation][i3nator-docs] 10 | * [GitHub source repository][i3nator-gh] 11 | * [Example configurations][i3nator-examples] 12 | 13 | ## Installation 14 | 15 | You have multiple options to install i3nator: 16 | 17 | 1. If you have a recent Rust with Cargo installed, you can install `i3nator` directly from 18 | crates.io: 19 | 20 | ```console 21 | $ cargo install i3nator 22 | ``` 23 | 24 | 2. Alternatively, you can download the supplied static binary from the [release 25 | page][i3nator-releases], this should work without any additional dependencies. 26 | 27 | 3. Another option is to install from directly from source (this again requires a recent Rust 28 | installation): 29 | 30 | ```console 31 | $ git clone https://github.com/pitkley/i3nator.git 32 | $ cd i3nator 33 | $ cargo install 34 | ``` 35 | 36 | **Note:** If you want to be able to use the automatic command execution feature, you will need 37 | to install [`xdotool`][xdotool]. 38 | 39 | ## Usage 40 | 41 | 42 | ```text 43 | i3nator 1.2.0 44 | Pit Kleyersburg 45 | i3nator is Tmuxinator for the i3 window manager 46 | 47 | USAGE: 48 | i3nator 49 | 50 | FLAGS: 51 | -h, --help Prints help information 52 | -V, --version Prints version information 53 | 54 | SUBCOMMANDS: 55 | copy Copy an existing project to a new project 56 | delete Delete existing projects 57 | edit Open an existing project in your editor 58 | help Prints this message or the help of the given subcommand(s) 59 | info Show information for the specified project 60 | layout Manage layouts which can used in projects 61 | list List all projects 62 | local Run a project from a local TOML-file 63 | new Create a new project and open it in your editor 64 | rename Rename a project 65 | start Start a project according to it's configuration 66 | verify Verify the configuration of the existing projects 67 | ``` 68 | 69 | 70 | Every command -- except `layout` -- is for managing your projects, i.e. creating, editing, 71 | starting and potentially deleting them. 72 | 73 | `layout` is a bit special, because it is most of the commands used for projects, except for i3 74 | layouts: 75 | 76 | 77 | ```text 78 | i3nator-layout 1.2.0 79 | Manage layouts which can used in projects 80 | 81 | USAGE: 82 | i3nator layout 83 | 84 | FLAGS: 85 | -h, --help Prints help information 86 | 87 | SUBCOMMANDS: 88 | copy Copy an existing layout to a new layout 89 | delete Delete existing layouts 90 | edit Open an existing layout in your editor 91 | help Prints this message or the help of the given subcommand(s) 92 | info Show information for the specified layout 93 | list List all layouts 94 | new Create a new layout and open it in your editor 95 | rename Rename a layout 96 | ``` 97 | 98 | 99 | These commands allow you to manage [i3 layouts][i3wm-layout-saving] in addition to the actual 100 | projects which will be able to use and reuse them. 101 | 102 | ## Examples 103 | 104 | (See [here][i3nator-examples] for additional examples. See also the [`types::Config` API 105 | documentation][i3nator-docs-types-Config] for detailed documentation on the configuration 106 | parameters.) 107 | 108 | ### Full workflow 109 | 110 | 1. Open all applications you want and lay them out on a workspace as desired. 111 | 112 | 2. Save the workspace's layout using [`i3-save-tree`][i3wm-save-tree], feeding the layout 113 | directly into i3nator's layout management: 114 | 115 | ```console 116 | $ i3-save-tree --workspace 1 | i3nator layout new -t - mylayout 117 | ``` 118 | 119 | If you don't want the layout managed by i3nator, you can alternatively: 120 | * copy the layout to your clipboard, and insert it directly in your project configuration 121 | later *or* 122 | * save it to a file on your disk, and reference this file-path in your configuration later. 123 | 124 | If you are using i3nator's layout management, the above command should drop you into an 125 | editor, ready for step 3. 126 | 127 | 3. Modify the saved layout to accurately match created applications. See [Editing layout 128 | files][i3wm-modify-layout] on how to do this. 129 | 130 | If you copied the layout to your clipboard, you will be able to do this in step 5. 131 | 132 | 4. Create a new project: 133 | 134 | ```console 135 | $ i3nator new myproject 136 | Created project 'myproject' 137 | Opening your editor to edit 'myproject' 138 | ``` 139 | 140 | This will open your default editor with a configuration template. If it doesn't, you have to 141 | specify either the `$VISUAL` or the `$EDITOR` environment variable. 142 | 143 | You can also simply edit the configuration file directly. Use `i3nator info ` to 144 | retreive its path. 145 | 146 | 5. Modify the template to fit your needs. This includes: 147 | 148 | 1. Setting the main working directory. 149 | 2. Setting the destination workspace (this is optional, if not specified, the active one 150 | will be used). 151 | 3. Specifying which layout to use. If you have passed the layout to i3nator in step 2, you 152 | can simply enter its name here, which in case two was `mylayout`. 153 | 154 | Alternatively you can either supply a path to the file containing the layout, or you can 155 | paste the layout from step 2 directly into your project file as a multi-line string. At 156 | this point you will also be able to modify the layout to match applications correctly. 157 | 158 | 4. Configuring which applications to start and how to start them. This is done by setting 159 | the `command` to the full command to be used to start the application and optionally 160 | configuring a different working directory if desired. 161 | 162 | If you want to execute additional commands or keypresses in the started applications, 163 | you can also define `exec`. 164 | 165 | The resulting configuration could look something like this: 166 | 167 | ```toml 168 | [general] 169 | working_directory = "/path/to/my/working/directory" 170 | workspace = "1" 171 | layout = "mylayout" 172 | 173 | [[applications]] 174 | command = "mycommand --with 'multiple args'" 175 | exec = ["command one", "command two"] 176 | ``` 177 | 178 | 6. Save and close your editor. This will automatically verify the created configuration. If 179 | there is an issue it will tell you what failed and allow you to reedit the file directly or 180 | ignore the error and exit. 181 | 182 | With these prerequisites fulfilled, you are now able to start a configuration which appends 183 | your layout to a specified workspace and starts the configured applications: 184 | 185 | ```console 186 | $ i3nator start myproject 187 | ``` 188 | 189 | ## Version bump policy 190 | 191 | In general, the versioning scheme follows the semantic versioning guidelines: 192 | 193 | * The patch version is bumped when backwards compatible fixes are made (this includes updates to dependencies). 194 | * The minor version is bumped when new features are introduced, but backwards compatibility is retained. 195 | * The major version is bumped when a backwards incompatible change was made. 196 | 197 | Special case: 198 | 199 | * A bump in the minimum supported Rust version (MSRV), which is currently 1.54.0, will be done in patch version updates (i.e. they do not require a major or minor version bump). 200 | 201 | ## License 202 | 203 | i3nator is licensed under either of 204 | 205 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 206 | http://www.apache.org/licenses/LICENSE-2.0) 207 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or 208 | http://opensource.org/licenses/MIT) 209 | 210 | at your option. 211 | 212 | #### Contribution 213 | 214 | Unless you explicitly state otherwise, any contribution intentionally submitted 215 | for inclusion in i3nator by you, as defined in the Apache-2.0 license, shall be 216 | dual licensed as above, without any additional terms or conditions. 217 | 218 | [gh-tmuxinator]: https://github.com/tmuxinator/tmuxinator 219 | [i3nator-docs]: https://docs.rs/i3nator 220 | [i3nator-docs-types-Config]: https://docs.rs/i3nator/*/i3nator/types/struct.Config.html 221 | [i3nator-examples]: https://github.com/pitkley/i3nator/tree/main/examples 222 | [i3nator-gh]: https://github.com/pitkley/i3nator 223 | [i3nator-releases]: https://github.com/pitkley/i3nator/releases 224 | [i3wm]: https://i3wm.org/ 225 | [i3wm-modify-layout]: https://i3wm.org/docs/layout-saving.html#_editing_layout_files 226 | [i3wm-layout-saving]: https://i3wm.org/docs/layout-saving.html 227 | [i3wm-save-tree]: https://i3wm.org/docs/layout-saving.html#_saving_the_layout 228 | [xdotool]: https://github.com/jordansissel/xdotool 229 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | * Releasing a release-candidate: 4 | 5 | ``` 6 | cargo release --skip-publish rc 7 | ``` 8 | 9 | * Releasing a full version: 10 | 11 | ``` 12 | cargo release 13 | ``` 14 | 15 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = [ 2 | "build-and-test (1.56.1, false)", 3 | "build-and-test (stable, false)", 4 | "build-and-test (beta, false)", 5 | "build-and-test (stable, x86_64-unknown-linux-musl, false)", 6 | "rustfmt", 7 | "clippy", 8 | "check-license-headers", 9 | "yamllint", 10 | ] 11 | -------------------------------------------------------------------------------- /examples/exec.toml: -------------------------------------------------------------------------------- 1 | # i3nator project example using exec. 2 | # 3 | # This example will open two terminals, lay them out side-by-side as per the 4 | # specified layout and execute the specified commands in them. 5 | 6 | # General configuration items 7 | [general] 8 | # Working directory to use 9 | working_directory = "/home/user/development/myproject" 10 | 11 | # Name of the workspace the layout should be applied to 12 | workspace = "1" 13 | 14 | # Layout to use, inserted directly. This could also be saved to a file and 15 | # referenced by its filepath here. 16 | layout = """ 17 | { 18 | "border": "pixel", 19 | "current_border_width": 2, 20 | "floating": "auto_off", 21 | "name": "split-left", 22 | "percent": 0.5, 23 | "swallows": [ 24 | { 25 | "class": "^Termite$", 26 | "window_role": "^split-left$" 27 | } 28 | ], 29 | "type": "con" 30 | } 31 | 32 | { 33 | "border": "pixel", 34 | "current_border_width": 2, 35 | "floating": "auto_off", 36 | "name": "split-right", 37 | "percent": 0.5, 38 | "swallows": [ 39 | { 40 | "class": "^Termite$", 41 | "window_role": "^split-right$" 42 | } 43 | ], 44 | "type": "con" 45 | }""" 46 | 47 | # Applications to start 48 | [[applications]] 49 | # Command to run to start the application 50 | command = "termite --role split-left" 51 | # Commands provided as a simple list. This means they will be executed in 52 | # order, each followed by a "Return". 53 | exec = ["echo Hello", "echo World"] 54 | 55 | [[applications]] 56 | command = "termite --role split-right" 57 | # Here the command is specified as a map, explicitly setting the exec-type to 58 | # `keys` to tell i3nator to interpret the given list as single keypresses. 59 | exec = { commands = ["ctrl+r", "e", "c", "h", "o", "Return"], exec_type = "keys" } 60 | # ^- this will simulate a press of CTRL+R, followed by typing 'echo' and 61 | # pressing "Return" to accept whatever the reverse-search found. 62 | -------------------------------------------------------------------------------- /examples/simple.toml: -------------------------------------------------------------------------------- 1 | # Simple i3nator project example. 2 | # 3 | # This example will open two terminals, and lay them out side-by-side as per 4 | # the specified layout. 5 | 6 | # General configuration items 7 | [general] 8 | # Working directory to use 9 | working_directory = "/home/user/development/myproject" 10 | 11 | # Name of the workspace the layout should be applied to 12 | workspace = "1" 13 | 14 | # Layout to use, inserted directly. This could also be saved to a file and 15 | # referenced by its filepath here. 16 | layout = """ 17 | { 18 | "border": "pixel", 19 | "current_border_width": 2, 20 | "floating": "auto_off", 21 | "name": "split-left", 22 | "percent": 0.5, 23 | "swallows": [ 24 | { 25 | "class": "^Termite$", 26 | "window_role": "^split-left$" 27 | } 28 | ], 29 | "type": "con" 30 | } 31 | 32 | { 33 | "border": "pixel", 34 | "current_border_width": 2, 35 | "floating": "auto_off", 36 | "name": "split-right", 37 | "percent": 0.5, 38 | "swallows": [ 39 | { 40 | "class": "^Termite$", 41 | "window_role": "^split-right$" 42 | } 43 | ], 44 | "type": "con" 45 | }""" 46 | 47 | # Applications to start 48 | [[applications]] 49 | # Command to run to start the application 50 | command = "termite --role split-left" 51 | 52 | [[applications]] 53 | command = "termite --role split-right" 54 | -------------------------------------------------------------------------------- /examples/template.toml: -------------------------------------------------------------------------------- 1 | ../resources/project_template.toml -------------------------------------------------------------------------------- /hack/cargo-release-hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | : "${DRY_RUN:?"DRY_RUN has to be specified"}" 5 | if [[ "$DRY_RUN" != "false" ]]; then 6 | exit 0 7 | fi 8 | 9 | #: "${CRATE_NAME:?"CRATE_NAME has to be specified"}" 10 | #: "${CRATE_ROOT:?"CRATE_ROOT has to be specified"}" 11 | : "${NEW_VERSION:?"NEW_VERSION has to be specified"}" 12 | #: "${PREV_VERSION:?"PREV_VERSION has to be specified"}" 13 | #: "${WORKSPACE_ROOT:?"WORKSPACE_ROOT has to be specified"}" 14 | 15 | if [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 16 | IS_PRERELEASE=false 17 | else 18 | IS_PRERELEASE=true 19 | fi 20 | function is_prerelease { 21 | [[ "$IS_PRERELEASE" == "true" ]] 22 | } 23 | 24 | function _replace { 25 | local marker="$1" 26 | local usage="$2" 27 | 28 | local tempfile 29 | tempfile="$(mktemp)" 30 | { 31 | echo "" 32 | echo "\`\`\`text" 33 | echo "$usage" 34 | echo "\`\`\`" 35 | echo "" 36 | } >> "$tempfile" 37 | 38 | sed \ 39 | -i \ 40 | -e "//,//!b" \ 41 | -e "//!d;r $tempfile" \ 42 | -e "d" \ 43 | README.md 44 | 45 | rm "$tempfile" 46 | } 47 | function readme_replace_usage { 48 | if is_prerelease; then 49 | return 0 50 | fi 51 | 52 | _replace "usage-main" "$(cargo run -- --help)" 53 | _replace "usage-layout" "$(cargo run -- layout --help)" 54 | } 55 | 56 | readme_replace_usage 57 | -------------------------------------------------------------------------------- /hack/check-license-headers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Idea and initial code taken from: 4 | # https://github.com/SerenityOS/serenity/blob/50265858abfc562297c62645e1ca96f16c46aad1/Meta/check-license-headers.sh 5 | # Copyright (c) 2020 the SerenityOS developers. 6 | # 7 | # The code in this file is licensed under the 2-clause BSD license. 8 | 9 | # We check if the file starts with "// Copyright". If it doesn't, it is 10 | # classified as an error. 11 | PATTERN=$'^// Copyright' 12 | ERRORS=() 13 | 14 | while IFS= read -r f; do 15 | if [[ ! $(cat "$f") =~ $PATTERN ]]; then 16 | ERRORS+=("$f") 17 | fi 18 | done < <(git ls-files -- \ 19 | '*.rs' \ 20 | ) 21 | 22 | if (( ${#ERRORS[@]} )); then 23 | echo "Files missing license headers: ${ERRORS[*]}" 24 | exit 1 25 | fi 26 | -------------------------------------------------------------------------------- /hack/deploy-github-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | : "${COMMIT_MESSAGE:?"COMMIT_MESSAGE needs to be non-empty"}" 4 | 5 | function git_add { 6 | echo "📐 Adding all new/changed/removed files" 7 | git add . 8 | } 9 | 10 | function git_commit { 11 | echo "📦 Creating commit" 12 | local status=0 13 | git \ 14 | -c "user.name=GitHub" \ 15 | -c "user.email=noreply@github.com" \ 16 | commit \ 17 | --quiet \ 18 | --author="github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" \ 19 | --message "${COMMIT_MESSAGE}" \ 20 | || status=$? 21 | echo "status: $status" 22 | case "$status" in 23 | 0) 24 | echo "return 0" 25 | return 0 26 | ;; 27 | 1) 28 | # Couldn't create a commit because it would have been empty. 29 | echo "⚠️ No commit required" 30 | return 1 31 | ;; 32 | *) 33 | echo "exit 1!" 34 | # A different error has occurred, exit! 35 | exit 1 36 | ;; 37 | esac 38 | } 39 | 40 | function git_push { 41 | git push 42 | } 43 | 44 | git_add 45 | if git_commit; then 46 | echo "📃 Pushing changes" 47 | git_push 48 | fi 49 | echo "🎉 Successfully deployed to GitHub pages!" 50 | -------------------------------------------------------------------------------- /hack/extract-current-changelog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from enum import auto, Enum 4 | import sys 5 | 6 | 7 | class State(Enum): 8 | Initial = auto() 9 | BeforeYielding = auto() 10 | Yielding = auto() 11 | End = auto() 12 | 13 | 14 | def get_current_changelog(changelog_path): 15 | with open(changelog_path, 'r') as fh: 16 | state = State.Initial 17 | for line in fh.readlines(): 18 | line = line.rstrip() 19 | if state == State.End: 20 | break 21 | elif state == State.Initial: 22 | if line.startswith("## "): 23 | state = State.BeforeYielding 24 | elif state == state.BeforeYielding: 25 | if line.strip(): 26 | state = State.Yielding 27 | yield line 28 | elif state == state.Yielding: 29 | if line.startswith("## "): 30 | state = State.End 31 | else: 32 | yield line 33 | 34 | 35 | if __name__ == '__main__': 36 | try: 37 | changelog_path = sys.argv[1] 38 | except: 39 | print("USAGE: extract-current-changelog.py ", file=sys.stderr) 40 | exit(1) 41 | for line in get_current_changelog(changelog_path): 42 | print(line) 43 | -------------------------------------------------------------------------------- /hack/git/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | exec 2>&1 5 | 6 | # Check if code is formatted 7 | cargo fmt --all -- --check 8 | 9 | # Check code 10 | cargo check --all --all-features 11 | # Check if all tests compile, but don't run them 12 | cargo test --all-features --no-run 13 | # Run non-guarded tests 14 | cargo test 15 | 16 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | sign-commit = true 2 | sign-tag = true 3 | tag-name = "{{version}}" 4 | 5 | pre-release-hook = ["hack/cargo-release-hook.sh"] 6 | 7 | # Handle new section in CHANGELOG.md 8 | # 1. Replace the fields in the unreleased header. 9 | [[pre-release-replacements]] 10 | file = "CHANGELOG.md" 11 | search = "## Unreleased" 12 | replace = "## {{version}} ({{date}})" 13 | exactly = 1 14 | prerelease = false 15 | # 2. Add a new unreleased header. 16 | [[pre-release-replacements]] 17 | file = "CHANGELOG.md" 18 | search = "" 19 | replace = "\n\n## Unreleased" 20 | exactly = 1 21 | prerelease = false 22 | -------------------------------------------------------------------------------- /resources/project_template.toml: -------------------------------------------------------------------------------- 1 | # i3nator project 2 | 3 | # General configuration items 4 | [general] 5 | # Working directory to use (this is optional) 6 | working_directory = "/path/to/my/working/directory" 7 | 8 | # Name of the workspace the layout should be applied to (this is optional, if 9 | # not specified, the active workspace will be used) 10 | workspace = "1" 11 | 12 | # Name of the i3nator managed layout 13 | layout = "mylayout" 14 | 15 | # Alternative 1: path to your layout-file 16 | # layout = "/path/to/my/layout.json" 17 | 18 | # Alternative 2: you can include the JSON-contents of the layout directly 19 | # layout = """ 20 | # { 21 | # ... 22 | # }""" 23 | 24 | # List of applications to start 25 | [[applications]] 26 | # Command to run to start the application 27 | command = "mycommand --with 'multiple args'" 28 | 29 | # Different working directory to use (optional) 30 | # working_directory = "/path/to/a/different/working/directory" 31 | 32 | # Execute commands in the started application 33 | # exec = "anothercommand --with 'multiple args'" 34 | 35 | # You can also execute multiple commands: 36 | # exec = ["command one", "command two"] 37 | 38 | # By default, the commands get "typed" into the application, followed by a 39 | # simulated press of "Return". You can influence this behaviour to either not 40 | # press return, or to interpret the given commands as individual keypresses: 41 | # exec = { commands = ["echo", " ", "hi"], exec_type = "text_no_return" } 42 | # ^- this will only input "echo hi" into the application, without simulating a 43 | # Return. 44 | # exec = { commands = ["e", "c", "h", "o", "space", "h", "i", "Return"] } 45 | # ^- this will forward the elements of `commands` as they are to `xdotool key`, 46 | # i.e. they will be executed as individual keypresses. 47 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | #![deny(clippy::missing_docs_in_private_items)] 10 | 11 | //! CLI module 12 | 13 | use clap::{ 14 | crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, Subcommand, 15 | }; 16 | use clap_complete::Shell; 17 | use std::{ffi::OsString, io}; 18 | 19 | /// Main CLI entry type 20 | #[derive(Parser)] 21 | #[clap( 22 | author = crate_authors!(), 23 | version = crate_version!(), 24 | about = crate_description!(), 25 | infer_subcommands = true, 26 | )] 27 | pub(crate) struct Cli { 28 | /// Some docs on subcommands 29 | #[clap(subcommand)] 30 | pub(crate) command: Commands, 31 | } 32 | 33 | /// Main level subcommands 34 | #[derive(Subcommand)] 35 | pub(crate) enum Commands { 36 | /// Manage projects 37 | #[clap(subcommand)] 38 | Project(ProjectCommands), 39 | /// Manage projects 40 | #[clap(flatten)] 41 | FlattenedProject(ProjectCommands), 42 | /// Manage layouts which can be used in projects 43 | #[clap(subcommand)] 44 | Layout(LayoutCommands), 45 | /// Generate shell completions for i3nator 46 | GenerateShellCompletions { 47 | /// Shell to generate the completions for 48 | #[clap(long = "shell", arg_enum)] 49 | generator: Shell, 50 | /// Path to save the completions into. 51 | /// 52 | /// If the directory in question does not exist, it will not be created. Don't specify this parameter if you 53 | /// want to output the completions to stdout. 54 | output_path: Option, 55 | }, 56 | } 57 | 58 | /// Project-specific subcommands 59 | #[derive(Subcommand)] 60 | pub(crate) enum ProjectCommands { 61 | /// Copy an existing project to a new project 62 | Copy { 63 | /// Name of the existing project 64 | existing: OsString, 65 | /// Name of the new, destination project 66 | new: OsString, 67 | /// Don't open new project for editing after copying 68 | #[clap(long = "no-edit")] 69 | no_edit: bool, 70 | /// Don't verify the contents of the new project after the editor closes 71 | #[clap(long = "no-verify")] 72 | no_verify: bool, 73 | }, 74 | /// Delete existing projects 75 | Delete { 76 | /// Names of the projects to delete 77 | #[clap(required = true)] 78 | names: Vec, 79 | }, 80 | /// Open an existing project in your editor 81 | #[clap(alias = "open")] 82 | Edit { 83 | /// Name of the project to edit 84 | name: OsString, 85 | /// Don't verify the contents of the new project after the editor closes 86 | #[clap(long = "no-verify")] 87 | no_verify: bool, 88 | }, 89 | /// Show information for the specified project 90 | Info { 91 | /// Name of the project to show informaiton for 92 | name: OsString, 93 | }, 94 | /// List all projects 95 | List { 96 | /// List one project per line, no other output 97 | #[clap(short = 'q', long = "quiet")] 98 | quiet: bool, 99 | }, 100 | /// Run a project from a local TOML-file 101 | Local { 102 | /// File to load the project from 103 | #[clap(short = 'f', long = "file", default_value = "i3nator.toml")] 104 | file: OsString, 105 | /// Directory used as context for starting the applications. This overrides any specified working-directory in 106 | /// the project's configuration. 107 | #[clap(short = 'd', long = "working-directory", value_name = "PATH")] 108 | working_directory: Option, 109 | /// Workspace to apply the layout to. This overrides the specified workspace in the project's configuration. 110 | #[clap(short = 'w', long = "workspace", value_name = "WORKSPACE")] 111 | workspace: Option, 112 | }, 113 | /// Create a new project and open it in your editor 114 | New { 115 | /// Name of the project to create 116 | name: OsString, 117 | /// Don't open new project for editing after copying 118 | #[clap(long = "no-edit")] 119 | no_edit: bool, 120 | /// Don't verify the contents of the new project after the editor closes 121 | #[clap(long = "no-verify")] 122 | no_verify: bool, 123 | }, 124 | /// Rename a project 125 | Rename { 126 | /// Name of the existing project to rename 127 | existing: OsString, 128 | /// New name for the existing project 129 | new: OsString, 130 | /// Open the renamed project for editing 131 | #[clap(long = "edit")] 132 | edit: bool, 133 | /// Don't verify the contents of the new project after the editor closes 134 | #[clap(long = "no-verify")] 135 | no_verify: bool, 136 | }, 137 | /// Start a project according to it's configuration 138 | #[clap(alias = "run")] 139 | Start { 140 | /// Name of the project to start 141 | name: OsString, 142 | /// Directory used as context for starting the applications. This overrides any specified working-directory in 143 | /// the project's configuration. 144 | #[clap(short = 'd', long = "working-directory", value_name = "PATH")] 145 | working_directory: Option, 146 | /// Workspace to apply the layout to. This overrides the specified workspace in the project's configuration. 147 | #[clap(short = 'w', long = "workspace", value_name = "WORKSPACE")] 148 | workspace: Option, 149 | }, 150 | /// Verify the configuration of the existing projects 151 | Verify { 152 | /// Names of the project to verify. 153 | /// 154 | /// If not specified, all projects will be checked. 155 | names: Vec, 156 | }, 157 | } 158 | 159 | /// Layout-specific subcommands 160 | #[derive(Subcommand)] 161 | pub(crate) enum LayoutCommands { 162 | /// Copy an existing layout to a new layout 163 | Copy { 164 | /// Name of the existing layout 165 | existing: OsString, 166 | /// Name of the new layout 167 | new: OsString, 168 | /// Don't open the new layout for editing after copying 169 | #[clap(long = "no-edit")] 170 | no_edit: bool, 171 | }, 172 | /// Delete existing layouts 173 | #[clap(alias = "remove")] 174 | Delete { 175 | /// Names of the layouts to delete 176 | #[clap(required = true)] 177 | names: Vec, 178 | }, 179 | /// Open an existing layout in your editor 180 | Edit { 181 | /// Name of the layout to edit 182 | name: OsString, 183 | }, 184 | /// Show information for the specified layout 185 | Info { 186 | /// Name of the layout to show information for 187 | name: OsString, 188 | }, 189 | /// List all layouts 190 | List { 191 | /// List one layout per line, no other output 192 | #[clap(short = 'q', long = "quiet")] 193 | quiet: bool, 194 | }, 195 | /// Create a new layout and open it in your editor 196 | New { 197 | /// Name of the layout to create 198 | name: OsString, 199 | /// Don't open the new layout for editing 200 | #[clap(long = "no-edit")] 201 | no_edit: bool, 202 | /// Prepopulate the layout from the given path. Use '-' to read from stdin. 203 | #[clap(short = 't', long = "template")] 204 | template: Option, 205 | }, 206 | /// Rename a layout 207 | Rename { 208 | /// Name of the existing layout to rename 209 | existing: OsString, 210 | /// New name for the existing layout 211 | new: OsString, 212 | /// Open the renamed layout for editing 213 | #[clap(long = "edit")] 214 | edit: bool, 215 | }, 216 | } 217 | 218 | /// Generate shell completions 219 | pub(crate) fn generate_completions>( 220 | generator: Shell, 221 | output_path: Option, 222 | ) -> Result<(), io::Error> { 223 | let mut cmd = Cli::command(); 224 | 225 | if let Some(output_path) = output_path { 226 | clap_complete::generate_to(generator, &mut cmd, crate_name!(), output_path)?; 227 | } else { 228 | clap_complete::generate(generator, &mut cmd, crate_name!(), &mut io::stdout()); 229 | } 230 | 231 | Ok(()) 232 | } 233 | -------------------------------------------------------------------------------- /src/configfiles.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | //! Module consolidating common functionality between projects and layouts. 10 | 11 | use crate::errors::*; 12 | use lazy_static::lazy_static; 13 | use std::{ 14 | ffi::{OsStr, OsString}, 15 | fs::{self, File}, 16 | io::prelude::*, 17 | path::{Path, PathBuf}, 18 | }; 19 | 20 | lazy_static! { 21 | static ref XDG_DIRS: xdg::BaseDirectories = 22 | xdg::BaseDirectories::with_prefix("i3nator").expect("couldn't get XDG base directory"); 23 | } 24 | 25 | /// Helping type to consolidate common functionality between projects and layouts. 26 | pub trait ConfigFile: Sized { 27 | /// Create a copy of the current configfile, that is a copy of the configuration file on disk, 28 | /// with a name of `new_name`. 29 | /// 30 | /// This will keep the same prefix. 31 | /// 32 | /// # Parameters 33 | /// 34 | /// - `new_name`: A `OsStr` that is the name of the destination configfile. 35 | /// 36 | /// # Returns 37 | /// 38 | /// A `Result` which is: 39 | /// 40 | /// - `Ok`: an instance of `ConfigFile` for the new configfile. 41 | /// - `Err`: an error, e.g. if a configfile with `new_name` already exists or copying the file 42 | /// failed. 43 | fn copy + ?Sized>(&self, new_name: &S) -> Result; 44 | 45 | /// Create a configfile given a `name`. 46 | /// 47 | /// This will not create the configuration file, but it will ensure a legal XDG path with all 48 | /// directories leading up to the file existing. 49 | /// 50 | /// If you want to pre-fill the configuration file with a template, see 51 | /// [`ConfigFile::create_from_template`][fn-ConfigFile-create_from_template]. 52 | /// 53 | /// # Parameters 54 | /// 55 | /// - `name`: A `OsStr` naming the configuration file on disk. 56 | /// 57 | /// # Returns 58 | /// 59 | /// A `Result` which is: 60 | /// 61 | /// - `Ok`: an instance of `ConfigFile` for the given `name`. 62 | /// - `Err`: an error, e.g. if the configfile already exists or couldn't be created. 63 | /// 64 | /// 65 | /// [fn-ConfigFile-create_from_template]: #method.create_from_template 66 | fn create + ?Sized>(name: &S) -> Result; 67 | 68 | /// Create a configfile given a `name`, pre-filling it with a given `template`. 69 | /// 70 | /// See [`ConfigFile::create`][fn-ConfigFile-create] for additional information. 71 | /// 72 | /// # Parameters 73 | /// 74 | /// - `name`: A `OsStr` naming the the configuration file on disk. 75 | /// - `template`: A byte-slice which will be written to the configuration file on disk. 76 | /// 77 | /// # Returns 78 | /// 79 | /// A `Result` which is: 80 | /// 81 | /// - `Ok`: an instance of `ConfigFile` for the given `name` with the contents of `template`. 82 | /// - `Err`: an error, e.g. if the configfile already exists or couldn't be created. 83 | /// 84 | /// 85 | /// [fn-ConfigFile-create]: #method.create 86 | fn create_from_template + ?Sized>(name: &S, template: &[u8]) -> Result; 87 | 88 | /// Delete this configfile from disk. 89 | /// 90 | /// # Returns 91 | /// 92 | /// A `Result` which is: 93 | /// 94 | /// - `Ok`: nothing (`()`). 95 | /// - `Err`: an error if deleting the file failed. 96 | fn delete(&self) -> Result<()>; 97 | 98 | /// Opens an existing configfile for a given path. 99 | /// 100 | /// This will not impose any XDG conventions, but rather allows to open a configuration from 101 | /// any path. 102 | /// 103 | /// See [`ConfigFile::open`][fn-ConfigFile-open] if you want to open a configfile by name and 104 | /// prefix. 105 | /// 106 | /// # Parameters 107 | /// 108 | /// - `path`: A `Path` specifiying the configuration file on disk. 109 | /// 110 | /// # Returns 111 | /// 112 | /// A `Result` which is: 113 | /// 114 | /// - `Ok`: an instance of `ConfigFile` for the given `path`. 115 | /// - `Err`: an error, e.g. if the file does not exist. 116 | /// 117 | /// 118 | /// [fn-ConfigFile-open]: #method.open 119 | fn from_path + ?Sized>(path: &P) -> Result; 120 | 121 | /// Get a list of all configfile names. 122 | /// 123 | /// This will check the current users XDG base directories for configuration files, and return a 124 | /// list of their names for use with e.g. [`ConfigFile::open`][fn-ConfigFile-open]. 125 | /// 126 | /// [fn-ConfigFile-open]: struct.Layout.html#method.open 127 | fn list() -> Vec; 128 | 129 | /// Returns the name of this configfile. 130 | /// 131 | /// As represented by the stem of the filename on disk. 132 | fn name(&self) -> String; 133 | 134 | /// Opens an existing configfile using a `name`. 135 | /// 136 | /// This will search for a matching configfile in the XDG directories. 137 | /// 138 | /// See [`ConfigFile::from_path`][fn-ConfigFile-from_path] if you want to open a configfile 139 | /// using any path. 140 | /// 141 | /// # Parameters 142 | /// 143 | /// - `name`: A `OsStr` naming the configuration file on disk. 144 | /// 145 | /// # Returns 146 | /// 147 | /// A `Result` which is: 148 | /// 149 | /// - `Ok`: an instance of `ConfigFile` for the given `name`. 150 | /// - `Err`: an error, e.g. if the file does not exist. 151 | /// 152 | /// 153 | /// [fn-ConfigFile-from_path]: #method.from_path 154 | fn open + ?Sized>(name: &S) -> Result; 155 | 156 | /// Returns the path to the configfile. 157 | fn path(&self) -> PathBuf; 158 | 159 | /// Return the prefix associated with this type of configfile. 160 | fn prefix() -> &'static OsStr; 161 | 162 | /// Rename the current configfile. 163 | /// 164 | /// # Parameters 165 | /// 166 | /// - `new_name`: A `OsStr` that is the name of the destination configfile. 167 | /// 168 | /// # Returns 169 | /// 170 | /// A `Result` which is: 171 | /// 172 | /// - `Ok`: an instance of `ConfigFile` for the renamed configfile. 173 | /// - `Err`: an error, e.g. if a configfile with `new_name` already exists or renaming the file 174 | /// failed. 175 | fn rename + ?Sized>(&self, new_name: &S) -> Result; 176 | 177 | /// This verifies the project's configuration, without storing it in the current project 178 | /// instance. 179 | /// 180 | /// # Returns 181 | /// 182 | /// A `Result` which is: 183 | /// 184 | /// - `Ok`: nothing (`()`) if the verification succeeded. 185 | /// - `Err`: an error if the configuration could not be parsed with details on what failed. 186 | fn verify(&self) -> Result<()>; 187 | } 188 | 189 | /// Helping type to consolidate common functionality between projects and layouts. 190 | #[derive(Debug, Clone, PartialEq, Eq)] 191 | pub struct ConfigFileImpl { 192 | prefix: OsString, 193 | 194 | /// The name of the configfile. 195 | /// 196 | /// As represented by the stem of the filename on disk. 197 | pub name: String, 198 | 199 | /// The path to the configfile. 200 | pub path: PathBuf, 201 | } 202 | 203 | impl ConfigFileImpl { 204 | /// Create a configfile given a `name` and `prefix`. 205 | /// 206 | /// This will not create the configuration file, but it will ensure a legal XDG path with all 207 | /// directories leading up to the file existing. 208 | /// 209 | /// If you want to pre-fill the configuration file with a template, see 210 | /// [`ConfigFile::create_from_template`][fn-ConfigFile-create_from_template]. 211 | /// 212 | /// # Parameters 213 | /// 214 | /// - `prefix`: A `OsStr` defining a prefix which is used as a sub-directory for the configfile. 215 | /// - `name`: A `OsStr` naming the configuration file on disk. 216 | /// 217 | /// # Returns 218 | /// 219 | /// A `Result` which is: 220 | /// 221 | /// - `Ok`: an instance of `ConfigFile` for the given `name`. 222 | /// - `Err`: an error, e.g. if the configfile already exists or couldn't be created. 223 | /// 224 | /// 225 | /// [fn-ConfigFile-create_from_template]: #method.create_from_template 226 | pub fn create + ?Sized>(prefix: &S, name: &S) -> Result { 227 | let path = config_path(prefix, name); 228 | 229 | if XDG_DIRS.find_config_file(&path).is_some() { 230 | Err(ErrorKind::ConfigExists( 231 | prefix.as_ref().to_string_lossy().into_owned(), 232 | name.as_ref().to_string_lossy().into_owned(), 233 | ) 234 | .into()) 235 | } else { 236 | XDG_DIRS 237 | .place_config_file(path) 238 | .map(|path| ConfigFileImpl { 239 | prefix: prefix.as_ref().to_owned(), 240 | name: name.as_ref().to_string_lossy().into_owned(), 241 | path, 242 | }) 243 | .map_err(|e| e.into()) 244 | } 245 | } 246 | 247 | /// Create a configfile given a `name`, pre-filling it with a given `template`. 248 | /// 249 | /// See [`ConfigFile::create`][fn-ConfigFile-create] for additional information. 250 | /// 251 | /// # Parameters 252 | /// 253 | /// - `prefix`: A `OsStr` defining a prefix which is used as a sub-directory for the configfile. 254 | /// - `name`: A `OsStr` naming the the configuration file on disk. 255 | /// - `template`: A byte-slice which will be written to the configuration file on disk. 256 | /// 257 | /// # Returns 258 | /// 259 | /// A `Result` which is: 260 | /// 261 | /// - `Ok`: an instance of `ConfigFile` for the given `name` with the contents of `template`. 262 | /// - `Err`: an error, e.g. if the configfile already exists or couldn't be created. 263 | /// 264 | /// 265 | /// [fn-ConfigFile-create]: #method.create 266 | pub fn create_from_template + ?Sized>( 267 | prefix: &S, 268 | name: &S, 269 | template: &[u8], 270 | ) -> Result { 271 | let configfile = ConfigFileImpl::create(prefix, name)?; 272 | 273 | // Copy template into config file 274 | let mut file = File::create(&configfile.path)?; 275 | file.write_all(template)?; 276 | file.flush()?; 277 | drop(file); 278 | 279 | Ok(configfile) 280 | } 281 | 282 | /// Opens an existing configfile using a `name`. 283 | /// 284 | /// This will search for a matching configfile in the XDG directories. 285 | /// 286 | /// See [`ConfigFile::from_path`][fn-ConfigFile-from_path] if you want to open a configfile 287 | /// using any path. 288 | /// 289 | /// # Parameters 290 | /// 291 | /// - `prefix`: A `OsStr` defining a prefix which is used as a sub-directory for the configfile. 292 | /// - `name`: A `OsStr` naming the configuration file on disk. 293 | /// 294 | /// # Returns 295 | /// 296 | /// A `Result` which is: 297 | /// 298 | /// - `Ok`: an instance of `ConfigFile` for the given `name`. 299 | /// - `Err`: an error, e.g. if the file does not exist. 300 | /// 301 | /// 302 | /// [fn-ConfigFile-from_path]: #method.from_path 303 | pub fn open + ?Sized>(prefix: &S, name: &S) -> Result { 304 | let path = config_path(prefix, name); 305 | let name = name.as_ref().to_string_lossy().into_owned(); 306 | 307 | XDG_DIRS 308 | .find_config_file(&path) 309 | .map(|path| ConfigFileImpl { 310 | prefix: prefix.as_ref().to_owned(), 311 | name: name.to_owned(), 312 | path, 313 | }) 314 | .ok_or_else(|| { 315 | ErrorKind::UnknownConfig(prefix.as_ref().to_string_lossy().into_owned(), name) 316 | .into() 317 | }) 318 | } 319 | } 320 | 321 | impl ConfigFile for ConfigFileImpl { 322 | fn copy + ?Sized>(&self, new_name: &S) -> Result { 323 | let new_configfile = ConfigFileImpl::create(self.prefix.as_os_str(), new_name.as_ref())?; 324 | fs::copy(&self.path, &new_configfile.path)?; 325 | Ok(new_configfile) 326 | } 327 | 328 | fn create + ?Sized>(name: &S) -> Result { 329 | Err(ErrorKind::UnknownConfig( 330 | "NO PREFIX".to_owned(), 331 | name.as_ref().to_string_lossy().into_owned(), 332 | ) 333 | .into()) 334 | } 335 | 336 | fn create_from_template + ?Sized>(name: &S, _template: &[u8]) -> Result { 337 | Err(ErrorKind::UnknownConfig( 338 | "NO PREFIX".to_owned(), 339 | name.as_ref().to_string_lossy().into_owned(), 340 | ) 341 | .into()) 342 | } 343 | 344 | fn delete(&self) -> Result<()> { 345 | fs::remove_file(&self.path)?; 346 | Ok(()) 347 | } 348 | 349 | fn from_path + ?Sized>(path: &P) -> Result { 350 | let path = path.as_ref(); 351 | 352 | if !path.exists() || !path.is_file() { 353 | Err(ErrorKind::PathDoesntExist(path.to_string_lossy().into_owned()).into()) 354 | } else { 355 | Ok(ConfigFileImpl { 356 | prefix: "local".to_owned().into(), 357 | name: "local".to_owned(), 358 | path: path.to_path_buf(), 359 | }) 360 | } 361 | } 362 | 363 | fn list() -> Vec { 364 | // This cannot be implemented. 365 | vec![] 366 | } 367 | 368 | fn name(&self) -> String { 369 | self.name.to_owned() 370 | } 371 | 372 | fn open + ?Sized>(name: &S) -> Result { 373 | Err(ErrorKind::UnknownConfig( 374 | "NO PREFIX".to_owned(), 375 | name.as_ref().to_string_lossy().into_owned(), 376 | ) 377 | .into()) 378 | } 379 | 380 | fn path(&self) -> PathBuf { 381 | self.path.to_owned() 382 | } 383 | 384 | fn prefix() -> &'static OsStr { 385 | OsStr::new("") 386 | } 387 | 388 | fn rename + ?Sized>(&self, new_name: &S) -> Result { 389 | // Create new configfile 390 | let new_configfile = ConfigFileImpl::create(self.prefix.as_os_str(), new_name.as_ref())?; 391 | // Rename old configfile 392 | fs::rename(&self.path, &new_configfile.path)?; 393 | 394 | Ok(new_configfile) 395 | } 396 | 397 | fn verify(&self) -> Result<()> { 398 | Ok(()) 399 | } 400 | } 401 | 402 | fn config_path + ?Sized>(prefix: &S, name: &S) -> PathBuf { 403 | let mut path = OsString::new(); 404 | path.push(prefix); 405 | path.push("/"); 406 | path.push(name); 407 | path.push(".toml"); 408 | 409 | path.into() 410 | } 411 | 412 | /// Get a list of all configfile names for a given prefix. 413 | /// 414 | /// This will check the current users XDG base directories for configuration files, and return a 415 | /// list of their names for use with e.g. [`ConfigFile::open`][fn-ConfigFile-open]. 416 | /// 417 | /// [fn-ConfigFile-open]: struct.Layout.html#method.open 418 | pub fn list + ?Sized>(prefix: &S) -> Vec { 419 | let mut files = XDG_DIRS.list_config_files_once(prefix.as_ref().to_string_lossy().into_owned()); 420 | files.sort(); 421 | files 422 | .iter() 423 | .filter_map(|file| file.file_stem()) 424 | .map(OsStr::to_os_string) 425 | .collect::>() 426 | } 427 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | //! Errors, using [`error-chain`][error-chain]. 10 | //! 11 | //! [error-chain]: https://crates.io/crates/error-chain 12 | 13 | use error_chain::error_chain; 14 | 15 | error_chain! { 16 | foreign_links { 17 | I3EstablishError(::i3ipc::EstablishError) 18 | #[doc = "Error caused by `i3ipc`, on establishing a connection."]; 19 | 20 | I3MessageError(::i3ipc::MessageError) 21 | #[doc = "Error caused by `i3ipc`, on sending a message."]; 22 | 23 | IoError(::std::io::Error) 24 | #[doc = "Error mapping to `std::io::Error`."]; 25 | 26 | Utf8Error(::std::str::Utf8Error) 27 | #[doc = "Error mapping to `std::str::Utf8Error`."]; 28 | 29 | TomlError(::toml::de::Error) 30 | #[doc = "Error caused by `toml`, on deserializing using Serde."]; 31 | } 32 | 33 | errors { 34 | /// An error that occurs if a trait-function is called that cannot be implemented. 35 | /// 36 | /// (This is pretty unclean but is currently required as `ConfigFileImpl` cannot implement 37 | /// any of the static functions of the `ConfigFile` trait since it is missing the required 38 | /// prefix.) 39 | CantBeImplemented(t: String) { 40 | description("called function cannot be implemented") 41 | display("called function cannot be implemented: '{}'", t) 42 | } 43 | 44 | /// An error that can occur when splitting a string into a 45 | /// [`ApplicationCommand`][struct-ApplicationCommand]. 46 | /// 47 | /// [struct-ApplicationCommand]: ../types/struct.ApplicationCommand.html 48 | CommandSplittingFailed(t: String) { 49 | description("command splitting failed") 50 | display("command splitting failed: '{}'", t) 51 | } 52 | 53 | /// An error that occurs if a project under the same name already exists. 54 | ConfigExists(p: String, t: String) { 55 | description("config already exists") 56 | display("config of type '{}' already exists: '{}'", p, t) 57 | } 58 | 59 | /// An error that occurs when the default editor is not specified. 60 | /// 61 | /// One of the environment variables `$VISUAL` or `$EDITOR` has to be set. 62 | EditorNotFound { 63 | description("cannot find an editor") 64 | display("cannot find an editor. Please specify $VISUAL or $EDITOR") 65 | } 66 | 67 | /// An error that occurs when a `Path` (i.e. `OsStr`) cannot be converted to UTF8. 68 | InvalidUtF8Path(t: String) { 69 | description("path is invalid UTF8") 70 | display("path is invalid UTF8: '{}'", t) 71 | } 72 | 73 | /// An error that occurs if a specified path does not exist. 74 | PathDoesntExist(t: String) { 75 | description("path doesn't exist") 76 | display("path doesn't exist: '{}'", t) 77 | } 78 | 79 | /// An error that occurs if text or key-presses could not be input into an application. 80 | TextOrKeyInputFailed { 81 | description("text or key input failed") 82 | display("inputting text or key-presses into an application failed") 83 | } 84 | 85 | /// An error that occurs if a project does not exist under a specified name. 86 | UnknownConfig(p: String, t: String) { 87 | description("config is unknown") 88 | display("config of type '{}' is unknown: '{}'", p, t) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/layouts.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | //! Module for layout handling. 10 | 11 | use crate::{ 12 | configfiles::{self, ConfigFile, ConfigFileImpl}, 13 | errors::*, 14 | }; 15 | use lazy_static::lazy_static; 16 | use std::{ 17 | ffi::{OsStr, OsString}, 18 | ops::Deref, 19 | path::{Path, PathBuf}, 20 | }; 21 | 22 | lazy_static! { 23 | static ref LAYOUTS_PREFIX: OsString = OsString::from("layouts"); 24 | } 25 | 26 | /// A structure representing a managed i3-layout. 27 | #[derive(Debug, Clone, PartialEq, Eq)] 28 | pub struct Layout { 29 | configfile: ConfigFileImpl, 30 | 31 | /// The name of the layout. 32 | /// 33 | /// As represented by the stem of the filename on disk. 34 | pub name: String, 35 | 36 | /// The path to the layout configuration. 37 | pub path: PathBuf, 38 | } 39 | 40 | impl Deref for Layout { 41 | type Target = ConfigFileImpl; 42 | 43 | fn deref(&self) -> &ConfigFileImpl { 44 | &self.configfile 45 | } 46 | } 47 | 48 | impl Layout { 49 | fn from_configfile(configfile: ConfigFileImpl) -> Self { 50 | let name = configfile.name.to_owned(); 51 | let path = configfile.path.clone(); 52 | 53 | Layout { 54 | configfile, 55 | name, 56 | path, 57 | } 58 | } 59 | } 60 | 61 | impl ConfigFile for Layout { 62 | fn create + ?Sized>(name: &S) -> Result { 63 | let configfile = ConfigFileImpl::create(LAYOUTS_PREFIX.as_os_str(), name.as_ref())?; 64 | Ok(Layout::from_configfile(configfile)) 65 | } 66 | 67 | fn create_from_template + ?Sized>(name: &S, template: &[u8]) -> Result { 68 | let configfile = ConfigFileImpl::create_from_template( 69 | LAYOUTS_PREFIX.as_os_str(), 70 | name.as_ref(), 71 | template, 72 | )?; 73 | Ok(Layout::from_configfile(configfile)) 74 | } 75 | 76 | fn from_path + ?Sized>(path: &P) -> Result { 77 | let configfile = ConfigFileImpl::from_path(path)?; 78 | Ok(Layout::from_configfile(configfile)) 79 | } 80 | 81 | fn open + ?Sized>(name: &S) -> Result { 82 | let configfile = ConfigFileImpl::open(LAYOUTS_PREFIX.as_os_str(), name.as_ref())?; 83 | Ok(Layout::from_configfile(configfile)) 84 | } 85 | 86 | fn copy + ?Sized>(&self, new_name: &S) -> Result { 87 | let configfile = self.configfile.copy(new_name)?; 88 | Ok(Layout::from_configfile(configfile)) 89 | } 90 | 91 | fn delete(&self) -> Result<()> { 92 | self.configfile.delete()?; 93 | Ok(()) 94 | } 95 | 96 | fn rename + ?Sized>(&self, new_name: &S) -> Result { 97 | let configfile = self.configfile.rename(new_name)?; 98 | Ok(Layout::from_configfile(configfile)) 99 | } 100 | 101 | fn verify(&self) -> Result<()> { 102 | Ok(()) 103 | } 104 | 105 | fn list() -> Vec { 106 | configfiles::list(&*LAYOUTS_PREFIX) 107 | } 108 | 109 | fn name(&self) -> String { 110 | self.name.to_owned() 111 | } 112 | 113 | fn path(&self) -> PathBuf { 114 | self.path.to_owned() 115 | } 116 | 117 | fn prefix() -> &'static OsStr { 118 | &*LAYOUTS_PREFIX 119 | } 120 | } 121 | 122 | /// Get a list of all layout names. 123 | /// 124 | /// This will check the current users XDG base directories for `i3nator` layout configurations, 125 | /// and return a list of their names for use with e.g. [`Layout::open`][fn-Layout-open]. 126 | /// 127 | /// [fn-Layout-open]: struct.Layout.html#method.open 128 | pub fn list() -> Vec { 129 | configfiles::list(&*LAYOUTS_PREFIX) 130 | } 131 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | //! # i3nator 10 | //! 11 | //! i3nator is [Tmuxinator][gh-tmuxinator] for the [i3 window manager][i3wm]. 12 | //! 13 | //! It allows you to manage what are called "projects", which are used to easily restore saved i3 14 | //! layouts (see [Layout saving in i3][i3wm-layout-saving]) and extending i3's base functionality 15 | //! by allowing you to automatically start applications too. 16 | //! 17 | //! For detailed introductions, see the [README][github-readme]. 18 | //! 19 | //! [github-readme]: https://github.com/pitkley/i3nator#readme 20 | //! 21 | //! ## License 22 | //! 23 | //! DFW is licensed under either of 24 | //! 25 | //! * Apache License, Version 2.0, () 26 | //! * MIT license () 27 | //! 28 | //! at your option. 29 | 30 | #![recursion_limit = "1024"] // `error_chain!` can recurse deeply 31 | #![deny(missing_docs)] 32 | 33 | pub mod configfiles; 34 | pub mod errors; 35 | pub mod layouts; 36 | pub mod projects; 37 | mod shlex; 38 | pub mod types; 39 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | mod cli; 10 | mod errors { 11 | use error_chain::error_chain; 12 | 13 | error_chain! { 14 | foreign_links { 15 | I3EstablishError(::i3ipc::EstablishError); 16 | I3MessageError(::i3ipc::MessageError); 17 | IoError(::std::io::Error); 18 | } 19 | 20 | links { 21 | Lib(::i3nator::errors::Error, ::i3nator::errors::ErrorKind); 22 | } 23 | 24 | errors { 25 | EditorNotFound { 26 | description("cannot find an editor") 27 | display("cannot find an editor. Please specify $VISUAL or $EDITOR") 28 | } 29 | 30 | NoConfigExist { 31 | description("no configfiles exist") 32 | display("no configfiles exist. Feel free to create one") 33 | } 34 | } 35 | } 36 | } 37 | 38 | use crate::errors::*; 39 | use clap::Parser; 40 | use error_chain::quick_main; 41 | use getch::Getch; 42 | use i3ipc::I3Connection; 43 | use i3nator::{configfiles::ConfigFile, layouts::Layout, projects::Project}; 44 | use lazy_static::lazy_static; 45 | use std::{ 46 | convert::Into, 47 | env, 48 | ffi::{OsStr, OsString}, 49 | fs::File, 50 | io::{stdin, BufReader, Read}, 51 | process::{Command, ExitStatus}, 52 | }; 53 | 54 | static PROJECT_TEMPLATE: &[u8] = include_bytes!("../resources/project_template.toml"); 55 | 56 | lazy_static! { 57 | static ref GETCH: Getch = Getch::new(); 58 | } 59 | 60 | fn command_copy( 61 | existing_configfile_name: &OsStr, 62 | new_configfile_name: &OsStr, 63 | no_edit: bool, 64 | no_verify: bool, 65 | ) -> Result<()> { 66 | let existing_configfile = C::open(existing_configfile_name)?; 67 | let new_configfile = existing_configfile.copy(new_configfile_name)?; 68 | 69 | println!( 70 | "Copied existing configfile '{}' to new configfile '{}'", 71 | existing_configfile.name(), 72 | new_configfile.name() 73 | ); 74 | 75 | // Open config file for editing 76 | if !no_edit { 77 | open_editor(&new_configfile)?; 78 | if !no_verify { 79 | verify_configfile(&new_configfile)?; 80 | } 81 | } 82 | 83 | Ok(()) 84 | } 85 | 86 | fn command_delete>(configfiles: &[S]) -> Result<()> { 87 | for configfile_name in configfiles { 88 | C::open(configfile_name)?.delete()?; 89 | println!( 90 | "Deleted configfile '{}'", 91 | configfile_name.as_ref().to_string_lossy() 92 | ); 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | fn command_edit(configfile_name: &OsStr, no_verify: bool) -> Result<()> { 99 | let configfile = C::open(configfile_name)?; 100 | 101 | open_editor(&configfile)?; 102 | 103 | // Verify configfile contents 104 | if !no_verify { 105 | verify_configfile(&configfile)?; 106 | } 107 | 108 | Ok(()) 109 | } 110 | 111 | fn command_info(configfile_name: &OsStr) -> Result<()> { 112 | let configfile = C::open(configfile_name)?; 113 | 114 | println!("Name: {}", configfile.name()); 115 | println!( 116 | "Configuration path: {}", 117 | configfile.path().to_string_lossy() 118 | ); 119 | println!( 120 | "Configuration valid: {}", 121 | if configfile.verify().is_ok() { 122 | "yes" 123 | } else { 124 | "NO" 125 | } 126 | ); 127 | 128 | Ok(()) 129 | } 130 | 131 | fn command_list(quiet: bool) -> Result<()> { 132 | let configfiles = C::list(); 133 | 134 | if configfiles.is_empty() { 135 | Err(ErrorKind::NoConfigExist.into()) 136 | } else { 137 | if !quiet { 138 | println!("i3nator {}:", C::prefix().to_string_lossy()); 139 | } 140 | for configfile in configfiles { 141 | if quiet { 142 | println!("{}", configfile.to_string_lossy()); 143 | } else { 144 | println!(" {}", configfile.to_string_lossy()); 145 | } 146 | } 147 | 148 | Ok(()) 149 | } 150 | } 151 | 152 | fn command_rename( 153 | existing_configfile_name: &OsStr, 154 | new_configfile_name: &OsStr, 155 | edit: bool, 156 | no_verify: bool, 157 | ) -> Result<()> { 158 | let existing_configfile = C::open(existing_configfile_name)?; 159 | println!( 160 | "Renaming configfile from '{}' to '{}'", 161 | existing_configfile_name.to_string_lossy(), 162 | new_configfile_name.to_string_lossy() 163 | ); 164 | let new_configfile = existing_configfile.rename(new_configfile_name)?; 165 | 166 | // Open editor for new configfile if desired 167 | if edit { 168 | open_editor(&new_configfile)?; 169 | if !no_verify { 170 | verify_configfile(&new_configfile)?; 171 | } 172 | } 173 | 174 | Ok(()) 175 | } 176 | 177 | fn project_local( 178 | project_path: &OsStr, 179 | working_directory: Option<&OsStr>, 180 | workspace: Option<&str>, 181 | ) -> Result<()> { 182 | let mut project = Project::from_path(project_path)?; 183 | let mut i3 = I3Connection::connect()?; 184 | 185 | println!("Starting project '{}'", project.name); 186 | project.start(&mut i3, working_directory, workspace)?; 187 | 188 | Ok(()) 189 | } 190 | 191 | fn project_new(project_name: &OsStr, no_edit: bool, no_verify: bool) -> Result<()> { 192 | let project = Project::create_from_template(project_name, PROJECT_TEMPLATE)?; 193 | println!("Created project '{}'", project.name); 194 | 195 | // Open config file for editing 196 | if !no_edit { 197 | open_editor(&project)?; 198 | if !no_verify { 199 | verify_configfile(&project)?; 200 | } 201 | } 202 | 203 | Ok(()) 204 | } 205 | 206 | fn project_start( 207 | project_name: &OsStr, 208 | working_directory: Option<&OsStr>, 209 | workspace: Option<&str>, 210 | ) -> Result<()> { 211 | let mut project = Project::open(project_name)?; 212 | let mut i3 = I3Connection::connect()?; 213 | 214 | println!("Starting project '{}'", project.name); 215 | project.start(&mut i3, working_directory, workspace)?; 216 | 217 | Ok(()) 218 | } 219 | 220 | fn project_verify>(configfiles: &[S]) -> Result<()> { 221 | // The list of config-fiels can be empty. If so, use the entire configfile list. 222 | let mut configfiles: Vec = configfiles 223 | .iter() 224 | .map(|v| v.as_ref().to_os_string()) 225 | .collect::>(); 226 | if configfiles.is_empty() { 227 | configfiles = Project::list(); 228 | } 229 | 230 | for configfile_name in configfiles { 231 | if let Err(e) = Project::open(&configfile_name)?.verify() { 232 | println!( 233 | "Configuration INVALID: '{}'", 234 | configfile_name.to_string_lossy() 235 | ); 236 | println!("Error:"); 237 | println!(" {}", e); 238 | println!(); 239 | } else { 240 | println!( 241 | "Configuration VALID: '{}'", 242 | configfile_name.to_string_lossy() 243 | ); 244 | } 245 | } 246 | 247 | Ok(()) 248 | } 249 | 250 | fn layout_new(layout_name: &OsStr, template: Option<&OsStr>, no_edit: bool) -> Result<()> { 251 | let layout = if let Some(template) = template { 252 | // Open appropriate reader 253 | let stdin_; 254 | let reader: Box = if template == "-" { 255 | stdin_ = stdin(); 256 | Box::new(stdin_.lock()) 257 | } else { 258 | Box::new(File::open(template)?) 259 | }; 260 | let mut reader = BufReader::new(reader); 261 | 262 | // Load bytes from reader 263 | let mut bytes: Vec = Vec::new(); 264 | reader.read_to_end(&mut bytes)?; 265 | 266 | // Create layout from template 267 | Layout::create_from_template(layout_name, &bytes)? 268 | } else { 269 | Layout::create(layout_name)? 270 | }; 271 | println!("Created layout '{}'", layout.name); 272 | 273 | // Open config file for editing 274 | if !no_edit { 275 | open_editor(&layout)?; 276 | } 277 | 278 | Ok(()) 279 | } 280 | 281 | fn get_editor() -> Result { 282 | env::var_os("VISUAL") 283 | .or_else(|| env::var_os("EDITOR")) 284 | .and_then(|s| if !s.is_empty() { Some(s) } else { None }) 285 | .ok_or_else(|| ErrorKind::EditorNotFound.into()) 286 | } 287 | 288 | fn open_editor(configfile: &C) -> Result { 289 | println!("Opening your editor to edit '{}'", configfile.name()); 290 | Command::new(get_editor()?) 291 | .arg(configfile.path().as_os_str()) 292 | .status() 293 | .map_err(|e| e.into()) 294 | } 295 | 296 | fn verify_configfile(configfile: &C) -> Result<()> { 297 | while let Err(e) = configfile.verify() { 298 | println!(); 299 | println!("VERIFICATION FAILED!"); 300 | println!("Error:"); 301 | println!(" {}", e); 302 | println!(); 303 | 304 | let mut ch: Option; 305 | while { 306 | println!("What do you want to do?"); 307 | println!("(R)eopen editor, (A)ccept anyway"); 308 | 309 | ch = GETCH 310 | .getch() 311 | .ok() 312 | .map(|byte| byte.to_ascii_lowercase()) 313 | .map(|byte| byte as char); 314 | 315 | if ch.is_none() { 316 | true 317 | } else { 318 | !matches!(ch, Some('a') | Some('r')) 319 | } 320 | } { 321 | // Ugly do-while syntax: 322 | // https://gist.github.com/huonw/8435502 323 | } 324 | 325 | match ch { 326 | Some('a') => break, 327 | Some('r') => open_editor(configfile)?, 328 | _ => continue, 329 | }; 330 | } 331 | 332 | Ok(()) 333 | } 334 | 335 | fn run() -> Result<()> { 336 | let clap = cli::Cli::parse(); 337 | match &clap.command { 338 | cli::Commands::Project(project_commands) 339 | | cli::Commands::FlattenedProject(project_commands) => match project_commands { 340 | cli::ProjectCommands::Copy { 341 | existing, 342 | new, 343 | no_edit, 344 | no_verify, 345 | } => command_copy::(existing, new, *no_edit, *no_verify), 346 | cli::ProjectCommands::Delete { names } => command_delete::(&names[..]), 347 | cli::ProjectCommands::Edit { name, no_verify } => { 348 | command_edit::(name, *no_verify) 349 | } 350 | cli::ProjectCommands::Info { name } => command_info::(name), 351 | cli::ProjectCommands::List { quiet } => command_list::(*quiet), 352 | cli::ProjectCommands::Local { 353 | file, 354 | working_directory, 355 | workspace, 356 | } => project_local(file, working_directory.as_deref(), workspace.as_deref()), 357 | cli::ProjectCommands::New { 358 | name, 359 | no_edit, 360 | no_verify, 361 | } => project_new(name, *no_edit, *no_verify), 362 | cli::ProjectCommands::Rename { 363 | existing, 364 | new, 365 | edit, 366 | no_verify, 367 | } => command_rename::(existing, new, *edit, *no_verify), 368 | cli::ProjectCommands::Start { 369 | name, 370 | working_directory, 371 | workspace, 372 | } => project_start(name, working_directory.as_deref(), workspace.as_deref()), 373 | cli::ProjectCommands::Verify { names } => project_verify(&names[..]), 374 | }, 375 | cli::Commands::Layout(layout_commands) => match layout_commands { 376 | cli::LayoutCommands::Copy { 377 | existing, 378 | new, 379 | no_edit, 380 | } => command_copy::(existing, new, *no_edit, false), 381 | cli::LayoutCommands::Delete { names } => command_delete::(&names[..]), 382 | cli::LayoutCommands::Edit { name } => command_edit::(name, false), 383 | cli::LayoutCommands::Info { name } => command_info::(name), 384 | cli::LayoutCommands::List { quiet } => command_list::(*quiet), 385 | cli::LayoutCommands::New { 386 | name, 387 | no_edit, 388 | template, 389 | } => layout_new(name, template.as_deref(), *no_edit), 390 | cli::LayoutCommands::Rename { 391 | existing, 392 | new, 393 | edit, 394 | } => command_rename::(existing, new, *edit, false), 395 | }, 396 | cli::Commands::GenerateShellCompletions { 397 | generator, 398 | output_path, 399 | } => cli::generate_completions(*generator, output_path.as_deref()).map_err(|e| e.into()), 400 | } 401 | } 402 | 403 | #[cfg(unix)] 404 | quick_main!(run); 405 | -------------------------------------------------------------------------------- /src/projects.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | //! Module for project handling. 10 | 11 | use crate::{ 12 | configfiles::{self, ConfigFile, ConfigFileImpl}, 13 | errors::*, 14 | layouts::Layout as ManagedLayout, 15 | types::*, 16 | }; 17 | use i3ipc::I3Connection; 18 | use lazy_static::lazy_static; 19 | use std::{ 20 | ffi::{OsStr, OsString}, 21 | fs::File, 22 | io::{prelude::*, BufReader}, 23 | ops::Deref, 24 | path::{Path, PathBuf}, 25 | process::{Child, Command, Stdio}, 26 | time::Duration, 27 | }; 28 | use tempfile::NamedTempFile; 29 | use wait_timeout::ChildExt; 30 | 31 | lazy_static! { 32 | static ref PROJECTS_PREFIX: OsString = OsString::from("projects"); 33 | } 34 | 35 | /// A structure representing a `i3nator` project. 36 | #[derive(Debug, Clone, PartialEq, Eq)] 37 | pub struct Project { 38 | configfile: ConfigFileImpl, 39 | 40 | /// The name of the project. 41 | /// 42 | /// As represented by the stem of the filename on disk. 43 | pub name: String, 44 | 45 | /// The path to the project configuration. 46 | pub path: PathBuf, 47 | 48 | config: Option, 49 | } 50 | 51 | impl Deref for Project { 52 | type Target = ConfigFileImpl; 53 | 54 | fn deref(&self) -> &ConfigFileImpl { 55 | &self.configfile 56 | } 57 | } 58 | 59 | impl Project { 60 | fn from_configfile(configfile: ConfigFileImpl) -> Self { 61 | let name = configfile.name.to_owned(); 62 | let path = configfile.path.clone(); 63 | 64 | Project { 65 | configfile, 66 | name, 67 | path, 68 | config: None, 69 | } 70 | } 71 | 72 | fn load(&self) -> Result { 73 | let mut file = BufReader::new(File::open(&self.path)?); 74 | let mut contents = String::new(); 75 | file.read_to_string(&mut contents)?; 76 | toml::from_str::(&contents).map_err(|e| e.into()) 77 | } 78 | 79 | /// Gets the project's configuration, loading and storing it in the current project instance if 80 | /// it hasn't been before. 81 | /// 82 | /// # Returns 83 | /// 84 | /// A `Result` which is: 85 | /// 86 | /// - `Ok`: an instance of [`Config`][struct-Config] for the project. 87 | /// - `Err`: an error, e.g. if parsing the configuration failed. 88 | /// 89 | /// If you only want to check if the configuration is valid, without modifying the project 90 | /// instance, you can use [`Project::verify`][fn-Project-verify]. 91 | /// 92 | /// 93 | /// [struct-Config]: ../types/struct.Config.html 94 | /// [fn-Project-verify]: #method.verify 95 | pub fn config(&mut self) -> Result<&Config> { 96 | if self.config.is_none() { 97 | self.config = Some(self.load()?); 98 | } 99 | 100 | Ok(self.config.as_ref().unwrap()) 101 | } 102 | 103 | /// Start the project. 104 | /// 105 | /// This will: 106 | /// 107 | /// 1. append the specified layout to a given workspace, 108 | /// 2. start the specified applications. 109 | /// 3. execute commands in the applications, if specified. 110 | /// 111 | /// Command execution is achieved through the use of [`xdotool`][xdotool], which in turn 112 | /// simulates key-events through X11 in applications. This is not without problems, though. 113 | /// Some applications do not react to `SendEvent`s, at least by default. 114 | /// 115 | /// One example: in `xterm` you have to specifically enable for `SendEvent`s to be processed. 116 | /// This can be done through the the [`XTerm.vt100.allowSendEvents`][xterm-allow-send-events] 117 | /// resource, which ensures that `SendEvent`s are activated when `xterm` starts. 118 | /// 119 | /// # Parameters: 120 | /// 121 | /// - `i3`: An `I3Connection` to append the layout to a given workspace. 122 | /// - `working_directory`: An optional working directory which overrides any specified working 123 | /// directories in the project configuration. 124 | /// - `workspace`: An optional workspace which overrides the specified workspace in the project 125 | /// configuration. 126 | /// 127 | /// # Returns: 128 | /// 129 | /// A `Result` which is: 130 | /// 131 | /// - `Ok`: nothing (`()`). 132 | /// - `Err`: an error, if: 133 | /// 134 | /// - the configuration is invalid, 135 | /// - if a `layout` was specified but could not be stored in a temporary file, 136 | /// - an i3-command failed, 137 | /// - an application could not be started, 138 | /// - a command could not be sent to an application. 139 | /// 140 | /// 141 | /// [xdotool]: https://github.com/jordansissel/xdotool 142 | /// [xterm-allow-send-events]: https://www.x.org/archive/X11R6.7.0/doc/xterm.1.html#sect6 143 | pub fn start( 144 | &mut self, 145 | i3: &mut I3Connection, 146 | working_directory: Option<&OsStr>, 147 | workspace: Option<&str>, 148 | ) -> Result<()> { 149 | let config = self.config()?; 150 | let general = &config.general; 151 | 152 | // Determine if the layout is a path or the actual contents. 153 | let mut tempfile; 154 | let managed_layout_path; 155 | let path: &Path = match general.layout { 156 | Layout::Contents(ref contents) => { 157 | tempfile = NamedTempFile::new()?; 158 | tempfile.write_all(contents.as_bytes())?; 159 | tempfile.flush()?; 160 | tempfile.path() 161 | } 162 | Layout::Managed(ref name) => { 163 | managed_layout_path = ManagedLayout::open(&name)?.path; 164 | &managed_layout_path 165 | } 166 | Layout::Path(ref path) => path, 167 | }; 168 | 169 | // Change workspace if provided 170 | let workspace = workspace 171 | .map(Into::into) 172 | .or_else(|| general.workspace.as_ref().cloned()); 173 | if let Some(ref workspace) = workspace { 174 | i3.run_command(&format!("workspace {}", workspace))?; 175 | } 176 | 177 | // Append the layout to the workspace 178 | i3.run_command(&format!( 179 | "append_layout {}", 180 | path.to_str() 181 | .ok_or_else(|| ErrorKind::InvalidUtF8Path(path.to_string_lossy().into_owned()))? 182 | ))?; 183 | 184 | // Start the applications 185 | let applications = &config.applications; 186 | for application in applications { 187 | let mut cmd = Command::new(&application.command.program); 188 | cmd.args(&application.command.args); 189 | 190 | // Get working directory. Precedence is as follows: 191 | // 1. `--working-directory` command-line parameter 192 | // 2. `working_directory` option in config for application 193 | // 3. `working_directory` option in the general section of the config 194 | let working_directory = working_directory 195 | .map(OsStr::to_os_string) 196 | .or_else(|| application.working_directory.as_ref().map(OsString::from)) 197 | .or_else(|| general.working_directory.as_ref().map(OsString::from)); 198 | 199 | if let Some(working_directory) = working_directory { 200 | cmd.current_dir(working_directory); 201 | } 202 | 203 | let child = cmd 204 | .stdin(Stdio::null()) 205 | .stdout(Stdio::null()) 206 | .stderr(Stdio::null()) 207 | .spawn()?; 208 | 209 | // Input text into application, if any 210 | if let Some(ref exec) = application.exec { 211 | exec_commands(&child, exec)?; 212 | } 213 | } 214 | 215 | Ok(()) 216 | } 217 | } 218 | 219 | impl ConfigFile for Project { 220 | fn create + ?Sized>(name: &S) -> Result { 221 | let configfile = ConfigFileImpl::create(PROJECTS_PREFIX.as_os_str(), name.as_ref())?; 222 | Ok(Project::from_configfile(configfile)) 223 | } 224 | 225 | fn create_from_template + ?Sized>(name: &S, template: &[u8]) -> Result { 226 | let configfile = ConfigFileImpl::create_from_template( 227 | PROJECTS_PREFIX.as_os_str(), 228 | name.as_ref(), 229 | template, 230 | )?; 231 | Ok(Project::from_configfile(configfile)) 232 | } 233 | 234 | fn from_path + ?Sized>(path: &P) -> Result { 235 | let configfile = ConfigFileImpl::from_path(path)?; 236 | Ok(Project::from_configfile(configfile)) 237 | } 238 | 239 | fn open + ?Sized>(name: &S) -> Result { 240 | let configfile = ConfigFileImpl::open(PROJECTS_PREFIX.as_os_str(), name.as_ref())?; 241 | Ok(Project::from_configfile(configfile)) 242 | } 243 | 244 | fn copy + ?Sized>(&self, new_name: &S) -> Result { 245 | let configfile = self.configfile.copy(new_name)?; 246 | Ok(Project::from_configfile(configfile)) 247 | } 248 | 249 | fn delete(&self) -> Result<()> { 250 | self.configfile.delete()?; 251 | Ok(()) 252 | } 253 | 254 | fn rename + ?Sized>(&self, new_name: &S) -> Result { 255 | let configfile = self.configfile.rename(new_name)?; 256 | Ok(Project::from_configfile(configfile)) 257 | } 258 | 259 | fn verify(&self) -> Result<()> { 260 | // Verify configuration can be loaded 261 | let config = self.load()?; 262 | 263 | // Collect all loaded paths 264 | let mut paths: Vec<&Path> = vec![]; 265 | if let Some(ref p) = config.general.working_directory { 266 | paths.push(p); 267 | } 268 | 269 | match config.general.layout { 270 | Layout::Contents(_) => (), 271 | Layout::Managed(ref name) => { 272 | ManagedLayout::open(name)?; 273 | } 274 | Layout::Path(ref path) => paths.push(path), 275 | } 276 | 277 | for application in &config.applications { 278 | if let Some(ref p) = application.working_directory { 279 | paths.push(p); 280 | } 281 | } 282 | 283 | // Verify that all paths exist 284 | for path in paths { 285 | if !path.exists() { 286 | return Err(ErrorKind::PathDoesntExist(path.to_string_lossy().into_owned()).into()); 287 | } 288 | } 289 | 290 | Ok(()) 291 | } 292 | 293 | fn list() -> Vec { 294 | configfiles::list(&*PROJECTS_PREFIX) 295 | } 296 | 297 | fn name(&self) -> String { 298 | self.name.to_owned() 299 | } 300 | 301 | fn path(&self) -> PathBuf { 302 | self.path.to_owned() 303 | } 304 | 305 | fn prefix() -> &'static OsStr { 306 | &*PROJECTS_PREFIX 307 | } 308 | } 309 | 310 | /// Get a list of all project names. 311 | /// 312 | /// This will check the current users XDG base directories for `i3nator` project configurations, 313 | /// and return a list of their names for use with e.g. [`Project::open`][fn-Project-open]. 314 | /// 315 | /// [fn-Project-open]: struct.Project.html#method.open 316 | pub fn list() -> Vec { 317 | configfiles::list(&*PROJECTS_PREFIX) 318 | } 319 | 320 | fn exec_text(base_parameters: &[&str], text: &str, timeout: Duration) -> Result<()> { 321 | let args = &[base_parameters, &["type", "--window", "%1", text]].concat(); 322 | let mut child = Command::new("xdotool") 323 | .args(args) 324 | .stdin(Stdio::null()) 325 | .stdout(Stdio::null()) 326 | .stderr(Stdio::null()) 327 | .spawn()?; 328 | 329 | // Return of `wait_timeout` is `None` if the process didn't exit. 330 | if child.wait_timeout(timeout)?.is_none() { 331 | // Kill the xdotool process, return error 332 | child.kill()?; 333 | child.wait()?; 334 | Err(ErrorKind::TextOrKeyInputFailed.into()) 335 | } else { 336 | Ok(()) 337 | } 338 | } 339 | 340 | fn exec_keys>( 341 | base_parameters: &[&str], 342 | keys: &[S], 343 | timeout: Duration, 344 | ) -> Result<()> { 345 | let args = &[base_parameters, &["key", "--window", "%1"]].concat(); 346 | let mut child = Command::new("xdotool") 347 | .args(args) 348 | .args(keys) 349 | .stdin(Stdio::null()) 350 | .stdout(Stdio::null()) 351 | .stderr(Stdio::null()) 352 | .spawn()?; 353 | 354 | // Return of `wait_timeout` is `None` if the process didn't exit. 355 | if child.wait_timeout(timeout)?.is_none() { 356 | // Kill the xdotool process, return error 357 | child.kill()?; 358 | child.wait()?; 359 | Err(ErrorKind::TextOrKeyInputFailed.into()) 360 | } else { 361 | Ok(()) 362 | } 363 | } 364 | 365 | fn exec_commands(child: &Child, exec: &Exec) -> Result<()> { 366 | let timeout = exec.timeout; 367 | let pid = child.id().to_string(); 368 | let base_parameters = &[ 369 | "search", 370 | "--sync", 371 | "--onlyvisible", 372 | "--any", 373 | "--pid", 374 | &pid, 375 | "ignorepattern", 376 | "windowfocus", 377 | "--sync", 378 | "%1", 379 | ]; 380 | 381 | let commands = &exec.commands; 382 | match exec.exec_type { 383 | ExecType::Text => { 384 | for command in commands { 385 | exec_text(base_parameters, command, timeout)?; 386 | exec_keys(base_parameters, &["Return"], timeout)?; 387 | } 388 | } 389 | ExecType::TextNoReturn => { 390 | for command in commands { 391 | exec_text(base_parameters, command, timeout)?; 392 | } 393 | } 394 | ExecType::Keys => exec_keys(base_parameters, commands.as_slice(), timeout)?, 395 | } 396 | 397 | Ok(()) 398 | } 399 | -------------------------------------------------------------------------------- /src/shlex.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | use crate::errors::*; 10 | use std::str::{self, Bytes}; 11 | 12 | // Implementation based in parts on: 13 | // https://github.com/comex/rust-shlex/blob/95ef6961a2500d89bc065b2873ca3e77850539e3/src/lib.rs 14 | // 15 | // which is dual-licensed under MIT and Apache-2.0: 16 | // https://github.com/comex/rust-shlex/blob/95ef6961a2500d89bc065b2873ca3e77850539e3/Cargo.toml#L5 17 | 18 | struct Shlex<'a> { 19 | in_str: &'a str, 20 | in_bytes: Bytes<'a>, 21 | offset: usize, 22 | } 23 | 24 | impl<'a> Shlex<'a> { 25 | pub fn new(in_str: &'a str) -> Shlex<'a> { 26 | Shlex { 27 | in_str, 28 | in_bytes: in_str.bytes(), 29 | offset: 0, 30 | } 31 | } 32 | 33 | fn next_word(&mut self) -> Result> { 34 | let start_offset = self.offset; 35 | let mut ch = self.next_byte(); 36 | 37 | if ch.is_none() { 38 | return Ok(None); 39 | } 40 | 41 | loop { 42 | if ch.is_some() { 43 | let result = match ch.unwrap() as char { 44 | '"' => self.parse_double(), 45 | '\'' => self.parse_single(), 46 | ' ' | '\t' | '\n' => break, 47 | _ => Ok(()), 48 | }; 49 | if result.is_err() { 50 | return result.map(|_| None); 51 | } 52 | ch = self.next_byte(); 53 | } else { 54 | break; 55 | } 56 | } 57 | 58 | Ok(Some( 59 | (&self.in_str[start_offset..self.offset - 1]).trim_matches(|c| c == '\'' || c == '"'), 60 | )) 61 | } 62 | 63 | fn parse_double(&mut self) -> Result<()> { 64 | loop { 65 | if let Some(ch) = self.next_byte() { 66 | if let '"' = ch as char { 67 | return Ok(()); 68 | } 69 | } else { 70 | return Err("".into()); 71 | } 72 | } 73 | } 74 | 75 | fn parse_single(&mut self) -> Result<()> { 76 | loop { 77 | if let Some(ch) = self.next_byte() { 78 | if let '\'' = ch as char { 79 | return Ok(()); 80 | } 81 | } else { 82 | return Err("".into()); 83 | } 84 | } 85 | } 86 | 87 | fn next_byte(&mut self) -> Option { 88 | self.offset += 1; 89 | self.in_bytes.next() 90 | } 91 | } 92 | 93 | impl<'a> Iterator for Shlex<'a> { 94 | type Item = &'a str; 95 | 96 | fn next(&mut self) -> Option<&'a str> { 97 | match self.next_word().ok() { 98 | None | Some(None) => None, 99 | Some(o) => o, 100 | } 101 | } 102 | } 103 | 104 | pub fn split<'a>(in_str: &'a str) -> Option> { 105 | let shl = Shlex::new(in_str); 106 | let res: Vec<&'a str> = shl.collect(); 107 | 108 | if res.is_empty() { 109 | None 110 | } else { 111 | Some(res) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | //! The types in this module make up the structure of the project configuration files. 10 | //! 11 | //! # Example 12 | //! 13 | //! The following is an examplary TOML configuration, which will be parsed into this modules types. 14 | //! 15 | //! ```toml 16 | //! # i3nator project 17 | //! 18 | //! # General configuration items 19 | //! [general] 20 | //! # Working directory to use 21 | //! working_directory = "/path/to/my/working/directory" 22 | //! 23 | //! # Name of the workspace the layout should be applied to 24 | //! workspace = "1" 25 | //! 26 | //! # Path to your layout-file 27 | //! layout_path = "/path/to/my/layout.json" 28 | //! 29 | //! # Alternatively, you can include the JSON-contents of the layout directly: 30 | //! # layout = "{ ... }" 31 | //! 32 | //! # List of applications to start 33 | //! [[applications]] 34 | //! command = "mycommand --with 'multiple args'" 35 | //! working_directory = "/path/to/a/different/working/directory" 36 | //! ``` 37 | 38 | use crate::{configfiles::ConfigFile, layouts::Layout as ManagedLayout, shlex}; 39 | use serde::{ 40 | de::{self, Deserializer}, 41 | Deserialize, 42 | }; 43 | #[cfg(unix)] 44 | use std::os::unix::ffi::OsStrExt; 45 | use std::{ 46 | borrow::Cow, 47 | ffi::{OsStr, OsString}, 48 | fmt, 49 | marker::PhantomData, 50 | path::{Path, PathBuf}, 51 | time::Duration, 52 | }; 53 | 54 | /// This is the parent type defining the complete project configuration used by i3nator. 55 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 56 | #[serde(deny_unknown_fields)] 57 | pub struct Config { 58 | /// The general configuration section. 59 | /// 60 | /// This section defines how a project behaves in general. 61 | pub general: General, 62 | 63 | /// The applications configuration list. 64 | /// 65 | /// This list defines what applications to start and how to start them. 66 | pub applications: Vec, 67 | } 68 | 69 | /// The general configuration section. 70 | /// 71 | /// This section defines how a project behaves in general. 72 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 73 | #[serde(deny_unknown_fields)] 74 | pub struct General { 75 | /// The working directory defines in which directory-context the applications should be 76 | /// launched in. 77 | #[serde(default, deserialize_with = "deserialize_opt_pathbuf_with_tilde")] 78 | pub working_directory: Option, 79 | 80 | /// If the workspace is `Some`, `i3` will be instructed to open the layout on the specified 81 | /// workspace. If it is `None`, `i3` will use the currently focused workspace. 82 | pub workspace: Option, 83 | 84 | /// The layout to append to a workspace. 85 | /// 86 | /// This should either be: 87 | /// 88 | /// * the quasi-JSON as returned by `i3-save-tree` 89 | /// * or a file-path containing the quasi-JSON as returned by `i3-save-tree`. 90 | /// 91 | /// Either one will be passed to [`append_layout`][append-layout]. 92 | /// 93 | /// [append-layout]: https://i3wm.org/docs/layout-saving.html#_append_layout_command 94 | #[serde(deserialize_with = "deserialize_layout")] 95 | pub layout: Layout, 96 | } 97 | 98 | /// This holds the layout, in multiple formats. 99 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 100 | #[serde(rename_all = "lowercase")] 101 | pub enum Layout { 102 | /// The layout is provided directly as a string. 103 | Contents(String), 104 | 105 | /// The name of a managed layout 106 | Managed(String), 107 | 108 | /// The layout is provided as a path. 109 | Path(PathBuf), 110 | } 111 | 112 | /// The applications configuration. 113 | /// 114 | /// This configuration defines how to start an applications and what potential commands to execute 115 | /// in them. 116 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 117 | #[serde(deny_unknown_fields)] 118 | pub struct Application { 119 | /// The command used for starting an application. 120 | /// 121 | /// See [`ApplicationCommand`](struct.ApplicationCommand.html). 122 | #[serde(deserialize_with = "deserialize_application_command")] 123 | pub command: ApplicationCommand, 124 | 125 | /// The working directory defines in which directory-context the applications should be 126 | /// launched in. 127 | /// 128 | /// This overrides [`general.working_directory`][general-working_directory]. 129 | /// 130 | /// [general-working_directory]: struct.General.html#structfield.working_directory 131 | #[serde(default, deserialize_with = "deserialize_opt_pathbuf_with_tilde")] 132 | pub working_directory: Option, 133 | 134 | /// Commands to execute or keys to simulate after application startup. 135 | #[serde(default, deserialize_with = "deserialize_opt_exec")] 136 | pub exec: Option, 137 | } 138 | 139 | /// The command used for starting an application. 140 | /// 141 | /// # Example 142 | /// 143 | /// This struct can be deserialized (from TOML) either from a string or a sequence of strings. The 144 | /// following are equivalent: 145 | /// 146 | /// ```toml 147 | /// command = "myprogram --with 'multiple args'" 148 | /// command = ["myprogram, "--with", "multiple args"] 149 | /// ``` 150 | /// 151 | /// ```rust 152 | /// # extern crate i3nator; 153 | /// # extern crate toml; 154 | /// # use i3nator::types::*; 155 | /// # fn main() { 156 | /// let string: ApplicationCommand = toml::from_str::(r#" 157 | /// command = "myprogram --with 'multiple args'" 158 | /// "#).unwrap().command; 159 | /// let sequence_of_strings: ApplicationCommand = toml::from_str::(r#" 160 | /// command = ["myprogram", "--with", "multiple args"] 161 | /// "#).unwrap().command; 162 | /// 163 | /// assert_eq!(string, sequence_of_strings); 164 | /// assert_eq!(string.program, "myprogram"); 165 | /// assert_eq!(string.args, vec!["--with".to_owned(), "multiple args".to_owned()]); 166 | /// # } 167 | /// ``` 168 | /// 169 | /// A string will be split up into separate args, honoring single- and double-quoted elements. 170 | #[derive(Deserialize, Debug, Default, Clone, PartialEq, Eq)] 171 | pub struct ApplicationCommand { 172 | /// The executable to start. 173 | pub program: String, 174 | 175 | /// A list of arguments to pass to the executable. 176 | #[serde(default)] 177 | pub args: Vec, 178 | } 179 | 180 | /// Commands to execute or keys to simulate after application startup. 181 | /// 182 | /// `xdotool` is used to simulate text or keys to input. 183 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 184 | pub struct Exec { 185 | /// List of text or keys to input into the application. 186 | /// 187 | /// Text can be defined as simple strings. Keystrokes have to specified in a format `xdotool` 188 | /// expects them, see `xdotool`'s [official documentation][xdotool-keyboard]. 189 | /// 190 | /// [xdotool-keyboard]: 191 | /// https://github.com/jordansissel/xdotool/blob/master/xdotool.pod#keyboard-commands 192 | pub commands: Vec, 193 | 194 | /// Defines how the commands above should be interpreted. 195 | /// 196 | /// If not specified, [`ExecType::Text`][variant-ExecType-Text] will be used by default. 197 | /// 198 | /// [variant-ExecType-Text]: enum.ExecType.html#variant.Text 199 | #[serde(default = "default_exec_type")] 200 | pub exec_type: ExecType, 201 | 202 | /// Specify a timeout after which a command has to be succesfully input into the application. 203 | /// 204 | /// The input of commands is done with `xdotool --sync`, that is `xdotool` will block until the 205 | /// required application starts up. `xdotool` might fail to find a started application, if that 206 | /// application does not behave well within the X11 standards. 207 | /// 208 | /// In this case, `xdotool` would block indefinitely. This timeout will kill the `xdotool` 209 | /// process if it does not exit (successfully or unsuccessfully). 210 | #[serde(default = "default_timeout", deserialize_with = "deserialize_duration")] 211 | pub timeout: Duration, 212 | } 213 | 214 | fn default_exec_type() -> ExecType { 215 | ExecType::Text 216 | } 217 | 218 | fn default_timeout() -> Duration { 219 | Duration::from_secs(5) 220 | } 221 | 222 | /// Defines how the commands in [`Exec`][struct-Exec] should be interpreted. 223 | /// 224 | /// [struct-Exec]: struct.Exec.html 225 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 226 | #[serde(rename_all = "snake_case")] 227 | pub enum ExecType { 228 | /// Interpret the commands given as separate text-lines, inputting them in order with a 229 | /// `Return` after each. 230 | Text, 231 | 232 | /// Interpret the commands given as text, but do not input a `Return` after each element. 233 | TextNoReturn, 234 | 235 | /// Interpret the commands given as key presses. 236 | /// 237 | /// This does not input any `Return`s. 238 | Keys, 239 | } 240 | 241 | struct Phantom(PhantomData); 242 | 243 | fn deserialize_application_command<'de, D>(deserializer: D) -> Result 244 | where 245 | D: Deserializer<'de>, 246 | { 247 | impl<'de> de::Visitor<'de> for Phantom { 248 | type Value = ApplicationCommand; 249 | 250 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 251 | formatter.write_str("string, sequence of strings or map") 252 | } 253 | 254 | fn visit_str(self, value: &str) -> Result 255 | where 256 | E: de::Error, 257 | { 258 | match shlex::split(value) { 259 | Some(mut v) => { 260 | if v.is_empty() { 261 | Err(de::Error::custom("command can not be empty")) 262 | } else { 263 | Ok(ApplicationCommand { 264 | program: v.remove(0).to_owned(), 265 | args: v.into_iter().map(str::to_owned).collect::>(), 266 | }) 267 | } 268 | } 269 | None => Err(de::Error::custom("command can not be empty")), 270 | } 271 | } 272 | 273 | fn visit_seq(self, visitor: S) -> Result 274 | where 275 | S: de::SeqAccess<'de>, 276 | { 277 | let mut v: Vec = 278 | de::Deserialize::deserialize(de::value::SeqAccessDeserializer::new(visitor))?; 279 | if v.is_empty() { 280 | Err(de::Error::custom("command can not be empty")) 281 | } else { 282 | Ok(ApplicationCommand { 283 | program: v.remove(0), 284 | args: v, 285 | }) 286 | } 287 | } 288 | 289 | fn visit_map(self, visitor: M) -> Result 290 | where 291 | M: de::MapAccess<'de>, 292 | { 293 | de::Deserialize::deserialize(de::value::MapAccessDeserializer::new(visitor)) 294 | } 295 | } 296 | 297 | deserializer.deserialize_any(Phantom::(PhantomData)) 298 | } 299 | 300 | fn deserialize_duration<'de, D>(deserializer: D) -> Result 301 | where 302 | D: Deserializer<'de>, 303 | { 304 | impl<'de> de::Visitor<'de> for Phantom { 305 | type Value = Duration; 306 | 307 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 308 | formatter.write_str("integer or map") 309 | } 310 | 311 | fn visit_i64(self, value: i64) -> Result 312 | where 313 | E: de::Error, 314 | { 315 | Ok(Duration::from_secs(value as u64)) 316 | } 317 | 318 | fn visit_map(self, visitor: M) -> Result 319 | where 320 | M: de::MapAccess<'de>, 321 | { 322 | de::Deserialize::deserialize(de::value::MapAccessDeserializer::new(visitor)) 323 | } 324 | } 325 | 326 | deserializer.deserialize_any(Phantom::(PhantomData)) 327 | } 328 | 329 | fn deserialize_exec<'de, D>(deserializer: D) -> Result 330 | where 331 | D: Deserializer<'de>, 332 | { 333 | impl<'de> de::Visitor<'de> for Phantom { 334 | type Value = Exec; 335 | 336 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 337 | formatter.write_str("string, sequence of strings or map") 338 | } 339 | 340 | fn visit_str(self, value: &str) -> Result 341 | where 342 | E: de::Error, 343 | { 344 | Ok(Exec { 345 | commands: vec![value.to_owned()], 346 | exec_type: default_exec_type(), 347 | timeout: default_timeout(), 348 | }) 349 | } 350 | 351 | fn visit_seq(self, visitor: S) -> Result 352 | where 353 | S: de::SeqAccess<'de>, 354 | { 355 | let v: Vec = 356 | de::Deserialize::deserialize(de::value::SeqAccessDeserializer::new(visitor))?; 357 | 358 | if v.is_empty() { 359 | Err(de::Error::custom("commands can not be empty")) 360 | } else { 361 | Ok(Exec { 362 | commands: v, 363 | exec_type: default_exec_type(), 364 | timeout: default_timeout(), 365 | }) 366 | } 367 | } 368 | 369 | fn visit_map(self, visitor: M) -> Result 370 | where 371 | M: de::MapAccess<'de>, 372 | { 373 | de::Deserialize::deserialize(de::value::MapAccessDeserializer::new(visitor)) 374 | } 375 | } 376 | 377 | deserializer.deserialize_any(Phantom::(PhantomData)) 378 | } 379 | 380 | fn deserialize_opt_exec<'de, D>(deserializer: D) -> Result, D::Error> 381 | where 382 | D: Deserializer<'de>, 383 | { 384 | deserialize_exec(deserializer).map(Some) 385 | } 386 | 387 | fn deserialize_layout<'de, D>(deserializer: D) -> Result 388 | where 389 | D: Deserializer<'de>, 390 | { 391 | impl<'de> de::Visitor<'de> for Phantom { 392 | type Value = Layout; 393 | 394 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 395 | formatter.write_str("string") 396 | } 397 | 398 | fn visit_str(self, value: &str) -> Result 399 | where 400 | E: de::Error, 401 | { 402 | if value.find('{').is_some() { 403 | Ok(Layout::Contents(value.into())) 404 | } else if ManagedLayout::open(value).is_ok() { 405 | Ok(Layout::Managed(value.to_owned())) 406 | } else { 407 | Ok(Layout::Path(tilde(value).into_owned())) 408 | } 409 | } 410 | } 411 | 412 | deserializer.deserialize_any(Phantom::(PhantomData)) 413 | } 414 | 415 | fn deserialize_pathbuf_with_tilde<'de, D>(deserializer: D) -> Result 416 | where 417 | D: Deserializer<'de>, 418 | { 419 | let pathbuf: PathBuf = de::Deserialize::deserialize(deserializer)?; 420 | Ok(tilde(&pathbuf).into_owned()) 421 | } 422 | 423 | fn deserialize_opt_pathbuf_with_tilde<'de, D>(deserializer: D) -> Result, D::Error> 424 | where 425 | D: Deserializer<'de>, 426 | { 427 | deserialize_pathbuf_with_tilde(deserializer).map(Some) 428 | } 429 | 430 | /// Taken from crate "shellexpand", adapted to work with `Path` instead of `str`: 431 | /// https://github.com/netvl/shellexpand/blob/ 432 | /// 501c4fdd8275fea2e56e71a2659cd90d21d18565/src/lib.rs#L558-L639 433 | /// 434 | /// (Linebreak in link to make rustfmt happy...) 435 | /// 436 | /// Dual-licensed under MIT/Apache 2.0 437 | /// Copyright (c) 2016 Vladimir Matveev 438 | #[doc(hidden)] 439 | fn tilde_with_context(input: &SI, home_dir: HD) -> Cow 440 | where 441 | SI: AsRef, 442 | P: AsRef, 443 | HD: FnOnce() -> Option

, 444 | { 445 | let input_str = input.as_ref(); 446 | let bytes = input_str.as_os_str().as_bytes(); 447 | if bytes[0] == b'~' { 448 | let input_after_tilde = &bytes[1..]; 449 | if input_after_tilde.is_empty() || input_after_tilde[0] == b'/' { 450 | if let Some(hd) = home_dir() { 451 | let mut s = OsString::new(); 452 | s.push(hd.as_ref()); 453 | s.push(OsStr::from_bytes(input_after_tilde)); 454 | PathBuf::from(s).into() 455 | } else { 456 | // home dir is not available 457 | input_str.into() 458 | } 459 | } else { 460 | // we cannot handle `~otheruser/` paths yet 461 | input_str.into() 462 | } 463 | } else { 464 | // input doesn't start with tilde 465 | input_str.into() 466 | } 467 | } 468 | 469 | fn tilde(input: &SI) -> Cow 470 | where 471 | SI: AsRef, 472 | { 473 | tilde_with_context(input, dirs_next::home_dir) 474 | } 475 | -------------------------------------------------------------------------------- /tests/projects.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | #![cfg(feature = "sequential-tests")] 10 | 11 | use i3nator::{ 12 | configfiles::ConfigFile, 13 | projects::{self, Project}, 14 | types::*, 15 | }; 16 | use lazy_static::lazy_static; 17 | use std::{ 18 | env, 19 | ffi::OsString, 20 | fs::{self, File}, 21 | io::prelude::*, 22 | panic::{self, UnwindSafe}, 23 | path::{Path, PathBuf}, 24 | }; 25 | use tempdir::TempDir; 26 | use tempfile::NamedTempFile; 27 | 28 | lazy_static! { 29 | static ref TMP_DIR: TempDir = TempDir::new("i3nator-tests").unwrap(); 30 | static ref PROJECTS_DIR: PathBuf = TMP_DIR.path().join("i3nator/projects"); 31 | } 32 | 33 | fn with_projects_dir ()>(body: F) 34 | where 35 | F: UnwindSafe, 36 | { 37 | // Create the temporary directories if they do not exist 38 | if !PROJECTS_DIR.exists() { 39 | fs::create_dir_all(&*PROJECTS_DIR).expect("couldn't create temporary directories"); 40 | } 41 | 42 | // Set up temporary XDG config directory 43 | env::set_var("XDG_CONFIG_HOME", TMP_DIR.path()); 44 | 45 | // Run body 46 | let panic_result = panic::catch_unwind(|| body(PROJECTS_DIR.as_ref())); 47 | 48 | // Remove the temporary directories 49 | fs::remove_dir_all(&*TMP_DIR).expect("couldn't delete temporary directories"); 50 | 51 | if let Err(err) = panic_result { 52 | panic::resume_unwind(err); 53 | } 54 | } 55 | 56 | #[test] 57 | fn empty_list() { 58 | with_projects_dir(|_| { 59 | assert!(projects::list().is_empty()); 60 | }) 61 | } 62 | 63 | #[test] 64 | fn create() { 65 | with_projects_dir(|projects_dir| { 66 | let project = Project::create("project-one").unwrap(); 67 | assert_eq!(project.name, "project-one"); 68 | assert_eq!(project.path, projects_dir.join("project-one.toml")); 69 | assert!(project.verify().is_err()); 70 | 71 | // File does not get created by default, list should still be empty 72 | assert!(projects::list().is_empty()); 73 | }) 74 | } 75 | 76 | #[test] 77 | #[should_panic(expected = "ConfigExists")] 78 | fn create_exists() { 79 | with_projects_dir(|projects_dir| { 80 | let project = Project::create("project-one").unwrap(); 81 | assert_eq!(project.name, "project-one"); 82 | assert_eq!(project.path, projects_dir.join("project-one.toml")); 83 | assert!(project.verify().is_err()); 84 | 85 | // Create project file 86 | File::create(&project.path).expect("couldn't create project file"); 87 | 88 | // File created, list should contain it 89 | assert_eq!(projects::list(), vec![OsString::from("project-one")]); 90 | 91 | // Create project with same name, this should fail 92 | Project::create("project-one").unwrap(); 93 | }) 94 | } 95 | 96 | #[test] 97 | fn create_from_template() { 98 | with_projects_dir(|projects_dir| { 99 | let template = "this is my template"; 100 | let project = 101 | Project::create_from_template("project-template", template.as_bytes()).unwrap(); 102 | 103 | assert_eq!(project.name, "project-template"); 104 | assert_eq!(project.path, projects_dir.join("project-template.toml")); 105 | assert!(project.path.exists()); 106 | assert!(project.verify().is_err()); 107 | 108 | let mut file = File::open(project.path).unwrap(); 109 | let mut contents = String::new(); 110 | file.read_to_string(&mut contents).unwrap(); 111 | 112 | assert_eq!(contents, template); 113 | }) 114 | } 115 | 116 | #[test] 117 | fn from_path() { 118 | let tempfile = NamedTempFile::new().expect("couldn't create temporary file"); 119 | let project = Project::from_path(tempfile.path()).unwrap(); 120 | assert_eq!(project.name, "local"); 121 | assert_eq!(project.path, tempfile.path()); 122 | assert!(project.verify().is_err()); 123 | } 124 | 125 | #[test] 126 | #[should_panic(expected = "PathDoesntExist")] 127 | fn from_path_not_exists() { 128 | Project::from_path("/this/path/does/not/exist").unwrap(); 129 | } 130 | 131 | #[test] 132 | fn open() { 133 | with_projects_dir(|projects_dir| { 134 | let project = Project::create("project-open").unwrap(); 135 | assert_eq!(project.name, "project-open"); 136 | assert_eq!(project.path, projects_dir.join("project-open.toml")); 137 | assert!(project.verify().is_err()); 138 | 139 | // Create project file 140 | File::create(&project.path).expect("couldn't create project file"); 141 | 142 | // Open project 143 | let project_open = Project::open("project-open").unwrap(); 144 | assert_eq!(project_open, project); 145 | }) 146 | } 147 | 148 | #[test] 149 | #[should_panic(expected = "UnknownConfig")] 150 | fn open_unknown_project() { 151 | with_projects_dir(|_| { 152 | Project::open("unknown-project").unwrap(); 153 | }) 154 | } 155 | 156 | #[test] 157 | fn config() { 158 | with_projects_dir(|projects_dir| { 159 | let template = r#"[general] 160 | layout = "{ ... }" 161 | 162 | [[applications]] 163 | command = "mycommand""#; 164 | let mut project = 165 | Project::create_from_template("project-template", template.as_bytes()).unwrap(); 166 | 167 | assert_eq!(project.name, "project-template"); 168 | assert_eq!(project.path, projects_dir.join("project-template.toml")); 169 | assert!(project.path.exists()); 170 | assert!(project.verify().is_ok()); 171 | 172 | let expected = Config { 173 | general: General { 174 | working_directory: None, 175 | workspace: None, 176 | layout: Layout::Contents("{ ... }".to_owned()), 177 | }, 178 | applications: vec![Application { 179 | command: ApplicationCommand { 180 | program: "mycommand".to_owned(), 181 | args: vec![], 182 | }, 183 | working_directory: None, 184 | exec: None, 185 | }], 186 | }; 187 | 188 | assert_eq!(project.config().unwrap(), &expected); 189 | }) 190 | } 191 | 192 | #[test] 193 | fn config_invalid() { 194 | with_projects_dir(|projects_dir| { 195 | let template = r#"invalid template"#; 196 | let mut project = 197 | Project::create_from_template("project-template", template.as_bytes()).unwrap(); 198 | 199 | assert_eq!(project.name, "project-template"); 200 | assert_eq!(project.path, projects_dir.join("project-template.toml")); 201 | assert!(project.path.exists()); 202 | assert!(project.verify().is_err()); 203 | assert!(project.config().is_err()); 204 | }) 205 | } 206 | 207 | #[test] 208 | fn copy() { 209 | with_projects_dir(|projects_dir| { 210 | let project = Project::create("project-existing").unwrap(); 211 | assert_eq!(project.name, "project-existing"); 212 | assert_eq!(project.path, projects_dir.join("project-existing.toml")); 213 | assert!(project.verify().is_err()); 214 | 215 | // Create project file 216 | File::create(&project.path).expect("couldn't create project file"); 217 | 218 | let project_new = project.copy("project-new").unwrap(); 219 | assert_eq!(project_new.name, "project-new"); 220 | assert_eq!(project_new.path, projects_dir.join("project-new.toml")); 221 | assert!(project.verify().is_err()); 222 | }) 223 | } 224 | 225 | #[test] 226 | #[should_panic(expected = "No such file or directory")] 227 | fn copy_without_file() { 228 | with_projects_dir(|projects_dir| { 229 | let project = Project::create("project-existing").unwrap(); 230 | assert_eq!(project.name, "project-existing"); 231 | assert_eq!(project.path, projects_dir.join("project-existing.toml")); 232 | assert!(project.verify().is_err()); 233 | 234 | project.copy("project-new").unwrap(); 235 | }) 236 | } 237 | 238 | #[test] 239 | fn delete() { 240 | with_projects_dir(|projects_dir| { 241 | let project = Project::create("project-delete").unwrap(); 242 | assert_eq!(project.name, "project-delete"); 243 | assert_eq!(project.path, projects_dir.join("project-delete.toml")); 244 | assert!(project.verify().is_err()); 245 | 246 | // Create project file 247 | File::create(&project.path).expect("couldn't create project file"); 248 | 249 | assert!(project.delete().is_ok()); 250 | assert!(!project.path.exists()) 251 | }) 252 | } 253 | 254 | #[test] 255 | #[should_panic(expected = "No such file or directory")] 256 | fn delete_without_file() { 257 | with_projects_dir(|projects_dir| { 258 | let project = Project::create("project-delete").unwrap(); 259 | assert_eq!(project.name, "project-delete"); 260 | assert_eq!(project.path, projects_dir.join("project-delete.toml")); 261 | assert!(project.verify().is_err()); 262 | 263 | project.delete().unwrap(); 264 | }) 265 | } 266 | 267 | #[test] 268 | fn rename() { 269 | with_projects_dir(|projects_dir| { 270 | let project = Project::create("project-rename-old").unwrap(); 271 | assert_eq!(project.name, "project-rename-old"); 272 | assert_eq!(project.path, projects_dir.join("project-rename-old.toml")); 273 | assert!(project.verify().is_err()); 274 | 275 | // Create project file 276 | File::create(&project.path).expect("couldn't create project file"); 277 | 278 | let project_new = project.rename("project-rename-new").unwrap(); 279 | assert_eq!(project_new.name, "project-rename-new"); 280 | assert_eq!( 281 | project_new.path, 282 | projects_dir.join("project-rename-new.toml") 283 | ); 284 | assert!(project_new.verify().is_err()); 285 | 286 | assert!(!project.path.exists()); 287 | assert!(project_new.path.exists()); 288 | }) 289 | } 290 | -------------------------------------------------------------------------------- /tests/types.rs: -------------------------------------------------------------------------------- 1 | // Copyright Pit Kleyersburg 2 | // 3 | // Licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your 6 | // option. This file may not be copied, modified or distributed 7 | // except according to those terms. 8 | 9 | use i3nator::types::*; 10 | use std::time::Duration; 11 | 12 | macro_rules! equivalent { 13 | ( $fragment:expr, $expected:expr; $ty:ty ) => { 14 | let actual: $ty = toml::from_str($fragment).unwrap(); 15 | assert_eq!(actual, $expected); 16 | }; 17 | } 18 | 19 | #[test] 20 | fn full_config() { 21 | let expected = Config { 22 | general: General { 23 | working_directory: Some("/path/to/my/working/directory".to_owned().into()), 24 | workspace: Some("0".to_owned()), 25 | layout: Layout::Path("/path/to/my/layout.json".into()), 26 | }, 27 | applications: vec![Application { 28 | command: ApplicationCommand { 29 | program: "mycommand".to_owned(), 30 | args: vec!["--with".to_owned(), "multiple args".to_owned()], 31 | }, 32 | working_directory: Some("/path/to/a/different/working/directory".to_owned().into()), 33 | exec: Some(Exec { 34 | commands: vec!["command one".to_owned(), "command two".to_owned()], 35 | exec_type: ExecType::TextNoReturn, 36 | timeout: Duration::from_secs(5), 37 | }), 38 | }], 39 | }; 40 | 41 | equivalent! { 42 | r#" 43 | [general] 44 | working_directory = "/path/to/my/working/directory" 45 | workspace = "0" 46 | layout = "/path/to/my/layout.json" 47 | 48 | [[applications]] 49 | command = "mycommand --with 'multiple args'" 50 | working_directory = "/path/to/a/different/working/directory" 51 | exec = { commands = ["command one", "command two"], exec_type = "text_no_return" } 52 | "#, 53 | expected; 54 | Config 55 | } 56 | } 57 | 58 | #[test] 59 | fn application_command_str() { 60 | let expected = Application { 61 | command: ApplicationCommand { 62 | program: "mycommand".to_owned(), 63 | args: vec!["--with".to_owned(), "multiple args".to_owned()], 64 | }, 65 | working_directory: None, 66 | exec: None, 67 | }; 68 | 69 | equivalent! { 70 | r#"command = "mycommand --with 'multiple args'""#, 71 | expected; 72 | Application 73 | } 74 | } 75 | 76 | #[test] 77 | fn application_command_str_no_args() { 78 | let expected = Application { 79 | command: ApplicationCommand { 80 | program: "mycommand".to_owned(), 81 | args: vec![], 82 | }, 83 | working_directory: None, 84 | exec: None, 85 | }; 86 | 87 | equivalent! { 88 | r#"command = "mycommand""#, 89 | expected; 90 | Application 91 | } 92 | } 93 | 94 | #[test] 95 | #[should_panic(expected = "command can not be empty")] 96 | fn application_command_empty_str() { 97 | toml::from_str::(r#"command = """#).unwrap(); 98 | } 99 | 100 | #[test] 101 | fn application_command_seq() { 102 | let expected = Application { 103 | command: ApplicationCommand { 104 | program: "mycommand".to_owned(), 105 | args: vec!["--with".to_owned(), "multiple args".to_owned()], 106 | }, 107 | working_directory: None, 108 | exec: None, 109 | }; 110 | 111 | equivalent! { 112 | r#"command = ["mycommand", "--with", "multiple args"]"#, 113 | expected; 114 | Application 115 | } 116 | } 117 | 118 | #[test] 119 | fn application_command_seq_no_args() { 120 | let expected = Application { 121 | command: ApplicationCommand { 122 | program: "mycommand".to_owned(), 123 | args: vec![], 124 | }, 125 | working_directory: None, 126 | exec: None, 127 | }; 128 | 129 | equivalent! { 130 | r#"command = ["mycommand"]"#, 131 | expected; 132 | Application 133 | } 134 | } 135 | 136 | #[test] 137 | #[should_panic(expected = "command can not be empty")] 138 | fn application_command_empty_seq() { 139 | toml::from_str::(r#"command = []"#).unwrap(); 140 | } 141 | 142 | #[test] 143 | fn application_command_map() { 144 | let expected = Application { 145 | command: ApplicationCommand { 146 | program: "mycommand".to_owned(), 147 | args: vec!["--with".to_owned(), "multiple args".to_owned()], 148 | }, 149 | working_directory: None, 150 | exec: None, 151 | }; 152 | 153 | equivalent! { 154 | r#"command = { program = "mycommand", args = ["--with", "multiple args"] }"#, 155 | expected; 156 | Application 157 | } 158 | } 159 | 160 | #[test] 161 | fn application_command_map_no_args() { 162 | let expected = Application { 163 | command: ApplicationCommand { 164 | program: "mycommand".to_owned(), 165 | args: vec![], 166 | }, 167 | working_directory: None, 168 | exec: None, 169 | }; 170 | 171 | equivalent! { 172 | r#"command = { program = "mycommand" }"#, 173 | expected; 174 | Application 175 | } 176 | } 177 | 178 | #[test] 179 | fn duration_secs() { 180 | equivalent! { 181 | r#"commands = [] 182 | timeout = 10"#, 183 | Exec { 184 | commands: vec![], 185 | exec_type: ExecType::Text, 186 | timeout: Duration::from_secs(10), 187 | }; 188 | Exec 189 | } 190 | } 191 | 192 | #[test] 193 | fn duration_map() { 194 | equivalent! { 195 | r#"commands = [] 196 | timeout = { secs = 10, nanos = 42 }"#, 197 | Exec { 198 | commands: vec![], 199 | exec_type: ExecType::Text, 200 | timeout: Duration::new(10, 42), 201 | }; 202 | Exec 203 | } 204 | } 205 | 206 | #[test] 207 | #[should_panic(expected = "invalid type: string")] 208 | fn duration_str() { 209 | toml::from_str::( 210 | r#" 211 | commands = [] 212 | timeout = "10" 213 | "#, 214 | ) 215 | .unwrap(); 216 | } 217 | 218 | #[test] 219 | fn exec_commands_only() { 220 | let expected = Exec { 221 | commands: vec!["command one".to_owned(), "command two".to_owned()], 222 | exec_type: ExecType::Text, 223 | timeout: Duration::from_secs(5), 224 | }; 225 | 226 | equivalent! { 227 | r#"commands = ["command one", "command two"]"#, 228 | expected; 229 | Exec 230 | } 231 | } 232 | 233 | #[test] 234 | fn exec_commands_and_type() { 235 | let expected = Exec { 236 | commands: vec!["command one".to_owned(), "command two".to_owned()], 237 | exec_type: ExecType::TextNoReturn, 238 | timeout: Duration::from_secs(5), 239 | }; 240 | 241 | equivalent! { 242 | r#" 243 | commands = ["command one", "command two"] 244 | exec_type = "text_no_return" 245 | "#, 246 | expected; 247 | Exec 248 | } 249 | } 250 | 251 | #[test] 252 | fn exec_commands_type_and_timeout() { 253 | let expected = Exec { 254 | commands: vec!["command one".to_owned(), "command two".to_owned()], 255 | exec_type: ExecType::TextNoReturn, 256 | timeout: Duration::from_secs(10), 257 | }; 258 | 259 | equivalent! { 260 | r#" 261 | commands = ["command one", "command two"] 262 | exec_type = "text_no_return" 263 | timeout = 10 264 | "#, 265 | expected; 266 | Exec 267 | } 268 | } 269 | 270 | #[test] 271 | fn exec_str() { 272 | let expected = Application { 273 | command: ApplicationCommand { 274 | program: "-".to_owned(), 275 | args: vec![], 276 | }, 277 | working_directory: None, 278 | exec: Some(Exec { 279 | commands: vec!["command one".to_owned()], 280 | exec_type: ExecType::Text, 281 | timeout: Duration::from_secs(5), 282 | }), 283 | }; 284 | 285 | equivalent! { 286 | r#" 287 | command = "-" 288 | exec = "command one" 289 | "#, 290 | expected; 291 | Application 292 | } 293 | } 294 | 295 | #[test] 296 | fn exec_seq() { 297 | let expected = Application { 298 | command: ApplicationCommand { 299 | program: "-".to_owned(), 300 | args: vec![], 301 | }, 302 | working_directory: None, 303 | exec: Some(Exec { 304 | commands: vec!["command one".to_owned(), "command two".to_owned()], 305 | exec_type: ExecType::Text, 306 | timeout: Duration::from_secs(5), 307 | }), 308 | }; 309 | 310 | equivalent! { 311 | r#" 312 | command = "-" 313 | exec = ["command one", "command two"] 314 | "#, 315 | expected; 316 | Application 317 | } 318 | } 319 | --------------------------------------------------------------------------------