pax_global_header00006660000000000000000000000064146337120300014511gustar00rootroot0000000000000052 comment=aff941abb3f31ce2b3105eaa92a7b790b0b956c0 precious-0.7.3/000077500000000000000000000000001463371203000133515ustar00rootroot00000000000000precious-0.7.3/.github/000077500000000000000000000000001463371203000147115ustar00rootroot00000000000000precious-0.7.3/.github/workflows/000077500000000000000000000000001463371203000167465ustar00rootroot00000000000000precious-0.7.3/.github/workflows/audit-nightly.yml000066400000000000000000000003221463371203000222500ustar00rootroot00000000000000name: Security audit - nightly on: schedule: - cron: "0 0 * * *" jobs: security_audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions-rust-lang/audit@v1 precious-0.7.3/.github/workflows/audit-on-push.yml000066400000000000000000000004271463371203000221710ustar00rootroot00000000000000name: Security audit - on push on: push: paths: - "**/Cargo.toml" - "**/Cargo.lock" tags-ignore: - "precious-*" jobs: security_audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions-rust-lang/audit@v1 precious-0.7.3/.github/workflows/ci.yml000066400000000000000000000143431463371203000200710ustar00rootroot00000000000000name: Tests and release on: push: branches: - "**" tags-ignore: - "precious-*" pull_request: env: CRATE_NAME: precious GITHUB_TOKEN: ${{ github.token }} RUST_BACKTRACE: 1 jobs: test: name: ${{ matrix.platform.os_name }} with rust ${{ matrix.toolchain }} runs-on: ${{ matrix.platform.os }} strategy: fail-fast: false matrix: platform: # Platforms that don't work: # # - sparc64-unknown-linux-gnu - cannot compile openssl-sys # - x86_64-unknown-illumos - weird error compiling openssl - "bin/sh: 1: granlib: not found" - os_name: FreeBSD-x86_64 os: ubuntu-20.04 target: x86_64-unknown-freebsd bin: precious name: precious-FreeBSD-x86_64.tar.gz skip_tests: true - os_name: Linux-x86_64 os: ubuntu-20.04 target: x86_64-unknown-linux-musl bin: precious name: precious-Linux-x86_64-musl.tar.gz - os_name: Linux-aarch64 os: ubuntu-20.04 target: aarch64-unknown-linux-musl bin: precious name: precious-Linux-aarch64-musl.tar.gz - os_name: Linux-arm os: ubuntu-20.04 target: arm-unknown-linux-musleabi bin: precious name: precious-Linux-arm-musl.tar.gz - os_name: Linux-i686 os: ubuntu-20.04 target: i686-unknown-linux-musl bin: precious name: precious-Linux-i686-musl.tar.gz skip_tests: true - os_name: Linux-powerpc os: ubuntu-20.04 target: powerpc-unknown-linux-gnu bin: precious name: precious-Linux-powerpc-gnu.tar.gz skip_tests: true - os_name: Linux-powerpc64 os: ubuntu-20.04 target: powerpc64-unknown-linux-gnu bin: precious name: precious-Linux-powerpc64-gnu.tar.gz skip_tests: true - os_name: Linux-powerpc64le os: ubuntu-20.04 target: powerpc64le-unknown-linux-gnu bin: precious name: precious-Linux-powerpc64le.tar.gz skip_tests: true - os_name: Linux-riscv64 os: ubuntu-20.04 target: riscv64gc-unknown-linux-gnu bin: precious name: precious-Linux-riscv64gc-gnu.tar.gz - os_name: Linux-s390x os: ubuntu-20.04 target: s390x-unknown-linux-gnu bin: precious name: precious-Linux-s390x-gnu.tar.gz skip_tests: true - os_name: NetBSD-x86_64 os: ubuntu-20.04 target: x86_64-unknown-netbsd bin: precious name: precious-NetBSD-x86_64.tar.gz skip_tests: true - os_name: Windows-aarch64 os: windows-latest target: aarch64-pc-windows-msvc bin: precious.exe name: precious-Windows-aarch64.zip skip_tests: true - os_name: Windows-i686 os: windows-latest target: i686-pc-windows-msvc bin: precious.exe name: precious-Windows-i686.zip skip_tests: true - os_name: Windows-x86_64 os: windows-latest target: x86_64-pc-windows-msvc bin: precious.exe name: precious-Windows-x86_64.zip - os_name: macOS-x86_64 os: macOS-latest target: x86_64-apple-darwin bin: precious name: precious-Darwin-x86_64.tar.gz - os_name: macOS-aarch64 os: macOS-latest target: aarch64-apple-darwin bin: precious name: precious-Darwin-aarch64.tar.gz skip_tests: true toolchain: - stable - beta - nightly steps: - uses: actions/checkout@v4 - name: Cache cargo & target directories uses: Swatinem/rust-cache@v2 - name: Configure Git run: | git config --global user.email "jdoe@example.com" git config --global user.name "J. Doe" - name: Install musl-tools on Linux run: sudo apt-get update --yes && sudo apt-get install --yes musl-tools if: contains(matrix.platform.name, 'musl') - name: Build binary uses: houseabsolute/actions-rust-cross@v0 with: command: "build" target: ${{ matrix.platform.target }} toolchain: ${{ matrix.toolchain }} args: "--locked --release" strip: true - name: Run tests uses: houseabsolute/actions-rust-cross@v0 with: command: "test" target: ${{ matrix.platform.target }} toolchain: ${{ matrix.toolchain }} args: "--locked --release" if: ${{ !matrix.platform.skip_tests }} - name: Package as archive shell: bash run: | cd target/${{ matrix.platform.target }}/release if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then 7z a ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }} else tar czvf ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }} fi cd - if: | matrix.toolchain == 'stable' && ( startsWith( github.ref, 'refs/tags/v' ) || github.ref == 'refs/tags/test-release' ) - name: Publish release artifacts uses: actions/upload-artifact@v4 with: name: precious-${{ matrix.platform.os_name }} path: "precious-*" if: matrix.toolchain == 'stable' && github.ref == 'refs/tags/test-release' - name: Generate SHA-256 run: shasum -a 256 ${{ matrix.platform.name }} if: | matrix.toolchain == 'stable' && matrix.platform.os == 'macOS-latest' && ( startsWith( github.ref, 'refs/tags/v' ) || github.ref == 'refs/tags/test-release' ) - name: Publish GitHub release uses: softprops/action-gh-release@v2 with: draft: true files: "precious-*" body_path: Changes.md if: matrix.toolchain == 'stable' && startsWith( github.ref, 'refs/tags/v' ) precious-0.7.3/.github/workflows/lint.yml000066400000000000000000000023571463371203000204460ustar00rootroot00000000000000name: Lint on: push: branches: - "**" tags-ignore: - "precious-*" pull_request: env: CRATE_NAME: precious RUST_BACKTRACE: 1 # There's not much value in running self-check on multiple platforms. Either # we're lint-clean or not. We do run on each rust target to catch warnings # coming from clippy in future rust versions. jobs: lint: name: Lint self runs-on: ubuntu-latest strategy: fail-fast: false matrix: toolchain: - stable - beta - nightly steps: - uses: actions/checkout@v4 - name: Cache cargo & target directories uses: Swatinem/rust-cache@v2 - name: Install toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.toolchain }} components: rustfmt - name: Run cargo check run: cargo install --path . --locked - uses: actions/setup-node@v4 - name: Run install-dev-tools.sh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -e mkdir $HOME/bin ./dev/bin/install-dev-tools.sh - name: "Run precious --lint" run: PATH=$HOME/bin:$PATH $HOME/.cargo/bin/precious --debug lint --all precious-0.7.3/.gitignore000066400000000000000000000001331463371203000153360ustar00rootroot00000000000000/bin /fatlib /node_modules /package-lock.json /package.json /target /**/*.rs.bk .\#* \#*\# precious-0.7.3/.typos.toml000066400000000000000000000001451463371203000155020ustar00rootroot00000000000000[default.extend-words] # These are golangci-lint imports decorder = "decorder" importas = "importas" precious-0.7.3/CODE_OF_CONDUCT.md000066400000000000000000000062241463371203000161540ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at autarch@urth.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org precious-0.7.3/Cargo.lock000066400000000000000000000766541463371203000153000ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anstream" version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" [[package]] name = "anstyle-parse" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", "windows-sys 0.52.0", ] [[package]] name = "anyhow" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "autocfg" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "bstr" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" dependencies = [ "memchr", "serde", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", "terminal_size", ] [[package]] name = "clap_derive" version = "4.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "clap_lex" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" [[package]] name = "clean-path" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aaa6b4b263a5d737e9bf6b7c09b72c41a5480aec4d7219af827f6564e950b6a5" [[package]] name = "colorchoice" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "colored" version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f741c91823341bebf717d4c71bda820630ce065443b58bd1b7451af008355" dependencies = [ "is-terminal", "lazy_static", "winapi", ] [[package]] name = "comfy-table" version = "7.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b34115915337defe99b2aff5c2ce6771e5fbc4079f4b506301f5cf394c8452f7" dependencies = [ "crossterm", "strum", "strum_macros", "unicode-width", ] [[package]] name = "crossbeam-deque" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crossterm" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ "bitflags 2.5.0", "crossterm_winapi", "libc", "parking_lot", "winapi", ] [[package]] name = "crossterm_winapi" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "either" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" [[package]] name = "env_filter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" dependencies = [ "log", "regex", ] [[package]] name = "env_logger" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9" dependencies = [ "anstream", "anstyle", "env_filter", "humantime", "log", ] [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "fastrand" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "fern" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" dependencies = [ "colored", "log", ] [[package]] name = "filetime" version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ "cfg-if", "libc", "redox_syscall 0.4.1", "windows-sys 0.52.0", ] [[package]] name = "futures" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-sink" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "globset" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" dependencies = [ "aho-corasick", "bstr", "log", "regex-automata", "regex-syntax", ] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "home" version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "ignore" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" dependencies = [ "crossbeam-deque", "globset", "log", "memchr", "regex-automata", "same-file", "walkdir", "winapi-util", ] [[package]] name = "indexmap" version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown", "serde", ] [[package]] name = "is-terminal" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" dependencies = [ "hermit-abi", "libc", "windows-sys 0.52.0", ] [[package]] name = "is_terminal_polyfill" version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" [[package]] name = "itertools" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" [[package]] name = "md5" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.2", "smallvec", "windows-targets 0.52.5", ] [[package]] name = "pathdiff" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" [[package]] name = "pin-project-lite" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "precious" version = "0.7.3" dependencies = [ "log", "precious-core", ] [[package]] name = "precious-core" version = "0.7.3" dependencies = [ "anyhow", "clap", "clean-path", "comfy-table", "fern", "filetime", "ignore", "indexmap", "itertools", "log", "md5", "once_cell", "pathdiff", "precious-helpers", "precious-testhelper", "pretty_assertions", "pushd", "rayon", "regex", "serde", "serial_test", "test-case", "thiserror", "toml", "which", ] [[package]] name = "precious-helpers" version = "0.7.3" dependencies = [ "anyhow", "itertools", "log", "pretty_assertions", "regex", "serial_test", "tempfile", "thiserror", "which", ] [[package]] name = "precious-integration" version = "0.7.3" dependencies = [ "anyhow", "itertools", "precious-core", "precious-helpers", "precious-testhelper", "pretty_assertions", "pushd", "regex", "serial_test", "tempfile", ] [[package]] name = "precious-testhelper" version = "0.7.3" dependencies = [ "anyhow", "env_logger", "log", "once_cell", "precious-helpers", "pushd", "regex", "tempfile", ] [[package]] name = "pretty_assertions" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" dependencies = [ "diff", "yansi", ] [[package]] name = "proc-macro2" version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" dependencies = [ "unicode-ident", ] [[package]] name = "pushd" version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54652e89ed86f16983916c7d80cd34df27cc6c82edba43d51117f85ef2a97390" dependencies = [ "log", "thiserror", ] [[package]] name = "quote" version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] name = "rayon" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "redox_syscall" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ "bitflags 1.3.2", ] [[package]] name = "redox_syscall" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" dependencies = [ "bitflags 2.5.0", ] [[package]] name = "regex" version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "rustix" version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.5.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.52.0", ] [[package]] name = "rustversion" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "scc" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76ad2bbb0ae5100a07b7a6f2ed7ab5fd0045551a4c507989b7a620046ea3efdc" dependencies = [ "sdd", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdd" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d" [[package]] name = "serde" version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_spanned" version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ "serde", ] [[package]] name = "serial_test" version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" dependencies = [ "futures", "log", "once_cell", "parking_lot", "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn", ] [[package]] name = "syn" version = "2.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "tempfile" version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", "rustix", "windows-sys 0.52.0", ] [[package]] name = "terminal_size" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" dependencies = [ "rustix", "windows-sys 0.48.0", ] [[package]] name = "test-case" version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" dependencies = [ "test-case-macros", ] [[package]] name = "test-case-core" version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" dependencies = [ "cfg-if", "proc-macro2", "quote", "syn", ] [[package]] name = "test-case-macros" version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", "syn", "test-case-core", ] [[package]] name = "thiserror" version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "toml" version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" dependencies = [ "serde", "serde_spanned", "toml_datetime", "toml_edit", ] [[package]] name = "toml_datetime" version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" dependencies = [ "serde", ] [[package]] name = "toml_edit" version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", "winnow", ] [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-width" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "which" version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" dependencies = [ "either", "home", "once_cell", "rustix", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets 0.48.5", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.5", ] [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5", ] [[package]] name = "windows-targets" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" dependencies = [ "windows_aarch64_gnullvm 0.52.5", "windows_aarch64_msvc 0.52.5", "windows_i686_gnu 0.52.5", "windows_i686_gnullvm", "windows_i686_msvc 0.52.5", "windows_x86_64_gnu 0.52.5", "windows_x86_64_gnullvm 0.52.5", "windows_x86_64_msvc 0.52.5", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" [[package]] name = "windows_i686_gnullvm" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" dependencies = [ "memchr", ] [[package]] name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" precious-0.7.3/Cargo.toml000066400000000000000000000043271463371203000153070ustar00rootroot00000000000000[package] name = "precious" authors.workspace = true description = "One code quality tool to rule them all" documentation = "https://github.com/houseabsolute/precious" edition.workspace = true license.workspace = true readme.workspace = true repository.workspace = true version.workspace = true categories = ["development-tools"] keywords = ["beautifier", "linter", "pretty-printer", "tidier"] [workspace.package] authors = ["Dave Rolsky "] edition = "2021" license = "MIT OR Apache-2.0" readme = "README.md" repository = "https://github.com/houseabsolute/precious" version = "0.7.3" [[bin]] name = "precious" path = "precious/src/main.rs" doc = false [dependencies] log.workspace = true precious-core.workspace = true [workspace.dependencies] anyhow = "1.0.86" clap = { version = "4.5.7", features = ["cargo", "derive", "wrap_help"] } clean-path = "0.2.1" comfy-table = "7.1.1" env_logger = "0.11.3" fern = { version = ">= 0.5.0, < 0.7.0", features = ["colored"] } filetime = "0.2.23" ignore = "0.4.22" indexmap = { version = "2.2.6", features = ["serde"] } itertools = ">= 0.9.0, < 0.11.0" log = "0.4.21" md5 = "0.7.0" once_cell = "1.19.0" pathdiff = "0.2.1" precious-core = { version = "0.7.3", path = "./precious-core" } precious-helpers = { version = "0.7.3", path = "./precious-helpers" } precious-testhelper = { version = "0.7.3", path = "./precious-testhelper" } pretty_assertions = "1.4.0" prettytable = "0.10.0" pushd = "0.0.1" rayon = "1.10.0" regex = "1.10.5" serde = { version = "1.0.203", features = ["derive"] } serial_test = "3.1.1" tempfile = "3.10.1" test-case = "3.3.1" thiserror = "1.0.61" toml = "0.8.14" which = ">= 3.0.0, < 5.0.0" [workspace] members = ["precious-helpers", "precious-core", "precious-integration", "precious-testhelper"] [package.metadata.release] tag-name = "v{{version}}" # workaround for https://github.com/cross-rs/cross/issues/1345 [package.metadata.cross.target.x86_64-unknown-netbsd] pre-build = [ "mkdir -p /tmp/netbsd", "curl https://cdn.netbsd.org/pub/NetBSD/NetBSD-9.2/amd64/binary/sets/base.tar.xz -O", "tar -C /tmp/netbsd -xJf base.tar.xz", "cp /tmp/netbsd/usr/lib/libexecinfo.so /usr/local/x86_64-unknown-netbsd/lib", "rm base.tar.xz", "rm -rf /tmp/netbsd", ] precious-0.7.3/Changes.md000066400000000000000000000376551463371203000152630ustar00rootroot00000000000000 ## 0.7.3 2024-06-16 - The generated config for Go projects no longer uses `golangci-lint` for code formatting. Instead, it is configured to use [`gofumpt`](https://github.com/mvdan/gofumpt). - The generated config for `golangci-lint` creates a config file named `.golangci.yml` file instead of `golangci-lint.yml`. This removes the need to pass the `-C` file to `golangci-lint`. - The generated config for Go projects _does_ enable `golangci-lint` as a tidier with its `--fix` flag. This is useful for applying fixes from various linters. ## 0.7.2 - 2024-05-19 - Added `fix` as an alias for the `tidy` command. Implemented by Michael McClimon (@mmcclimon). GH #70. - Added `shell` and `toml` components for `precious config --component ...` options. ## 0.7.1 - 2024-05-05 - Added an `--auto` flag for `precious config init`. If this is specified then `precious` will look at all the files in your project and generate config based on the types of files it finds. Suggested by John Vandenberg (@jayvdb). GH #67. - Fixed a bug when running `precious config init`. The `--component` argument was not required, when it should require at least one. If none were given it would create an empty `precious.toml` file. Reported by John Vandenberg (@jayvdb). GH #67. - Changed how `precious config init` generates config for Perl. The `perlimports` command is now the first one in the generated config. This is necessary because `perlimports` may change the code in a way that `perltidy` will then change further. Running `perlimports` after `perltidy` meant that tidying Perl code could leave the code in a state where it fails linting checks. Implemented by Olaf Alders (@oalders). GH #68. - Added/cleaned up some debugging output for the new `invoke.per-x-or-y` options. Fixes GH #65 and #66. ## 0.7.0 - 2024-03-30 - Added three new **experimental** `invoke` options: - `invoke.per-file-or-dir = n` - `invoke.per-file-or-once = n` - `invoke.per-dir-or-once = n` These will run the command in different ways depending on how many files or directories match the command's config. This lets you pick the fastest way to run commands that can be invoked in more than one way. For example, if you're in a large repo and have only made changes to files in a few directories, `golangci-lint` is much faster when run once per directory. But once the number of directories is large enough, it's faster to just run it once on the whole repo. - All config keys have been changed to use dashes instead of underscores, so for example `path_args` is not `path-args` and `ok_exit_codes` is now `ok-exit-codes`. However, the names with underscores will continue to work. I do not intend to ever deprecate the underscore version. They simply will not be used in the docs and examples. - Fixed cases where `precious` would exit with an exit code of `1` on errors that were _not_ linting failures. Going forward, an exit code of `1` should only be used for linting failures. - `precious` will now emit a warning if your config file uses any of the deprecated config keys, `run_mode` and `chdir`. Support for these options will be removed entirely in a future release. ## 0.6.4 - 2024-03-23 - Added a `--git-diff-from ` option that will find all files changed in the diff between `` and the current `HEAD`. Requested by Michael McClimon. GH #64. ## 0.6.3 - 2024-03-05 - When running `precious config init` and asking for the Perl or Rust components, `precious` would tell you to install `omegasort` even though the generated config did not use it. Reported by Olaf Alders. GH #61. - If precious was run with `--staged` and a file that was staged had been deleted from the filesystem but not removed with `git rm`, it would exit with a very unhelpful error like `Error: No such file or directory (os error 2)`, without any indication of what the file was. Now it will simply ignore such deleted file, though it will log a debug-level message saying that the file is being ignored. GH #63. ## 0.6.2 - 2023-12-18 - When printing the results of running a command that was invoked with a long list of paths, the output will now summarize the paths affected instead of always printing all of them. GH #60. ## 0.6.1 - 2023-11-06 - The `dev/bin/check-go-mod.sh` script created when running `precious config init --component go` is now executable. Reported by Olaf Olders. GH #56. - The generated config for Go now excludes the `vendor` directory for all commands. Implemented by Olaf Alders. GH #57. - When running `precious config init`, it would overwrite an existing file if it was given a `--path` argument, but not if the argument was left unset. Now it will always error out instead of overwriting an existing file. Reported by Olaf Alders. GH #58. - When running `precious config init --component go` a `golangci-lint.yml` file will also be created. GH #59. - As of this release there are no longer binaries built for MIPS on Linux. These targets have been demoted to tier 3 support by the Rust compiler. ## 0.6.0 - 2023-10-29 - Added a new `precious config init` command that can generate `precious.toml` for you. Suggested by Olaf Alders. GH #53. ## 0.5.2 - 2023-10-09 - Help output is now line-wrapped based on your terminal width. - Added a new `precious config list` command that prints a table showing all the commands in your config file. Requested by Olaf Alders. GH #52. ## 0.5.1 - 2023-03-11 - Added a new labels feature. This allows you to group commands in your config file by assigning one or more `labels` in their config. Then when running `precious`, you can run commands with a specific label: `precious lint --label some-label --all`. Suggested by Greg Oschwald. Addresses #8. ## 0.5.0 - 2023-02-04 - The `--git` flag did not include any staged files, only files that were modified but _not_ staged. It now includes all modified files, whether or not they're staged. ## 0.4.1 - 2022-11-26 - The previous release didn't handle all of the old config keys correctly. If just `run_mode` or `chdir` was set, but not both, it may not have replicated the behavior of precious v0.3.0 and earlier with the same settings. ## 0.4.0 - 2022-11-19 - **This release has huge changes to how commands are invoked. The old `run_mode` and `chdir` configuration keys have been deprecated. In the future, using these will cause precious to print a warning, and later support will be removed entirely** See [the documentation](README.md) for more details. There are also some [docs on upgrading from previous versions](docs/upgrade-from-0.3.0-to-0.4.0.md). - Fixed path handling for `--git` and `--staged` when the project root (the directory containing the precious config file) is a subdirectory of the git repo root. Previously this would just attempt to run against incorrect paths. - Precious now supports patterns starting with `!` in `include` and `exclude` keys. This allow you to exclude the given pattern, even if matches previous rules in the list. See [the Git docs on `.gitignore` patterns](https://git-scm.com/docs/gitignore#_pattern_format) for more details. Fixes GH #39. - When run in GitHub Actions, `precious` will now emit [GitHub annotations](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message) for linting errors. ## 0.3.0 - 2022-10-02 - The `expect_stderr` config parameter has been replaced by `ignore_stderr`. This new parameter accepts one or more strings, which are turned into regexes. If the command's `stderr` output matches _any_ of the regexes then it is ignore. The old `expect_stderr` parameter will continue to work for now, but it is no longer documented. To replicate the old behavior simply set `ignore_stderr = ".*"`. ## 0.2.3 - 2022-10-01 - When given the , `--git`, `--staged`, or `--staged-with-stash` flags, precious would error out if all the relevant files were excluded. This is likely to break commit hooks so this is no longer an error. However, if given either the `--all` flag or an explicit list of files, it will still error if all of them are excluded. ## 0.2.2 - 2022-09-24 - Added a `--command` flag to the `lint` and `tidy` subcommands. If this is passed, then only the command with the given name will be run. This addresses #31, requested by Olaf Alders. ## 0.2.1 - 2022-09-18 - The way precious works when run in a subdirectory of the project root has changed. - When given the `--all`, `--git`, `--staged`, or `--staged-with-stash` flags, it will look for all files in the project, regardless of what directory you execute `precious` in. - When given relative paths to files it will do the right thing. Previously it would error out with "No such file or directory". Reported by Greg Oschwald. Fixes #29. ## 0.2.0 - 2022-09-15 - The `--staged` mode no longer tries to stash unstaged content before linting or tidying files. This can cause a number of issues, and shouldn't be the default. There is a new `--staged-with-stash` mode that provides the old `--staged` behavior. Reported by Greg Oschwald. Fixes #30. ## 0.1.7 - 2022-09-03 - If a command sent output to stdout, but not stderr, and exited with an unexpected error code, then the output to stdout would not be shown by precious in the error message. Reported by Greg Oschwald. Fixes #28. ## 0.1.6 - 2022-09-02 - All binaries now statically link musl instead of the system libc. - Added a number of new platforms for released binaries: Linux ARM 32-bit and 64-bit, and macOS ARM 64-bit. ## 0.1.5 - 2022-08-27 - When a command unexpectedly prints to stderr the error message we print now includes both stdout and stderr from that command. Reported by Greg Oschwald. Fixes #26. - When a command was configured with the `run_mode` as `files` and `chdir` as `true`, the paths passed to the command would still include parent directories. Reported by Greg Oschwald. Fixes #25. ## 0.1.4 - 2022-08-14 - Running precious with the `--staged` flag would exit with an error if a post-checkout hook wrote any output to stderr. It appears that any output from a hook to stdout ends up on stderr for some reason, probably related to https://github.com/git/git/commit/e258eb4800e30da2adbdb2df8d8d8c19d9b443e4. Based on PR#24 by Olaf Alders. Fixes #23. ## 0.1.3 - 2022-02-19 - Relaxed some dependencies for the benefit of packaging precious for Debian. Implemented by Jonas Smedegaard. - Added support for `.precious.toml` as a config name. Based on PR#21 by Olaf Alders. Fixes #13. ## 0.1.2 - 2021-10-14 - The order of commands in the config file is now preserved, and commands are executed in the order in which they appear in the config file. This addresses #12, requested by Olaf Alders. - Fixed the tests so that they set the default branch name when running `git init`, rather than setting this via `git config`. This lets anyone run the tests, whereas it was only safe to set this via `git config` in CI. This fixes #14, reported by Olaf Alders. ## 0.1.1 - 2021-07-12 - Fixed config handling of a global `exclude` key. The previous release did not handle a single string as that key's value, only an array. ## 0.1.0 - 2021-07-02 - The verbose and debugging level output now includes timing information on each linter and tidier that is run. This is helpful if you want to figure out why linting or tidying is slower than expected. - Fixed a bug in the debug output. It was not showing the correct cwd for commands where `chdir = true` was set. It always showed the project root directory instead of the directory where the command was run. It _was_ running these commands in the right directory. This was solely a bug in the debug output. ## 0.0.11 - 2021-02-20 - Fixed a bug in 0.0.10 where when _not_ running with `--debug`, precious would not honor the `expect_stderr = true` configuration, and would instead unconditionally treat stderr output as an error. ## 0.0.10 - 2021-02-20 - Errors are now printed out a bit differently, and in particular errors when trying to execute a command (not in the path, command fails unexpectedly, etc.) should be more readable now. - When running any commands, precious now explicitly checks to see if the executable is in your `PATH`. If it's not it prints a new error for this case, as opposed to when running the executable produces an error. This partially addresses #10. ## 0.0.9 - 2021-02-12 - Added a --jobs (-j) option for all subcommands. This lets you limit how many parallel threads are run. The default is to run one thread per available core. Requested by Shane Warden. GH #7. - Fixed a bug where running precious in "git staged mode" (`precious lint --staged`) would cause breakage with merge commits that were the result of resolving a merge conflict. Basically, you'd get the commit but git would no longer know it was merging a commit, because precious was running `git stash` under the hood to only check the staged files, then `git stash pop` to restore things back to their original state. But running `git stash` command. There's some discussion of this on [Stack Overflow](https://stackoverflow.com/questions/24637571/merge-status-lost-when-stashing) but apparently it's still an issue with git today. Reported by Carey Hoffman. GH #9. ## 0.0.8 - 2021-01-30 - Added a summary when there are problems linting or tidying files. Previously, when there were any errors, the last thing precious printed out would be something like `[precious::precious][ERROR] Error when linting files`. Now it also includes a summary of what filter failed on each path. This is primarily useful for linting, as tidy failures are typically failures to execute the tidy command. ## 0.0.7 - 2021-01-02 - Look for a `precious.toml` file in the current directory before trying to find one in the root of the current VCS checkout. - Use the current directory as the root for finding files, rather than the VCS checkout root. - When a filter command does not exist, the error output now shows the full command that was run, including any arguments. Fixes GH #6. ## 0.0.6 - 2020-08-01 - Precious can now be run outside of a VCS repo, as long as there is a `precious.toml` file in the current directory. There is probably more work to be done for precious to not expect to be run inside a VCS repo. - Fixed a bug where lint failures would still result in precious exiting with 0. I'm not sure when this bug was introduced. - Replaced deprecated failure and failure_derive crates with anyhow and thiserror. - Replaced the `on_dir` and `run_once` config flags with a single `run_mode` flag, which can be one of "files" (the default)", "dirs", or "root". If the mode is "root" then the command runs exactly once from the root of the project. - Added an `env` config key for filters. This allows you to define env vars that will be set when the filter's command is run. ## 0.0.5 - 2019-09-05 - Renamed the config key `lint_flag` to `lint_flags` so it can now be an array of strings as well as a single string. - Added a `tidy_flags` option as well. Now commands which are both must define either `lint_flags` or `tidy_flags` (or both). ## 0.0.4 - 2019-08-31 - Fixed a bug where `git stash` would be run multiple times in staged mode if you had more than one filter defined. As a bonus this also makes precious more efficient by not retrieving the list of files to check more than once. ## 0.0.3 - 2019-08-31 - Add a `run_once` flag for commands. This causes command to be run exactly once from the root directory of the project (when it applies). This lets you set your `include` and `exclude` rules based on files properly. Previously you would have to set `include = "."` and the command would run when any files changed, even files which shouldn't trigger a run. - Fixed a bug where a command with `on_dir` set to `true` would incorrectly be run when a file matched both an include _and_ exclude rule. Exclude rules should always win in these situations. ## 0.0.2 - 2019-08-13 - Documentation fixes ## 0.0.1 - 2019-08-13 - First release upon an unsuspecting world. precious-0.7.3/LICENSE-APACHE000066400000000000000000000261351463371203000153040ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. precious-0.7.3/LICENSE-MIT000066400000000000000000000020551463371203000150070ustar00rootroot00000000000000MIT License Copyright (c) 2022 David Rolsky Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. precious-0.7.3/README.md000066400000000000000000001116231463371203000146340ustar00rootroot00000000000000# Precious - One Code Quality Tool to Rule Them All Who doesn't love linters and tidiers (aka pretty printers)? I sure love them. I love them so much that in many of my projects I might have five or ten! Wouldn't it be great if you could run all of them with just one command? Wouldn't it be great if that command just had one config file to define what tools to run on each part of your project? Wouldn't it be great if Sauron were our ruler? Now with Precious you can say "yes" to all of those questions. ## TLDR Precious is a code quality tool that lets you run all of your linters and tidiers with a single command. It's features include: - One file, `precious.toml`, defines all of your linter and tidier commands, as well as what files they operate on. - Respects VCS ignore files and allows global and per-command excludes. - Language-agnostic, and it works the same way with single- or multi-language projects. - Easy integration with commit hooks and CI systems. - Commands are executed in parallel by default, with one process per CPU. - Commands can be grouped with labels, for example to just run a subset of commands for commit hooks and all commands in CI. ## Installation There are several ways to install this tool. ### Use ubi Install my [universal binary installer (ubi)](https://github.com/houseabsolute/ubi) tool and you can use it to download `precious` and many other tools. ``` $> ubi --project houseabsolute/precious --in ~/bin ``` ### Binary Releases You can grab a binary release from the [releases page](https://github.com/houseabsolute/precious/releases). Untar the tarball and put the executable it contains somewhere in your path and you're good to go. ### Cargo You can also install this via `cargo` by running `cargo install precious`. See [the cargo documentation](https://doc.rust-lang.org/cargo/commands/cargo-install.html) to understand where the binary will be installed. ## Getting Started The `precious` binary has a `config init` subcommand that will generate a config file for you. This subcommand takes the following flags: | Flag | Description | | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `-a`, `--auto` | Automatically determines what components to create | | `-c`, ‑‑component <COMPONENT> | The component(s) to generate config for (see below) | | `-p`, ‑‑path <PATH> | The path to which the config file should be written. Defaults to `./precious.toml` | You must pass either `--auto` or at least one `--component`. In `--auto` mode, `precious` will look at all the files in your project and generate config based on the types of files it finds. Here's an example for a Rust project: ``` $> precious config init --component rust --component gitignore --component yaml ``` ### Components The following components are supported: - `go` - Generates config for a Go project which uses [`golangci-lint`](https://golangci-lint.run/) for linting and tidying. - `perl` - Generates config for a Perl project which uses a variety of tools, including [`perlcritic`](https://metacpan.org/dist/Perl-Critic) and [`perltidy`](https://metacpan.org/dist/Perl-Tidy). - `rust` - Generates config for a Rust project which uses [`rustfmt`](https://rust-lang.github.io/rustfmt/) for tidying and [`clippy`](https://doc.rust-lang.org/stable/clippy/) for linting. - `shell` - Generated config which uses [`shfmt`](https://github.com/mvdan/sh) for tidying and [`shellcheck`](https://www.shellcheck.net/) for linting. - `gitignore` - Generates config to lint and tidy (by sorting) `.gitignore` files using [`omegasort`](https://github.com/houseabsolute/omegasort). - `markdown` - Generates config to lint and tidy Markdown files using [`prettier`](https://prettier.io/). - `toml` - Generates config to lint and tidy TOML files using [`taplo`](https://taplo.tamasfe.dev/). - `yaml` - Generates config to lint and tidy YAML files using [`prettier`](https://prettier.io/). ### Examples This repo's [examples directory](examples) has `precious.toml` config files for several languages. Contributions for other languages are welcome! The config in the examples matches what `precious config init` generates, and there are comments in the files with more details about how you might change this configuration. Also check out [the example `install-dev-tools.sh`](examples/bin/install-dev-tools.sh) script for a tool to install all of your project's linting and tidying dependencies. You can customize this as needed to install only the tools you need for your project. ## Configuration Precious is configured via a single `precious.toml` or `.precious.toml` file that lives in your project root. The file is in [TOML format](https://github.com/toml-lang/toml). There is just one key that can be set in the top level table of the config file: | Key | Type | Required? | Description | | --------- | ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `exclude` | array of strings | no | Each array member is a pattern that will be matched against potential files when `precious` is run. These patterns are matched in the same way as patterns in a [gitignore file](https://git-scm.com/docs/gitignore#_pattern_format).
You can use lines starting with a `!` to negate the meaning of previous rules in the list, so that anything that matches is _not_ excluded even if it matches previous rules. | All other configuration is on a per-command basis. A command is something that either tidies (aka pretty prints or beautifies), lints, or does both. These commands are external programs which precious will execute as needed. Each command is defined in a block named something like `[commands.command-name]`. Each name after the `commands.` prefix must be unique. You **can** have run the same executable differently with different commands as long as each command has a unique name. Commands are run in the same order as they appear in the config file. ### Command Invocation There are three configuration keys for command invocation. All of them are optional. If none are specified, `precious` defaults to this: ```toml invoke = "per-file" working-dir = "root" path-args = "file" ``` This runs the command once per file with the working directory for the command as the project root. The command will be passed a relative path to the file from the root as a single argument to the command. #### `invoke` The `invoke` key tells `precious` how the command should be invoked. | Value | Description | | ------------ | ---------------------------------------------------------------------- | | `"per-file"` | Run this command once for each matching file. **This is the default.** | | `"per-dir"` | Run this command once for each matching directory. | | `"once"` | Run this command once. | There are some experimental options for the `invoke` key as well. **The exact names or the details of how they operate may change in a future release.** | Value | Description | | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | |  .per‑file‑or‑dir = n  | If the number of matching files is less than `n`, run this command once for each matching file. Otherwise run it once for each matching directory. | |  .per‑file‑or‑once = n  | If the number of matching files is less than `n`, run this command once for each matching file. Otherwise run it once. | |  .per‑dir‑or‑dir = n  | If the number of matching directories is less than `n`, run this command once for each matching directory. Otherwise run it once. | These are written like this: ```toml [commands.some-command] invoke.per-file-or-dir = 42 ``` These experimental options are useful for optimizing the speed of running a command. In some cases, a command can be run in multiple ways, and how quickly it completes depends on how many files or directories need to be linted or tidied. The `golangci-lint` tool is a good example. Invoking it multiple times for a few directories can be much faster than running it against the entire repo. However, once there are enough directories to check, invoking it once for the entire repo will be faster. Note that the `path-args` setting needs to work with both possible cases for these options. For `golangci-lint`, that means setting it to `dir` when using `per-dir-or-once`. #### `working-dir` The `working-dir` key tells precious what the working directory should be when the command is run. | Value | Description | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"root"` | The working directory is the project root. **This is the default.** | | `"dir"` | The working directory is the directory containing the matching files. This means `precious` will `chdir` into each matching directory in turn as it executes the command. | | .chdir‑to = "path" | The working directory will be the given path when executing the command. **This path must be relative to the project root.** | ##### `working-dir.chdir-to = "path"` The final option for `working-dir` is to set an explicit path as the working directory. With this option, the working directory will be set to the given subdirectory when the command is executed. Relative paths passed to the command will be relative to this subdirectory rather than the project root. #### `path-args` The `path-args` key tells precious how paths should be passed when the command is run. | Value | Description | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"file"` | Passes the path to the matching file relative to the root. **This is the default.**
With `working-directory.chdir-to` the path is relative to the given working directory. | | `"dir"` | Passes the path to the directory containing the matching files relative to the root.
With `working-directory.chdir-to` the path is relative to the given working directory. | | `"none"` | No paths are passed to the command at all. | | `"dot"` | Always pass `.` as the path. This is useful when `working-dir = "dir"` and the command still requires a path to be passed. | | "absolute‑file" | Passes the path to the matching file as an absolute path from the filesystem's root directory. | | "absolute‑dir" | Passes the path to the directory containing the matching files as an absolute path from the filesystem's root directory. | #### Nonsensical Combinations Most combinations of these configuration keys are allowed, but there are some nonsensical combinations that will cause `precious` to exit with an error. ``` invoke = "per-file" path-args = "dir", "none", "dot", or "absolute-dir" ``` You cannot invoke a command once per file without passing the filename. ``` invoke = "per-dir" path-args = "none" or "dot" working-dir = "root" # ... or ... working-dir.chdir-to = "whatever" ``` You cannot invoke a command once per directory from a root without passing the directory name or a list of file names. If you want to run a command once per directory with no path arguments or using `.` as the path then you _must_ set `working-dir = "dir"`. ``` invoke = "once" working-dir = "dir" ``` You cannot invoke a command once if the working directory is set to each matching directory in turn. #### Invocation Examples See the [Invocation Examples documentation](docs/invocation-examples.md) for comprehensive examples of every possible set of options. ### Other Per-Command Configuration Keys The other keys allowed for each command are as follows: | Key | Type | Required? | Applies To | Default | Description | | ------------------------- | ---------------------------- | --------- | ------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | string | **yes** | all | | This must be either `lint`, `tidy`, or `both`. This defines what type of command this is. A command which is `both` **must** define `lint-flags` or `tidy-flags` as well. | | `include` | string or array of strings | **yes** | all | | Each array member is a [gitignore pattern](https://git-scm.com/docs/gitignore#_pattern_format) that tells `precious` what files this command applies to.
You can use lines starting with a `!` to negate the meaning of previous rules in the list, so that anything that matches is _not_ included even if it matches previous rules. | | `exclude` | string or array of strings | no | all | | Each array member is a [gitignore pattern](https://git-scm.com/docs/gitignore#_pattern_format) that tells `precious` what files this command should not be applied to.
You can use lines starting with a `!` to negate the meaning of previous rules in the list, so that anything that matches is _not_ excluded even if it matches previous rules. | | `cmd` | string or array of strings | **yes** | all | | This is the executable to be run followed by any arguments that should always be passed. | | `env` | table - values are strings | no | all | | This key allows you to set one or more environment variables that will be set when the command is run. The values in this table must be strings. | | `path-flag` | string | no | all | | By default, `precious` will pass the path being operated on to the command it executes as the final, positional, argument(s). If the command takes paths via a flag you need to specify that flag with this key. | | `lint-flags` | string or array of strings | no | combined linter & tidier | | If a command is both a linter and tidier then it may take extra flags to operate in linting mode. This is how you set that flag. | | `tidy-flags` | string or array of strings | no | combined linter & tidier | | If a command is both a linter and tidier then it may take extra flags to operate in tidying mode. This is how you set that flag. | | `ok-exit-codes` | integer or array of integers | **yes** | all | | Any exit code that **does not** indicate an abnormal exit should be here. For most commands this is just `0` but some commands may use other exit codes even for a normal exit. | | `lint-failure-exit-codes` | integer or array of integers | no | linters | | If the command is a linter then these are the status codes that indicate a lint failure. These need to be specified so `precious` can distinguish an exit because of a lint failure versus an exit because of some unexpected issue. | | `ignore-stderr` | string or array of strings | all | all | | By default, `precious` assumes that when a command sends output to `stderr` that indicates a failure to lint or tidy. This parameter can specify one or more regexes. These regexes will be matched against the command's stderr output. If _any_ of the regexes match, the stderr output is ignored. | | `labels` | string or array of strings | all | all | | One or more labels used to categorize commands. See below for more details. | ### Referencing the Project Root For commands that can be run from a subdirectory, you may need to specify config files in terms of the project root. You can do this by using the string `$PRECIOUS_ROOT` in any element of the `cmd` configuration key. So for example you might write something like this: ```toml cmd = ["some-tidier", "--config", "$PRECIOUS_ROOT/some-tidier.conf"] ``` The `$PRECIOUS_ROOT` string will be replaced by the absolute path to the project root. ## Running Precious To get help run `precious --help`. The root command takes the following flags: | Flag | Description | | --------------------------- | ------------------------------------------------------------------- | | `-c`, `--config` `` | Path to the precious config file | | `-j`, `--jobs` `` | Number of parallel jobs (threads) to run (defaults to one per core) | | `-q`, `--quiet` | Suppresses most output | | `-a`, `--ascii` | Replace super-fun Unicode symbols with terribly boring ASCII | | `-v`, `--verbose` | Enable verbose output | | `-V`, `--version` | Prints version information | | `-d`, `--debug` | Enable debugging output | | `-t`, `--trace` | Enable tracing output (maximum logging) | | `-h`, `--help` | Prints help information | ### Parallel Execution Precious will always execute commands in parallel, with one process per CPU by default. The execution is parallelized based on the command's invocation configuration. For example, on a 12 CPU system, a command that has `invoke = "per-file"` will be executed up to 12 times in parallel, with each command execution receiving one file. You can disable parallel execution by passing `--jobs 1`. ### Subcommands The `precious` command has three subcommands, `lint`, `tidy`, and `config`. You must always specify one of these. The `lint` and `tidy` commands take the same flags: #### Selecting Paths to Operate On When you run `precious` you must tell it what paths to operate on. There are several flags for this: | Mode | Flag | Description | | ------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | All paths | `-a`, `--all` | Run on all files under the project root (the directory containing the precious config file). | | Modified files according to git | `-g`, `--git` | Run on all files that git reports as having been modified, including staged files. | | Staged files according to git | `-s`, `--staged` | Run on all files that git reports as having been staged. | | Files that differ from a given git ref | `-d `, ‑‑git‑diff‑from | Run on all files in the current `HEAD` that differ from the given ``. The value `` can be a branch name, like `master`, or an ref name like `HEAD~6` or `master@{2.days.ago}`. See `git help rev-parse` for more options. Note that this will _not_ see files with uncommitted changes in the local working directory. | | Staged files according to git, with unstaged changes stashed | ‑‑staged‑with‑stash | This is like `--stashed`, but it will stash unstaged changes while it runs and pop the stash at the end. This ensures that commands only run against the staged version of your codebase. This can cause issues with many editors or other tools that watch for file changes, so exercise care with this flag. Be careful when using this option in scripts because of this issue. | | Paths given on CLI | | If you don't pass any of the above flags then `precious` will expect one or more paths to be passed on the command line after all other flags. If any of these paths are directories then that entire directory tree will be included. | #### Running One Command You can tidy or lint with just a single command by passing the `--command` flag: ``` $> precious lint --command some-command --all ``` The name passed to `--command` must match the name of the command in your config file. So in the above example, this would look for a command defined as `[commands.some-command]` in your config. #### Selecting Commands With Labels Each command can be assigned one or more labels. This lets you create arbitrary groups of commands. Then when you tidy or lint you can pick a label by passing a `--label` flag: ``` $> precious lint --label some-label --all ``` The way labels work is as follows: - A command _without_ a `labels` key in its config has one label, `default`. - Running `tidy` or `lint` _without_ a `--label` flag uses the `default` label. - If you assign `labels` to a command and you want that command included in the `default` label, you must explicitly include it: ```toml [command.some-command] # ... labels = [ "default", "some-label" ] ``` #### Default Exclusions When selecting paths `precious` _always_ respects your ignore files. Right now it only knows how this works for git, and it will respect all of the following ignore files: - Per-directory `.ignore` and `.gitignore` files. - The `.git/info/exclude` file. - Global gitignore globs, usually found in `$XDG_CONFIG_HOME/git/ignore`. This is implemented using the [rust `ignore` crate](https://crates.io/crates/ignore), so adding support for other VCS systems should be proposed there. In addition, you can specify excludes for all commands by setting a global `exclude` key. Finally, you can specify per-command `include` and `exclude` keys. #### How Include and Exclude Are Applied When `precious` runs it does the following to determine which commands apply to which paths. - The base files to operate on are selected based on the command line flag specified. This is one of: - `--all` - All files under the project root (the directory containing the precious config file). - `--git` - All files in the git repo that have been modified, including staged files. - `--staged` - All files in the git repo that have been staged. - `--git-diff-from ` - All files in the current `HEAD` that differ from ``. - paths passed on the CLI - If a path is a file it is added to the list as-is. If the path is a directory then all the files under that directory (recursively) are found. - VCS ignore rules are applied to remove files from this list. - The global exclude rules are applied to remove files from this list. - Based on the command's `invoke` key, a list of files to be checked is generated and the command's include/exclude rules are applied. To be included, a file must match at least one include rule _and_ not match any exclude rules to be accepted. - If `invoke` is `per-file`, then the rules are applied one file at a time. - If `invoke` is `per-dir`, then if any file in the directory matches the rules, the command will be run on that directory. - If `invoke` is `once`, then the rules are applied to all of the files at once. If any one of those files matches the include rule, the command will be run. ### The `config` Subcommand In addition to the `init` subcommand, this command has a `list` subcommand. This prints a Unicode table describing the commands in your config file. ``` Found config file at: /home/autarch/projects/precious/precious.toml ┌─────────────────────┬──────┬────────────────────────────────────────────────────────┐ │ Name ┆ Type ┆ Runs │ ╞═════════════════════╪══════╪════════════════════════════════════════════════════════╡ │ rustfmt ┆ both ┆ rustfmt --edition 2021 │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ clippy ┆ lint ┆ cargo clippy --locked --all-targets --all-features │ │ ┆ ┆ --workspace -- -D clippy::all │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ prettier ┆ both ┆ ./node_modules/.bin/prettier --no-config --print-width │ │ ┆ ┆ 100 --prose-wrap always │ ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ omegasort-gitignore ┆ both ┆ omegasort --sort path --unique │ └─────────────────────┴──────┴────────────────────────────────────────────────────────┘ ``` ## Configuration Recommendations Here are some recommendations for how to get the best experience with precious. ### Choosing How to `invoke` the Command Some commands might work equally well with `invoke` set to either `per-dir` or `once`. The right run mode to choose depends on how you are using precious. In general, if you either have a very small set of directories, _or_ you are running precious on most or all of the directories at once, then `once` will be faster. However, if you have a larger set of directories and you usually only need to lint or tidy a small subset of these at once, then `per-dir` mode will be faster. You can also use the experimental `invoke.per-dir-or-once = n` option to invoke the command one of two ways, depending on the number of directories that precious will operate on. ### Quiet Flags for Commands Many commands will accept a "quiet" flag of some sort. In general, you probably _do not_ want to run commands in a quiet mode with precious. In the case of a successful tidy or lint command execution, precious already hides all stdout from the command that it runs. If the command fails somehow, precious will print out the command's stdout and stderr output. By default, precious treats _any_ output to stderr as an error in the command (as opposed to a linting failure). You can use the `ignore-stderr` to specify one or more regexes for allowed stderr output. In addition, you can see all stdout and stderr output from a command by running precious in `--debug` mode. All of which is to say that in general there's no value to running a command in quiet mode with precious. All that does is make it harder to debug issues with that command when lint checks fail or other issues occur. ## Exit Codes When running in `--tidy` mode, precious always exits with `0` if there are no errors when tidying, whether or not any files are tidied. When running in `--lint` mode, precious will exit with `0` when all files pass linting. If any lint commands fail it will exit with `1`. In both modes, if any commands fail, either by returning exit codes that aren't listed as ok or by printing to stderr unexpectedly, then the exit code will not be `0` or `1`. ## Common Scenarios There are some configuration scenarios that you may need to handle. Here are some examples: ### Command runs just once for the entire source tree Some commands, such as [rust-clippy](https://github.com/rust-lang/rust-clippy), expect to run just once across the entire source tree, rather than once per file or directory. In order to make that happen you should use the following config: ```toml include = "**/*.rs" invoke = "once" path-args = "dot" # or "none" ``` This will cause `precious` to run the command exactly once in the project root. ### Command runs in the same directory as the files it lints and does not accept path arguments If you want to run the command without passing the path being operated on to the command, set `invoke = "per-dir"`, `working-dir = "dir"`, and `path-args = "none"`: ```toml include = "**/*.rs" invoke = "per-dir" working-dir = "dir" path-args = "none" ``` ### You want a command to exclude an entire directory (tree) except for one or more files Use an ignore pattern starting with `!` in the `exclude` list: ```toml [commands.rustfmt] type = "both" include = "**/*.rs" exclude = [ "path/to/dir", "!path/to/dir/included.rs", ] cmd = ["rustfmt"] lint-flags = "--check" ok-exit-codes = [0] lint-failure-exit-codes = [1] ``` ### You want to run Precious as a commit hook Simply run `precious lint -s` in your hook. It will exit with a non-zero status if any of the lint commands indicate a linting problem. ### You want to run commands in a specific order As of version 0.1.2, commands are run in the same order as they appear in the config file. ## Build Status ### Build and Test ![Build Status](https://github.com/houseabsolute/precious/actions/workflows/ci.yml/badge.svg) ### Cargo Audit Nightly ![Cargo Audit Nightly](https://github.com/houseabsolute/precious/actions/workflows/audit-nightly.yml/badge.svg) ### Cargo Audit On Push ![Cargo Audit On Push](https://github.com/houseabsolute/precious/actions/workflows/audit-on-push.yml/badge.svg) precious-0.7.3/dev/000077500000000000000000000000001463371203000141275ustar00rootroot00000000000000precious-0.7.3/dev/bin/000077500000000000000000000000001463371203000146775ustar00rootroot00000000000000precious-0.7.3/dev/bin/install-dev-tools.sh000077500000000000000000000017031463371203000206170ustar00rootroot00000000000000#!/bin/bash set -eo pipefail function run () { echo $1 eval $1 } function install_tools () { curl --silent --location \ https://raw.githubusercontent.com/houseabsolute/ubi/master/bootstrap/bootstrap-ubi.sh | sh run "rustup component add clippy" run "ubi --project houseabsolute/precious --in ~/bin" run "ubi --project houseabsolute/omegasort --in ~/bin" run "ubi --project crate-ci/typos --in ~/bin" run "ubi --project tamasfe/taplo --tag 0.8.1 --in ~/bin" run "npm install prettier" } if [ "$1" == "-v" ]; then set -x fi mkdir -p $HOME/bin set +e echo ":$PATH:" | grep --extended-regexp ":$HOME/bin:" >& /dev/null if [ "$?" -eq "0" ]; then path_has_home_bin=1 fi set -e if [ -z "$path_has_home_bin" ]; then PATH=$HOME/bin:$PATH fi install_tools echo "Tools were installed into $HOME/bin." if [ -z "$path_has_home_bin" ]; then echo "You should add $HOME/bin to your PATH." fi exit 0 precious-0.7.3/do-release.sh000077500000000000000000000003451463371203000157320ustar00rootroot00000000000000#!/bin/bash cargo release --package precious-helpers $@ cargo release --package precious-testhelper $@ cargo release --package precious-core $@ cargo release --package precious-integration $@ cargo release --package precious $@ precious-0.7.3/docs/000077500000000000000000000000001463371203000143015ustar00rootroot00000000000000precious-0.7.3/docs/invocation-examples.md000066400000000000000000000243721463371203000206200ustar00rootroot00000000000000# Invocation Examples The following examples illustrate how `precious` executes a command with different invocation options. For these examples we will assume that the command is configured to execute for any file ending in `.go`. The executable is `some-linter` and only takes paths as argument. The file tree looks like this: ``` example ├── app.go ├── main.go ├── pkg1 │ ├── pkg1.go ├── pkg2 │ ├── pkg2.go │ ├── pkg2_test.go │ └── subpkg │ └── subpkg.go └── precious.toml ``` --- - **Runs once per file** - **Working directory is the project root** - **Relative file path as the argument** This is the default configuration. ```toml [commands.some-linter] invoke = "per-file" working-dir = "root" path-args = "file" ``` ``` some-linter app.go some-linter main.go some-linter pkg1/pkg1.go some-linter pkg2/pkg2.go some-linter pkg2/pkg2_test.go some-linter pkg2/subpkg/subpkg.go ``` --- - **Runs once per file** - **From the root** - **Absolute file path as the argument** ```toml [commands.some-linter] invoke = "per-file" working-dir = "root" path-args = "absolute-file" ``` ``` some-linter /example/app.go some-linter /example/main.go some-linter /example/pkg1/pkg1.go some-linter /example/pkg2/pkg2.go some-linter /example/pkg2/pkg2_test.go some-linter /example/pkg2/subpkg/subpkg.go ``` --- - **Runs once per file** - **Working directory changes per-directory** - **Relative file path as the argument** ```toml [commands.some-linter] invoke = "per-file" working-dir = "dir" path-args = "file" ``` ``` some-linter app.go some-linter main.go cd /example/pkg1 some-linter pkg1.go cd /example/pkg2 some-linter pkg2.go some-linter pkg2_test.go cd /example/pkg2/subpkg some-linter subpkg.go ``` --- - **Runs once per file** - **Working directory changes per-directory** - **Absolute file path as the argument** ```toml [commands.some-linter] invoke = "per-file" working-dir = "dir" path-args = "absolute-file" ``` ``` some-linter /example/app.go some-linter /example/main.go cd /example/pkg1 some-linter /example/pkg1/pkg1.go cd /example/pkg2 some-linter /example/pkg2/pkg2.go some-linter /example/pkg2/pkg2_test.go cd /example/pkg2/subpkg some-linter /example/pkg2/subpkg/subpkg.go ``` It's odd to combine `working-dir = "dir"` with `path-args = "absolute-file"`, but it will work. --- - **Runs once per file** - **Working directory is the given subdirectory** - **Relative file path as the argument** ```toml [commands.some-linter] invoke = "per-file" working-dir.chdir-to = "pkg1" path-args = "file" ``` ``` cd /example/pkg1 some-linter ../app.go some-linter ../main.go some-linter ../pkg2/pkg2.go some-linter ../pkg2/pkg2_test.go some-linter ../pkg2/subpkg/subpkg.go some-linter pkg1.go ``` --- - **Runs once per file** - **Working directory is the given subdirectory** - **Absolute file path as the argument** ```toml [commands.some-linter] invoke = "per-file" working-dir.chdir-to = "pkg1" path-args = "absolute-file" ``` ``` cd /example/pkg1 some-linter /example/app.go some-linter /example/main.go some-linter /example/pkg1/pkg1.go some-linter /example/pkg2/pkg2.go some-linter /example/pkg2/pkg2_test.go some-linter /example/pkg2/subpkg/subpkg.go ``` --- - **Runs once per directory** - **Working directory is the root** - **Relative directory path as pargument** ```toml [commands.some-linter] invoke = "per-dir" working-dir = "root" path-args = "dir" ``` ``` some-linter . some-linter pkg1 some-linter pkg2 some-linter pkg2/subpkg ``` --- - **Runs once per directory** - **Working directory is the root** - **Absolute directory path as pargument** ```toml [commands.some-linter] invoke = "per-dir" working-dir = "root" path-args = "absolute-dir" ``` ``` some-linter /example some-linter /example/pkg1 some-linter /example/pkg2 some-linter /example/pkg2/subpkg ``` --- - **Runs once per directory** - **Working directory is the root** - **Relative file paths as arguments** ```toml [commands.some-linter] invoke = "per-dir" working-dir = "root" path-args = "file" ``` ``` some-linter app.go main.go some-linter pkg1/pkg1.go some-linter pkg2/pkg2.go pkg2/pkg2_test.go some-linter pkg2/subpkg/subpkg.go ``` --- - **Runs once per directory** - **Working directory is the root** - **Absolute file paths as arguments** ```toml [commands.some-linter] invoke = "per-dir" working-dir = "root" path-args = "absolute-file" ``` ``` some-linter /example/app.go /example/main.go some-linter /example/pkg1/pkg1.go some-linter /example/pkg2/pkg2.go /example/pkg2/pkg2_test.go some-linter /example/pkg2/subpkg/subpkg.go ``` --- - **Runs once per directory** - **Working directory is each directory in turn** - **Dot (`.`) as the path argument** ```toml [commands.some-linter] invoke = "per-dir" working-dir = "dir" path-args = "dot" ``` ``` some-linter . cd /example/pkg1 some-linter . cd /example/pkg2 some-linter . cd /example/pkg2/subpkg some-linter . ``` --- - **Runs once per directory** - **Working directory is each directory in turn** - **No path argument** ```toml [commands.some-linter] invoke = "per-dir" working-dir = "dir" path-args = "none" ``` ``` some-linter cd /example/pkg1 some-linter cd /example/pkg2 some-linter cd /example/pkg2/subpkg some-linter ``` --- - **Runs once per directory** - **Working directory is the given subdirectory** - **Relative file paths as the argument** ```toml [commands.some-linter] invoke = "per-dir" working-dir.chdir-to = "pkg1" path-args = "file" ``` ``` cd /example/pkg1 some-linter ../app.go ../main.go some-linter ../pkg2/pkg2.go ../pkg2/pkg2_test.go some-linter ../pkg2/subpkg/subpkg.go some-linter pkg1.go ``` --- - **Runs once per directory** - **Working directory is the given subdirectory** - **Absolute file paths as the argument** ```toml [commands.some-linter] invoke = "per-dir" working-dir.chdir-to = "pkg1" path-args = "absolute-file" ``` ``` cd /example/pkg1 some-linter /example/app.go /example/main.go some-linter /example/pkg1/pkg1.go some-linter /example/pkg2/pkg2.go /example/pkg2/pkg2_test.go some-linter /example/pkg2/subpkg/subpkg.go ``` --- - **Runs once per directory** - **Working directory is the given subdirectory** - **Relative directory path as the argument** ```toml [commands.some-linter] invoke = "per-dir" working-dir.chdir-to = "pkg1" path-args = "dir" ``` ``` cd /example/pkg1 some-linter . some-linter .. some-linter ../pkg2 some-linter ../pkg2/subpkg ``` --- - **Runs once per directory** - **Working directory is the given subdirectory** - **Absolute directory path as the argument** ```toml [commands.some-linter] invoke = "per-dir" working-dir.chdir-to = "pkg1" path-args = "absolute-dir" ``` ``` cd /example/pkg1 some-linter /example some-linter /example/pkg1 some-linter /example/pkg2 some-linter /example/pkg2/subpkg ``` --- - **Runs once for all files** - **Working directory is the root** - **Relative file paths as arguments** ```toml [commands.some-linter] invoke = "once" working-dir = "root" path-args = "file" ``` ``` some-linter \ app.go \ main.go \ pkg1/pkg1.go \ pkg2/pkg2.go \ pkg2/pkg2_test.go \ pkg2/subpkg/subpkg.go ``` --- - **Runs once for all files** - **Working directory is the root** - **Absolute file paths as arguments** ```toml [commands.some-linter] invoke = "once" working-dir = "root" path-args = "absolute-file" ``` ``` some-linter \ /example/app.go \ /example/main.go \ /example/pkg1/pkg1.go \ /example/pkg2/pkg2.go \ /example/pkg2/pkg2_test.go \ /example/pkg2/subpkg/subpkg.go ``` --- - **Runs once for all directories** - **Working directory is the root** - **Relative directory paths as arguments** ```toml [commands.some-linter] invoke = "once" working-dir = "root" path-args = "dir" ``` ``` some-linter . pkg1 pkg2 pkg2/subpkg ``` --- - **Runs once for all directories** - **Working directory is the root** - **Absolute directory paths as arguments** ```toml [commands.some-linter] invoke = "once" working-dir = "root" path-args = "absolute-dir" ``` ``` some-linter \ /example \ /example/pkg1 \ /example/pkg2 \ /example/pkg2/subpkg ``` --- - **Runs once for all paths** - **Working directory is the root** - **Dot (`.`) as the path argument** ```toml [commands.some-linter] invoke = "once" working-dir = "root" path-args = "dot" ``` ``` some-linter . ``` --- - **Runs once for all paths** - **Working directory is the root** - **No path argument** ```toml [commands.some-linter] invoke = "once" working-dir = "root" path-args = "none" ``` ``` some-linter ``` --- - **Runs once** - **Working directory is the given subdirectory** - **Relative file paths as the arguments** ```toml [commands.some-linter] invoke = "once" working-dir.chdir-to = "pkg1" path-args = "file" ``` ``` cd /example/pkg1 some-linter ../app.go ../main.go pkg1.go ../pkg2/pkg2.go ../pkg2/pkg2_test.go ../pkg2/subpkg/subpkg.go ``` --- - **Runs once** - **Working directory is the given subdirectory** - **Absolute file paths as the arguments** ```toml [commands.some-linter] invoke = "once" working-dir.chdir-to = "pkg1" path-args = "absolute-file" ``` ``` cd /example/pkg1 some-linter \ /example/app.go \ /example/main.go \ /example/pkg1/pkg1.go \ /example/pkg2/pkg2.go \ /example/pkg2/pkg2_test.go \ /example/pkg2/subpkg/subpkg.go ``` --- - **Runs once** - **Working directory is the given subdirectory** - **Relative directory paths as the arguments** ```toml [commands.some-linter] invoke = "once" working-dir.chdir-to = "pkg1" path-args = "dir" ``` ``` cd /example/pkg1 some-linter .. . ../pkg2 ../pkg2/subpkg ``` --- - **Runs once** - **Working directory is the given subdirectory** - **Absolute directory paths as the arguments** ```toml [commands.some-linter] invoke = "once" working-dir.chdir-to = "pkg1" path-args = "absolute-dir" ``` ``` cd /example/pkg1 some-linter /example /example/pkg1 /example/pkg2 /example/pkg2/subpkg ``` --- - **Runs once** - **Working directory is the given subdirectory** - **Dot (`.`) as the path argument** ```toml [commands.some-linter] invoke = "once" working-dir.chdir-to = "pkg1" path-args = "dot" ``` ``` cd /example/pkg1 some-linter . ``` --- - **Runs once** - **Working directory is the given subdirectory** - **No path argument** ```toml [commands.some-linter] invoke = "once" working-dir.chdir-to = "pkg1" path-args = "none" ``` ``` cd /example/pkg1 some-linter ``` precious-0.7.3/docs/upgrade-from-0.3.0-to-0.4.0.md000066400000000000000000000025311463371203000207250ustar00rootroot00000000000000# Upgrading from 0.3.0 to 0.4.0 Some of the command configuration has changed dramatically in this release. The old `run_mode` and `chdir` config keys have been replaced by new options, `invoke`, `working_dir`, and `path_args`. **The old keys have been deprecated. They will continue to work for a while but are no longer documented.** Here is what the new config looks like for all possible combinations of the `run_mode` and `chdir` keys. --- ```toml run_mode = "files" chdir = false ``` This was the default, and the equivalent defaults produce the same result: ```toml invoke = "per-file" working_dir = "root" path_args = "file" ``` --- ```toml run_mode = "files" chdir = true ``` ```toml invoke = "per-file" working_dir = "dir" path_args = "file" ``` --- ```toml run_mode = "dirs" chdir = false ``` ```toml invoke = "per-dir" working_dir = "root" path_args = "dir" ``` --- ```toml run_mode = "dirs" chdir = true ``` ```toml invoke = "per-dir" working_dir = "dir" path_args = "none" ``` --- ```toml run_mode = "once" chdir = false ``` ```toml invoke = "once" working_dir = "root" path_args = "dot" ``` --- ```toml run_mode = "once" chdir = true ``` ```toml invoke = "once" working_dir = "root" path_args = "none" ``` --- But note that the new config options allow for many other possibilities that couldn't be expressed with the old options. precious-0.7.3/examples/000077500000000000000000000000001463371203000151675ustar00rootroot00000000000000precious-0.7.3/examples/bin/000077500000000000000000000000001463371203000157375ustar00rootroot00000000000000precious-0.7.3/examples/bin/install-dev-tools.sh000066400000000000000000000023231463371203000216530ustar00rootroot00000000000000#!/bin/bash set -eo pipefail function run () { echo $1 eval $1 } function install_any_project_tools () { curl --silent --location \ https://raw.githubusercontent.com/houseabsolute/ubi/master/bootstrap/bootstrap-ubi.sh | sh run "ubi --project houseabsolute/precious --in ~/bin" run "ubi --project houseabsolute/omegasort --in ~/bin" } function install_go_project_tools () { run "ubi --project golangci/golangci-lint --in ~/bin" } function install_rust_project_tools () { run "rustup component add clippy" } if [ "$1" == "-v" ]; then set -x fi mkdir -p $HOME/bin set +e echo ":$PATH:" | grep --extended-regexp ":$HOME/bin:" >& /dev/null if [ "$?" -eq "0" ]; then path_has_home_bin=1 fi set -e if [ -z "$path_has_home_bin" ]; then PATH=$HOME/bin:$PATH fi install_any_project_tools install_go_project_tools install_rust_project_tools echo "Tools were installed into $HOME/bin." if [ -z "$path_has_home_bin" ]; then echo "You should add $HOME/bin to your PATH." fi # For Perl, you would generally expect to have a cpanfile in the project root # that included the relevant develop prereqs, so developers could just run # `cpanm --installdeps --with-develop .` exit 0 precious-0.7.3/examples/golang/000077500000000000000000000000001463371203000164365ustar00rootroot00000000000000precious-0.7.3/examples/golang/helpers/000077500000000000000000000000001463371203000201005ustar00rootroot00000000000000precious-0.7.3/examples/golang/helpers/check-go-mod.sh000066400000000000000000000015151463371203000226730ustar00rootroot00000000000000#!/bin/bash set -e ROOT=$( git rev-parse --show-toplevel ) BEFORE_MOD=$( md5sum "$ROOT/go.mod" ) BEFORE_SUM=$( md5sum "$ROOT/go.sum" ) OUTPUT=$( go mod tidy -v 2>&1 ) AFTER_MOD=$( md5sum "$ROOT/go.mod" ) AFTER_SUM=$( md5sum "$ROOT/go.sum" ) red=$'\e[1;31m' end=$'\e[0m' if [ "$BEFORE_MOD" != "$AFTER_MOD" ]; then printf "${red}Running go mod tidy changed the contents of go.mod${end}\n" git diff "$ROOT/go.mod" changed=1 fi if [ "$BEFORE_SUM" != "$AFTER_SUM" ]; then printf "${red}Running go mod tidy changed the contents of go.sum${end}\n" git diff "$ROOT/go.sum" changed=1 fi if [ -n "$changed" ]; then if [ -n "$OUTPUT" ]; then printf "\nOutput from running go mod tidy -v:\n${OUTPUT}\n" else printf "\nThere was no output from running go mod tidy -v\n\n" fi exit 1 fi exit 0 precious-0.7.3/examples/golang/precious.toml000066400000000000000000000036151463371203000211710ustar00rootroot00000000000000excludes = ["vendor/**/*"] [commands.golangci-lint] type = "both" include = "**/*.go" # For large projects with many packages, you may want to set # `invoke.per-dir-or-once = 7`. You can experiment with different numbers of # directories to see what works best for your project. invoke = "once" path-args = "dir" # The `--allow-parallel-runners` flag is only relevant when `invoke` is not # set to `once`. # # Allowing golangci-lint to run in parallel reduces the effectiveness of its # cache when it has to parse the same code repeatedly. Depending on the # structure of your repo, you may get a better result by using the # `--allow-serial-runners` flag instead. However, if `invoke` is not `once`, # you must use one of these, as by default golangci-lint can simply timeout # and fail when multiple instances of the executable are invoked at the same # time for the same project. # # Alternatively, for smaller projects you can set `invoke = "once"` and # `path-args = "none"` to run it once for all code in the project, in which # case you can remove this flag. cmd = ["golangci-lint", "run", "-c", "--allow-parallel-runners"] tidy-flags = "--fix" env = { "FAIL_ON_WARNINGS" = "1" } ok-exit-codes = [0] lint-failure-exit-codes = [1] [commands."tidy go files"] type = "tidy" include = "**/*.go" cmd = ["gofumpt", "-w"] ok-exit-codes = [0] # This script will be created for you if you run `precious config init` to # generate your config file. [commands.check-go-mod] type = "lint" include = "**/*.go" invoke = "once" path-args = "none" cmd = ["$PRECIOUS_ROOT/dev/bin/check-go-mod.sh"] ok-exit-codes = [0] lint-failure-exit-codes = [1] [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = ["omegasort", "--sort", "path", "--unique"] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["The .+ file is not sorted", "The .+ file is not unique"] precious-0.7.3/examples/perl/000077500000000000000000000000001463371203000161315ustar00rootroot00000000000000precious-0.7.3/examples/perl/precious.toml000066400000000000000000000052011463371203000206550ustar00rootroot00000000000000# See examples/bin/install-dev-tools.sh for an example script to install all # of the tools needed by this config. You can copy it into your project and # modify it as needed to only install the tools you need. # This list is appropriate for a Perl distribution that you upload to # CPAN. For Perl applications you develop locally, you can probably remove # most or all of these. exclude = [ # Used by Dist::Zilla ".build/**", # Replace with your distro name "DateTime-*", "blib/**", # All of these are generated by Dist::Zilla "t/00-*", "t/author-*", "t/release-*", "xt/author/**", "xt/release/**", ] # Add App::perlimports as a develop phase prereq [commands.perlimports] type = "both" include = ["**/*.{pl,pm,t,psgi}"] cmd = ["perlimports"] lint-flags = ["--lint"] tidy-flags = ["-i"] ok-exit-codes = 0 expect-stderr = true # Add Perl::Critic as a develop phase prereq [commands.perlcritic] type = "lint" include = ["**/*.{pl,pm,t,psgi}"] cmd = ["perlcritic", "--profile=$PRECIOUS_ROOT/perlcriticrc"] ok-exit-codes = 0 lint-failure-exit-codes = 2 # Add Perl::Tidy as a develop phase prereq [commands.perltidy] type = "both" include = ["**/*.{pl,pm,t,psgi}"] cmd = ["perltidy", "--profile=$PRECIOUS-ROOT/perltidyrc"] lint-flags = ["--assert-tidy", "--no-standard-output", "--outfile=/dev/null"] tidy-flags = ["--backup-and-modify-in-place", "--backup-file-extension=/"] ok-exit-codes = 0 lint-failure-exit-codes = 2 ignore-stderr = "Begin Error Output Stream" # Add Pod::Checker as a develop phase prereq [commands.podchecker] type = "lint" include = ["**/*.{pl,pm,pod}"] cmd = ["podchecker", "--warnings", "--warnings"] ok-exit-codes = [0, 2] lint-failure-exit-codes = 1 ignore-stderr = [".+ pod syntax OK", ".+ does not contain any pod commands"] # Add Pod::Tidy as a develop phase prereq [commands.podtidy] type = "tidy" include = ["**/*.{pl,pm,pod}"] cmd = ["podtidy", "--columns", "80", "--inplace", "--nobackup"] ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = ["omegasort", "--sort", "path", "--unique"] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["The .+ file is not sorted", "The .+ file is not unique"] # If you have an external stopwords file for use with Test::Spelling [commands.omegasort-stopwords] type = "both" include = ".stopwords" cmd = ["omegasort", "--sort", "text", "--case-insensitive", "--unique"] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["The .+ file is not sorted", "The .+ file is not unique"] precious-0.7.3/examples/rust/000077500000000000000000000000001463371203000161645ustar00rootroot00000000000000precious-0.7.3/examples/rust/precious.toml000066400000000000000000000027671463371203000207260ustar00rootroot00000000000000# See examples/bin/install-dev-tools.sh for an example script to install all # of the tools needed by this config. You can copy it into your project and # modify it as needed to only install the tools you need. exclude = ["target"] # If you install a nightly rustfmt from the project's GitHub releases # (https://github.com/rust-lang/rustfmt/releases) you can change the cmd to: # # cmd = [ "rustfmt", "--skip-children", "--unstable-features" ] # # This stops "rustfmt --check" from showing errors in main.rs or lib.rs # because of errors in files containing modules imported by main.rs/lib.rs. # # Try ubi (https://github.com/houseabsolute/ubi) for installing rustfmt and # other single-file executables. [commands.rustfmt] type = "both" include = "**/*.rs" cmd = ["rustfmt", "--edition", "2021"] lint-flags = "--check" ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.clippy] type = "lint" include = "**/*.rs" invoke = "once" path-args = "none" cmd = [ "cargo", "clippy", "--locked", "--all-targets", "--all-features", "--workspace", "--", "-D", "clippy::all", ] ok-exit-codes = 0 lint-failure-exit-codes = 101 ignore-stderr = ["Checking.+precious", "Finished.+dev", "could not compile"] [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = ["omegasort", "--sort", "path", "--unique"] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["The .+ file is not sorted", "The .+ file is not unique"] precious-0.7.3/git/000077500000000000000000000000001463371203000141345ustar00rootroot00000000000000precious-0.7.3/git/hooks/000077500000000000000000000000001463371203000152575ustar00rootroot00000000000000precious-0.7.3/git/hooks/pre-commit.sh000077500000000000000000000002701463371203000176710ustar00rootroot00000000000000#!/bin/bash status=0 PRECIOUS=$(which precious) if [[ -z $PRECIOUS ]]; then PRECIOUS=./bin/precious fi "$PRECIOUS" lint -s if (( $? != 0 )); then status+=1 fi exit $status precious-0.7.3/git/setup.pl000077500000000000000000000007021463371203000156330ustar00rootroot00000000000000#!/usr/bin/env perl use strict; use warnings; use Cwd qw( abs_path ); symlink_hook('pre-commit'); sub symlink_hook { my $hook = shift; my $dot = ".git/hooks/$hook"; my $file = "git/hooks/$hook.sh"; my $link = "../../$file"; if ( -e $dot ) { if ( -l $dot ) { return if readlink $dot eq $link; } warn "You already have a hook at $dot!\n"; return; } symlink $link, $dot; } precious-0.7.3/precious-core/000077500000000000000000000000001463371203000161305ustar00rootroot00000000000000precious-0.7.3/precious-core/Cargo.toml000066400000000000000000000016571463371203000200710ustar00rootroot00000000000000[package] name = "precious-core" authors.workspace = true description = "The core of precious as a library - not for external use" edition.workspace = true license.workspace = true readme.workspace = true repository.workspace = true version.workspace = true [dependencies] anyhow.workspace = true clap.workspace = true clean-path.workspace = true comfy-table.workspace = true fern.workspace = true ignore.workspace = true indexmap.workspace = true itertools.workspace = true log.workspace = true md5.workspace = true once_cell.workspace = true pathdiff.workspace = true precious-helpers.workspace = true rayon.workspace = true regex.workspace = true serde.workspace = true thiserror.workspace = true toml.workspace = true which.workspace = true [dev-dependencies] filetime.workspace = true precious-testhelper.workspace = true pretty_assertions.workspace = true pushd.workspace = true serial_test.workspace = true test-case.workspace = true precious-0.7.3/precious-core/src/000077500000000000000000000000001463371203000167175ustar00rootroot00000000000000precious-0.7.3/precious-core/src/chars.rs000066400000000000000000000020201463371203000203570ustar00rootroot00000000000000#[derive(Debug, Eq, PartialEq)] pub struct Chars { pub ring: &'static str, pub tidied: &'static str, pub unchanged: &'static str, pub unknown: &'static str, pub lint_free: &'static str, pub lint_dirty: &'static str, pub empty: &'static str, pub bullet: &'static str, pub execution_error: &'static str, } pub const FUN_CHARS: Chars = Chars { ring: "💍", tidied: "💧", unchanged: "✨", // Person shrugging with medium skin tone - it'd be cool to randomize the // skin tone and gender on each run but then this wouldn't be static and // the chars wouldn't be constants and I'd have to turn this all into // functions. unknown: "🤷🏽", lint_free: "💯", lint_dirty: "💩", empty: "⚫", bullet: "▶", execution_error: "💥", }; pub const BORING_CHARS: Chars = Chars { ring: ":", tidied: "*", unchanged: "|", unknown: "?", lint_free: "|", lint_dirty: "*", empty: "_", bullet: "*", execution_error: "!", }; precious-0.7.3/precious-core/src/command.rs000066400000000000000000001741571463371203000207220ustar00rootroot00000000000000use crate::paths::matcher::{Matcher, MatcherBuilder}; use anyhow::Result; use itertools::Itertools; use log::{debug, info}; use precious_helpers::exec; use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, fmt, fs, io::ErrorKind, path::{Path, PathBuf}, time::SystemTime, }; use thiserror::Error; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] pub enum LintOrTidyCommandType { #[serde(rename = "lint")] Lint, #[serde(rename = "tidy")] Tidy, #[serde(rename = "both")] Both, } impl LintOrTidyCommandType { fn what(self) -> &'static str { match self { LintOrTidyCommandType::Lint => "linter", LintOrTidyCommandType::Tidy => "tidier", LintOrTidyCommandType::Both => "linter/tidier", } } } impl fmt::Display for LintOrTidyCommandType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { LintOrTidyCommandType::Lint => "lint", LintOrTidyCommandType::Tidy => "tidy", LintOrTidyCommandType::Both => "both", }) } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum Invoke { #[serde(rename = "per-file")] PerFile, #[serde(rename = "per-file-or-dir")] PerFileOrDir(usize), #[serde(rename = "per-file-or-once")] PerFileOrOnce(usize), #[serde(rename = "per-dir")] PerDir, #[serde(rename = "per-dir-or-once")] PerDirOrOnce(usize), #[serde(rename = "once")] Once, } impl fmt::Display for Invoke { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Invoke::PerFile => write!(f, r#"invoke = "per-file""#), Invoke::PerFileOrDir(n) => write!(f, "invoke.per-file-or-dir = {n}"), Invoke::PerFileOrOnce(n) => write!(f, "invoke.per-file-or-once = {n}"), Invoke::PerDir => write!(f, r#"invoke = "per-dir""#), Invoke::PerDirOrOnce(n) => write!(f, "invoke.per-dir-or-once = {n}"), Invoke::Once => write!(f, r#"invoke = "once""#), } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum ActualInvoke { PerFile, PerDir, Once, } impl ActualInvoke { #[cfg(test)] fn as_invoke(&self) -> Invoke { match *self { ActualInvoke::PerFile => Invoke::PerFile, ActualInvoke::PerDir => Invoke::PerDir, ActualInvoke::Once => Invoke::Once, } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub enum WorkingDir { Root, Dir, ChdirTo(PathBuf), } impl fmt::Display for WorkingDir { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { WorkingDir::Root => f.write_str(r#""root""#), WorkingDir::Dir => f.write_str(r#""dir""#), WorkingDir::ChdirTo(cd) => { f.write_str(r#"chdir-to = ""#)?; f.write_str(&format!("{}", cd.display()))?; f.write_str(r#"""#) } } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum PathArgs { #[serde(rename = "file")] File, #[serde(rename = "dir")] Dir, #[serde(rename = "none")] None, #[serde(rename = "dot")] Dot, #[serde(rename = "absolute-file")] AbsoluteFile, #[serde(rename = "absolute-dir")] AbsoluteDir, } impl fmt::Display for PathArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { PathArgs::File => r#""file""#, PathArgs::Dir => r#""dir""#, PathArgs::None => r#""none""#, PathArgs::Dot => r#""dot""#, PathArgs::AbsoluteFile => r#""absolute-file""#, PathArgs::AbsoluteDir => r#""absolute-dir""#, }) } } #[derive(Debug, Error, PartialEq, Eq)] enum CommandError { #[error( "You cannot define a command which lints and tidies without lint-flags and/or tidy-flags" )] CommandWhichIsBothRequiresLintOrTidyFlags, #[error("Cannot {method:} with the {command:} command, which is a {typ:}")] CannotMethodWithCommand { method: &'static str, command: String, typ: &'static str, }, #[error("Path {path:} has no parent")] PathHasNoParent { path: String }, #[error("Path {path:} should exist but it does not")] PathDoesNotExist { path: String }, } #[derive(Debug)] #[allow(clippy::module_name_repetitions)] pub struct LintOrTidyCommand { project_root: PathBuf, pub name: String, typ: LintOrTidyCommandType, includer: Matcher, include: Vec, excluder: Matcher, invoke: Invoke, working_dir: WorkingDir, path_args: PathArgs, cmd: Vec, env: HashMap, lint_flags: Option>, tidy_flags: Option>, path_flag: Option, ok_exit_codes: Vec, lint_failure_exit_codes: HashSet, ignore_stderr: Option>, } #[derive(Debug)] pub struct LintOrTidyCommandParams { pub project_root: PathBuf, pub name: String, pub typ: LintOrTidyCommandType, pub include: Vec, pub exclude: Vec, pub invoke: Invoke, pub working_dir: WorkingDir, pub path_args: PathArgs, pub cmd: Vec, pub env: HashMap, pub lint_flags: Vec, pub tidy_flags: Vec, pub path_flag: String, pub ok_exit_codes: Vec, pub lint_failure_exit_codes: Vec, pub expect_stderr: bool, pub ignore_stderr: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub enum TidyOutcome { Unchanged, Changed, Unknown, } #[derive(Debug)] pub struct LintOutcome { pub ok: bool, pub stdout: Option, pub stderr: Option, } #[derive(Clone, Debug)] struct PathMetadata { dir: Option, path_map: HashMap, } #[derive(Clone, Debug, PartialEq, Eq)] struct PathInfo { mtime: SystemTime, size: u64, hash: md5::Digest, } // This should be safe because we never mutate the Command struct in any of its // methods. unsafe impl Sync for LintOrTidyCommand {} impl LintOrTidyCommand { pub fn new(params: LintOrTidyCommandParams) -> Result { if let LintOrTidyCommandType::Both = params.typ { if params.lint_flags.is_empty() && params.tidy_flags.is_empty() { return Err(CommandError::CommandWhichIsBothRequiresLintOrTidyFlags.into()); } } let ignore_stderr = if params.expect_stderr { // If this regex isn't Some(vec![Regex::new(".*").unwrap_or_else(|e| { unreachable!("The '.*' regex should always compile: {}", e) })]) } else if params.ignore_stderr.is_empty() { None } else { Some( params .ignore_stderr .into_iter() .map(|i| Regex::new(&i).map_err(Into::into)) .collect::>>()?, ) }; let cmd = replace_root(¶ms.cmd, ¶ms.project_root); let root = params.project_root.clone(); Ok(LintOrTidyCommand { project_root: params.project_root, name: params.name, typ: params.typ, includer: MatcherBuilder::new(&root).with(¶ms.include)?.build()?, include: params.include, excluder: MatcherBuilder::new(&root).with(¶ms.exclude)?.build()?, invoke: params.invoke, working_dir: params.working_dir, path_args: params.path_args, cmd, env: params.env, lint_flags: if params.lint_flags.is_empty() { None } else { Some(params.lint_flags) }, tidy_flags: if params.tidy_flags.is_empty() { None } else { Some(params.tidy_flags) }, path_flag: if params.path_flag.is_empty() { None } else { Some(params.path_flag) }, ok_exit_codes: Self::unique_exit_codes( ¶ms.ok_exit_codes, Some(¶ms.lint_failure_exit_codes), ), lint_failure_exit_codes: params .lint_failure_exit_codes .into_iter() .map(i32::from) .collect(), ignore_stderr, }) } fn unique_exit_codes(ok_exit_codes: &[u8], lint_failure_exit_codes: Option<&[u8]>) -> Vec { let unique_codes: HashSet = ok_exit_codes .iter() .merge(lint_failure_exit_codes.unwrap_or(&[]).iter()) .map(|c| i32::from(*c)) .collect(); unique_codes.into_iter().collect() } // This returns a vec of vecs where each of the sub-vecs contains 1+ // files. Each of those sub-vecs represents one invocation of the // program. The exact paths that are passed to that invocation are later // determined based on the command's `path-args` field. pub fn files_to_args_sets<'a>( &self, files: &'a [PathBuf], ) -> Result<(Vec>, ActualInvoke)> { let files = files.iter().filter(|f| self.file_matches_rules(f)); Ok(match self.invoke { // Every file becomes its own one one-element Vec. Invoke::PerFile => ( files.sorted().map(|f| vec![f.as_path()]).collect(), ActualInvoke::PerFile, ), Invoke::PerFileOrDir(n) => { let count = files.clone().count(); if count < n { debug!( "Invoking {} once per file for {count} files, which is less than {n}.", self.name, ); ( files.sorted().map(|f| vec![f.as_path()]).collect(), ActualInvoke::PerFile, ) } else { debug!( "Invoking {} once per directory for {count} files, which is at least {n}.", self.name, ); (Self::files_to_dirs(files)?, ActualInvoke::PerDir) } } Invoke::PerFileOrOnce(n) => { let count = files.clone().count(); if count < n { debug!( "Invoking {} once per file for {count} files, which is less than {n}.", self.name, ); ( files.sorted().map(|f| vec![f.as_path()]).collect(), ActualInvoke::PerFile, ) } else { debug!( "Invoking {} once for all {count} files, which is at least {n}.", self.name, ); ( vec![files.sorted().map(PathBuf::as_path).collect()], ActualInvoke::Once, ) } } // Every directory becomes a Vec of its files. Invoke::PerDir => (Self::files_to_dirs(files)?, ActualInvoke::PerDir), Invoke::PerDirOrOnce(n) => { let dirs = Self::files_to_dirs(files.clone())?; let count = dirs.len(); if count < n { debug!("Invoking {} once per directory because there are fewer than {n} directories.", self.name); (dirs, ActualInvoke::PerDir) } else { debug!( "Invoking {} once for all {count} directories, which is at least {n}.", self.name, ); ( vec![files.sorted().map(PathBuf::as_path).collect()], ActualInvoke::Once, ) } } // All the files in one Vec. Invoke::Once => ( vec![files.sorted().map(PathBuf::as_path).collect()], ActualInvoke::Once, ), }) } fn files_to_dirs<'a>(files: impl Iterator) -> Result>> { let files = files.map(AsRef::as_ref).collect::>(); let by_dir = Self::files_by_dir(&files)?; Ok(by_dir .into_iter() .sorted_by_key(|(k, _)| *k) .map(|(_, v)| v.into_iter().sorted().collect()) .collect()) } fn files_by_dir<'a>(files: &[&'a Path]) -> Result>> { let mut by_dir: HashMap<&Path, Vec<&Path>> = HashMap::new(); for f in files { let d = f.parent().ok_or_else(|| CommandError::PathHasNoParent { path: f.to_string_lossy().to_string(), })?; by_dir.entry(d).or_default().push(f); } Ok(by_dir) } pub fn tidy( &self, actual_invoke: ActualInvoke, files: &[&Path], ) -> Result> { self.require_is_not_command_type("tidy", LintOrTidyCommandType::Lint)?; if !self.should_act_on_files(actual_invoke, files)? { return Ok(None); } let path_metadata = self.maybe_path_metadata_for(actual_invoke, files)?; let in_dir = self.in_dir(files[0])?; let operating_on = self.operating_on(files, &in_dir)?; let mut cmd = self.command_for_paths(&self.tidy_flags, &operating_on); info!( "Tidying [{}] with {} in [{}] using command [{}]", files.iter().map(|p| p.to_string_lossy()).join(" "), self.name, in_dir.display(), cmd.join(" "), ); let bin = cmd.remove(0); exec::run( &bin, &cmd.iter().map(String::as_str).collect::>(), &self.env, &self.ok_exit_codes, self.ignore_stderr.as_deref(), Some(&in_dir), )?; if let Some(pm) = path_metadata { if self.paths_were_changed(pm)? { return Ok(Some(TidyOutcome::Changed)); } return Ok(Some(TidyOutcome::Unchanged)); } Ok(Some(TidyOutcome::Unknown)) } pub fn lint( &self, actual_invoke: ActualInvoke, files: &[&Path], ) -> Result> { self.require_is_not_command_type("lint", LintOrTidyCommandType::Tidy)?; if !self.should_act_on_files(actual_invoke, files)? { return Ok(None); } let in_dir = self.in_dir(files[0])?; let operating_on = self.operating_on(files, &in_dir)?; let mut cmd = self.command_for_paths(&self.lint_flags, &operating_on); info!( "Linting [{}] with {} in [{}] using command [{}]", files.iter().map(|p| p.to_string_lossy()).join(" "), self.name, in_dir.display(), cmd.join(" "), ); let bin = cmd.remove(0); let result = exec::run( &bin, &cmd.iter().map(String::as_str).collect::>(), &self.env, &self.ok_exit_codes, self.ignore_stderr.as_deref(), Some(&in_dir), )?; Ok(Some(LintOutcome { ok: !self.lint_failure_exit_codes.contains(&result.exit_code), stdout: result.stdout, stderr: result.stderr, })) } fn require_is_not_command_type( &self, method: &'static str, not_allowed: LintOrTidyCommandType, ) -> Result<()> { if not_allowed == self.typ { return Err(CommandError::CannotMethodWithCommand { method, command: self.name.clone(), typ: self.typ.what(), } .into()); } Ok(()) } fn should_act_on_files(&self, actual_invoke: ActualInvoke, files: &[&Path]) -> Result { match actual_invoke { ActualInvoke::PerFile => { let f = &files[0]; // This check isn't strictly necessary since we default to not // matching, but the debug output is helpful. if self.excluder.path_matches(f, false) { debug!( "File {} is excluded for the {} command", f.display(), self.name, ); return Ok(false); } if self.includer.path_matches(f, false) { debug!( "File {} is included for the {} command", f.display(), self.name, ); return Ok(true); } } ActualInvoke::PerDir => { let dir = files[0] .parent() .ok_or_else(|| CommandError::PathHasNoParent { path: files[0].to_string_lossy().to_string(), })?; for f in files { if self.excluder.path_matches(f, false) { debug!( "File {} is excluded for the {} command", f.display(), self.name, ); continue; } if self.includer.path_matches(f, false) { debug!( "Directory {} is included for the {} command because it contains {} which is included", dir.display(), self.name, f.display(), ); return Ok(true); } } debug!( "Directory {} is not included in the {} command because none of its files are included", dir.display(), self.name ); } ActualInvoke::Once => { for f in files { if self.excluder.path_matches(f, false) { debug!( "File {} is excluded for the {} command", f.display(), self.name, ); continue; } if self.includer.path_matches(f, false) { debug!( "File {} is included for the {} command", f.display(), self.name, ); return Ok(true); } } debug!( "The {} command will not run because none of the files in the list are included", self.name, ); } } // The default is to not match. Ok(false) } // This takes the list of files relevant to the command. That list comes // the filenames which were produced by the call to // `files_to_args_sets`. This turns those files into the actual paths to // be passed to the command, which is passed on the command's `PathArgs` // type. Those files are all relative to the _project root_. We may return // them as is (but sorted), or we may turn them paths relative to the // given directory. The given directory is the directory in which the // command will be run, and may not be the project root. fn operating_on(&self, files: &[&Path], in_dir: &Path) -> Result> { match self.path_args { PathArgs::File => Ok(files .iter() .sorted() .map(|r| self.path_relative_to(r, in_dir)) .collect::>()), PathArgs::Dir => Ok(Self::files_by_dir(files)? .into_keys() .sorted() .map(|r| self.path_relative_to(r, in_dir)) .collect::>()), PathArgs::None => Ok(vec![]), PathArgs::Dot => Ok(vec![PathBuf::from(".")]), PathArgs::AbsoluteFile => Ok(files .iter() .sorted() .map(|f| { let mut abs = self.project_root.clone(); abs.push(f); abs }) .collect()), PathArgs::AbsoluteDir => Ok(Self::files_by_dir(files)? .into_keys() .map(|d| { let mut abs = self.project_root.clone(); if d.components().count() != 0 { abs.push(d); } abs }) .sorted() .collect()), } } fn path_relative_to(&self, path: &Path, in_dir: &Path) -> PathBuf { let mut abs = self.project_root.clone(); abs.push(path); if let Some(mut diff) = pathdiff::diff_paths(&abs, in_dir) { if diff == Path::new("") { diff = PathBuf::from("."); } return diff; } path.to_path_buf() } // This takes the list of files relevant to the command. That list comes // the filenames which were produced by the call to // `files_to_args_sets`. Based on the command's `Invoke` type, it // determines what paths it should collect metadata for (which may be // none). This metadata is collected for tidy commands so we can determine // whether the command changed anything. fn maybe_path_metadata_for( &self, actual_invoke: ActualInvoke, files: &[&Path], ) -> Result> { match actual_invoke { // If it's invoked per file we know that we only have one file in // `files`. ActualInvoke::PerFile => Ok(Some(self.path_metadata_for(files[0])?)), // If it's invoked per dir we can look at the first file's // parent. All the files should have the same dir. ActualInvoke::PerDir => { let dir = files[0] .parent() .ok_or_else(|| CommandError::PathHasNoParent { path: files[0].to_string_lossy().to_string(), })?; Ok(Some(self.path_metadata_for(dir)?)) } // If it's invoked once we would have to look at the entire // tree. That might be too expensive so we won't report a tidy // outcome in this case. ActualInvoke::Once => Ok(None), } } // Given a directory, this gets the metadata for all files in the // directory that match the command's include/exclude rules. fn path_metadata_for(&self, path: &Path) -> Result { let mut path_map = HashMap::new(); let mut dir = None; let mut full_path = self.project_root.clone(); full_path.push(path); if full_path.is_file() { let meta = Self::metadata_for_file(&full_path)?; path_map.insert(full_path, meta); } else if full_path.is_dir() { dir = Some(path.to_path_buf()); for entry in fs::read_dir(full_path)? { let entry = entry?; let path = entry.path(); if path.is_file() && self.file_matches_rules(&path) { let meta = entry.metadata()?; let hash = md5::compute(fs::read(&path)?); path_map.insert( path, PathInfo { mtime: meta.modified()?, size: meta.len(), hash, }, ); } } } else if !path.exists() { return Err(CommandError::PathDoesNotExist { path: path.to_string_lossy().to_string(), } .into()); } else { unreachable!( "I sure hope is_file(), is_dir(), and !exists() are the only three states" ); } Ok(PathMetadata { dir, path_map }) } fn file_matches_rules(&self, file: &Path) -> bool { if self.excluder.path_matches(file, false) { return false; } if self.includer.path_matches(file, false) { return true; } false } fn metadata_for_file(file: &Path) -> Result { let meta = fs::metadata(file)?; Ok(PathInfo { mtime: meta.modified()?, size: meta.len(), hash: md5::compute(fs::read(file)?), }) } fn command_for_paths(&self, flags: &Option>, paths: &[PathBuf]) -> Vec { let mut cmd = self.cmd.clone(); if let Some(flags) = flags { for f in flags { cmd.push(f.clone()); } } for p in paths { if let Some(pf) = &self.path_flag { cmd.push(pf.clone()); } cmd.push(p.to_string_lossy().to_string()); } cmd } pub(crate) fn paths_summary(&self, actual_invoke: ActualInvoke, paths: &[&Path]) -> String { let all = paths .iter() .sorted() .map(|p| p.to_string_lossy().to_string()) .join(" "); if paths.len() <= 3 { return all; } match actual_invoke { ActualInvoke::Once | ActualInvoke::PerDir => { let initial = paths .iter() .sorted() .take(2) .map(|p| p.to_string_lossy()) .join(" "); format!( "{} files matching {}, starting with {}", paths.len(), self.include.join(" "), initial ) } ActualInvoke::PerFile => format!("{} files: {}", paths.len(), all), } } fn paths_were_changed(&self, prev: PathMetadata) -> Result { for (prev_file, prev_meta) in &prev.path_map { debug!("Checking {} for changes", prev_file.display()); let current_meta = match fs::metadata(prev_file) { Ok(m) => m, // If the file no longer exists the command must've deleted // it. Err(e) if e.kind() == ErrorKind::NotFound => return Ok(true), Err(e) => return Err(e.into()), }; // If the mtime is unchanged we don't need to compare anything // else. Unfortunately there's no guarantee a command won't modify // the mtime even if it doesn't change the file's contents, so we // cannot assume anything was changed just because the mtime // changed. For example, Perl::Tidy does this :( if prev_meta.mtime == current_meta.modified()? { continue; } // If the size changed we know the contents changed. if prev_meta.size != current_meta.len() { return Ok(true); } // Otherwise we need to compare the content hash. if prev_meta.hash != md5::compute(fs::read(prev_file)?) { return Ok(true); } } if let Some(dir) = prev.dir { let entries = match fs::read_dir(dir) { Ok(rd) => rd, Err(e) if e.kind() == ErrorKind::NotFound => return Ok(true), Err(e) => return Err(e.into()), }; for entry in entries { let entry = entry?; let path = entry.path(); if path.is_file() && self.file_matches_rules(&path) && !prev.path_map.contains_key(&path) { return Ok(true); } } } Ok(false) } pub fn config_key(&self) -> String { format!("commands.{}", Self::maybe_toml_quote(&self.name),) } fn maybe_toml_quote(name: &str) -> String { if name.contains(' ') { return format!(r#""{name}""#); } name.to_string() } fn in_dir(&self, file: &Path) -> Result { match &self.working_dir { WorkingDir::Root => Ok(self.project_root.clone()), WorkingDir::Dir => { let mut abs = self.project_root.clone(); abs.push(file); let parent = abs.parent().ok_or_else(|| CommandError::PathHasNoParent { path: file.to_string_lossy().to_string(), })?; Ok(parent.to_path_buf()) } WorkingDir::ChdirTo(cd) => { let mut dir = self.project_root.clone(); dir.push(cd); Ok(dir) } } } pub fn config_debug(&self) -> String { format!( "{} | working-dir = {} | path-args = {}", self.invoke, self.working_dir, self.path_args ) } } fn replace_root(cmd: &[String], root: &Path) -> Vec { cmd.iter() .map(|c| { c.replace( "$PRECIOUS_ROOT", root.to_string_lossy().into_owned().as_str(), ) }) .collect() } #[cfg(test)] mod tests { use super::*; use anyhow::Result; use precious_testhelper as testhelper; use pretty_assertions::assert_eq; use serial_test::parallel; use std::env; use test_case::test_case; use testhelper::TestHelper; fn matcher(globs: &[&str]) -> Result { MatcherBuilder::new("/").with(globs)?.build() } fn default_command() -> Result { Ok(LintOrTidyCommand { // These params will be ignored project_root: PathBuf::new(), name: String::new(), typ: LintOrTidyCommandType::Lint, includer: matcher(&[])?, include: vec![], excluder: matcher(&[])?, invoke: Invoke::PerFile, working_dir: WorkingDir::Root, path_args: PathArgs::File, cmd: vec![], env: HashMap::new(), lint_flags: None, tidy_flags: None, path_flag: None, ok_exit_codes: vec![], lint_failure_exit_codes: HashSet::new(), ignore_stderr: None, }) } #[test] #[parallel] fn files_to_args_sets_per_file() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFile, includer: matcher(&["**/*.go"])?, ..default_command()? }; let files = &["foo.go", "test/foo.go", "bar.go", "subdir/baz.go"] .iter() .map(PathBuf::from) .collect::>(); let bar = PathBuf::from("bar.go"); let foo = PathBuf::from("foo.go"); let baz = PathBuf::from("subdir/baz.go"); let test_foo = PathBuf::from("test/foo.go"); assert_eq!( command.files_to_args_sets(files)?, ( vec![ vec![bar.as_path()], vec![foo.as_path()], vec![baz.as_path()], vec![test_foo.as_path()], ], ActualInvoke::PerFile, ), ); Ok(()) } #[test] #[parallel] fn files_to_args_sets_per_file_or_dir() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFileOrDir(3), includer: matcher(&["**/*.go"])?, ..default_command()? }; let files = &["foo.go", "test/foo.go", "bar.go", "subdir/baz.go"] .iter() .map(PathBuf::from) .collect::>(); let bar = PathBuf::from("bar.go"); let foo = PathBuf::from("foo.go"); let baz = PathBuf::from("subdir/baz.go"); let test_foo = PathBuf::from("test/foo.go"); assert_eq!( command.files_to_args_sets(&files[0..2])?, ( vec![vec![foo.as_path()], vec![test_foo.as_path()],], ActualInvoke::PerFile, ), "with two paths invoke is PerFile", ); assert_eq!( command.files_to_args_sets(files)?, ( vec![ vec![bar.as_path(), foo.as_path()], vec![baz.as_path()], vec![test_foo.as_path()], ], ActualInvoke::PerDir, ), "with four paths invoke is PerDir", ); Ok(()) } #[test] #[parallel] fn files_to_args_sets_per_file_or_once() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFileOrOnce(3), includer: matcher(&["**/*.go"])?, ..default_command()? }; let files = &["foo.go", "test/foo.go", "bar.go", "subdir/baz.go"] .iter() .map(PathBuf::from) .collect::>(); let bar = PathBuf::from("bar.go"); let foo = PathBuf::from("foo.go"); let baz = PathBuf::from("subdir/baz.go"); let test_foo = PathBuf::from("test/foo.go"); assert_eq!( command.files_to_args_sets(&files[0..2])?, ( vec![vec![foo.as_path()], vec![test_foo.as_path()],], ActualInvoke::PerFile, ), "with 2 paths invoke is PerFile", ); assert_eq!( command.files_to_args_sets(files)?, ( vec![vec![ bar.as_path(), foo.as_path(), baz.as_path(), test_foo.as_path() ]], ActualInvoke::Once, ), "with four paths invoke is Once", ); Ok(()) } #[test] #[parallel] fn files_to_args_sets_per_dir() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerDir, includer: matcher(&["**/*.go"])?, ..default_command()? }; let files = &["foo.go", "test/foo.go", "bar.go", "subdir/baz.go"] .iter() .map(PathBuf::from) .collect::>(); let bar = PathBuf::from("bar.go"); let foo = PathBuf::from("foo.go"); let baz = PathBuf::from("subdir/baz.go"); let test_foo = PathBuf::from("test/foo.go"); assert_eq!( command.files_to_args_sets(files)?, ( vec![ vec![bar.as_path(), foo.as_path()], vec![baz.as_path()], vec![test_foo.as_path()], ], ActualInvoke::PerDir, ), ); Ok(()) } #[test] #[parallel] fn files_to_args_sets_once() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::Once, includer: matcher(&["**/*.go"])?, ..default_command()? }; let files = &["foo.go", "test/foo.go", "bar.go", "subdir/baz.go"] .iter() .map(PathBuf::from) .collect::>(); let bar = PathBuf::from("bar.go"); let foo = PathBuf::from("foo.go"); let baz = PathBuf::from("subdir/baz.go"); let test_foo = PathBuf::from("test/foo.go"); assert_eq!( command.files_to_args_sets(files)?, ( vec![vec![ bar.as_path(), foo.as_path(), baz.as_path(), test_foo.as_path(), ]], ActualInvoke::Once, ), ); Ok(()) } #[test] #[parallel] fn require_is_not_command_type_with_lint_command() -> Result<()> { let command = LintOrTidyCommand { typ: LintOrTidyCommandType::Lint, ..default_command()? }; assert!(command .require_is_not_command_type("lint", LintOrTidyCommandType::Tidy) .is_ok()); assert_eq!( command .require_is_not_command_type("tidy", LintOrTidyCommandType::Lint) .unwrap_err() .downcast::() .unwrap(), CommandError::CannotMethodWithCommand { method: "tidy", command: command.name, typ: "linter", }, ); Ok(()) } #[test] #[parallel] fn require_is_not_command_type_with_tidy_command() -> Result<()> { let command = LintOrTidyCommand { typ: LintOrTidyCommandType::Tidy, ..default_command()? }; assert!(command .require_is_not_command_type("tidy", LintOrTidyCommandType::Lint) .is_ok()); assert_eq!( command .require_is_not_command_type("lint", LintOrTidyCommandType::Tidy) .unwrap_err() .downcast::() .unwrap(), CommandError::CannotMethodWithCommand { method: "lint", command: command.name, typ: "tidier", }, ); Ok(()) } #[test] #[parallel] fn require_is_not_command_type_with_both_command() -> Result<()> { let command = LintOrTidyCommand { typ: LintOrTidyCommandType::Both, ..default_command()? }; assert!(command .require_is_not_command_type("tidy", LintOrTidyCommandType::Lint) .is_ok()); assert!(command .require_is_not_command_type("lint", LintOrTidyCommandType::Tidy) .is_ok()); Ok(()) } #[test] #[parallel] fn should_act_on_files_invoke_per_file() -> Result<()> { let command = LintOrTidyCommand { project_root: PathBuf::from("/foo/bar"), name: String::from("Test"), typ: LintOrTidyCommandType::Lint, includer: matcher(&["**/*.go", "!this/file.go"])?, excluder: matcher(&["foo/**/*", "!foo/some/file.go", "baz/bar/**/quux/*"])?, ..default_command()? }; let include = [ "something.go", "dir/foo.go", ".foo.go", "bar/foo/x.go", "foo/some/file.go", ]; for i in include.iter().map(PathBuf::from) { let name = i.clone(); assert!( command.should_act_on_files(ActualInvoke::PerFile, &[&i])?, "{}", name.display(), ); } let exclude = [ "this/file.go", "something.pl", "dir/foo.pl", "foo/bar.go", "baz/bar/anything/here/quux/file.go", ]; for e in exclude.iter().map(PathBuf::from) { let name = e.clone(); assert!( !command.should_act_on_files(ActualInvoke::PerFile, &[&e])?, "{}", name.display(), ); } Ok(()) } #[test] #[parallel] fn should_act_on_files_invoke_per_dir() -> Result<()> { let command = LintOrTidyCommand { project_root: PathBuf::from("/foo/bar"), name: String::from("Test"), typ: LintOrTidyCommandType::Lint, includer: matcher(&["**/*.go", "!this/file.go"])?, excluder: matcher(&["foo/**/*", "!foo/some/file.go", "baz/bar/**/quux/*"])?, invoke: Invoke::PerDir, path_args: PathArgs::Dir, ..default_command()? }; let include = [ ["foo.go", "README.md"], ["dir/foo/foo.pl", "dir/foo/file.go"], ["dir/some.go", "dir/some.rs"], ["foo/some/file.go", "foo/excluded.go"], ]; for i in include.iter() { let files = i.iter().map(PathBuf::from).collect::>(); assert!( command.should_act_on_files( ActualInvoke::PerDir, &files.iter().map(|f| f.as_ref()).collect::>(), )?, "{}", i.join(", ") ); } let exclude = [ ["foo/bar.go", "foo/baz.go"], ["baz/bar/foo/quux/file.go", "baz/bar/foo/quux/other.go"], ["dir/foo.pl", "dir/file.txt"], ["this/file.go", "foo/excluded.go"], ]; for e in exclude.iter() { let files = e.iter().map(PathBuf::from).collect::>(); assert!( !command.should_act_on_files( ActualInvoke::PerDir, &files.iter().map(|f| f.as_ref()).collect::>(), )?, "{}", e.join(", ") ); } Ok(()) } #[test] #[parallel] fn should_act_on_files_invoke_once() -> Result<()> { let command = LintOrTidyCommand { project_root: PathBuf::from("/foo/bar"), name: String::from("Test"), typ: LintOrTidyCommandType::Lint, includer: matcher(&["**/*.go", "!this/file.go"])?, excluder: matcher(&["foo/**/*", "!foo/some/file.go", "baz/bar/**/quux/*"])?, invoke: Invoke::Once, ..default_command()? }; let include = [ [".", "foo.go", "README.md"], ["dir/foo", "dir/foo/foo.pl", "dir/foo/file.go"], [".", "foo/bar.go", "foo/some/file.go"], ]; for i in include.iter() { let dir = PathBuf::from(i[0]); let files = i[1..].iter().map(PathBuf::from).collect::>(); let name = dir.clone(); assert!( command.should_act_on_files( ActualInvoke::Once, &files.iter().map(|f| f.as_ref()).collect::>() )?, "{}", name.display() ); } let exclude = [ ["foo", "foo/bar.go", "foo/baz.go"], [ "baz/bar/foo/quux", "baz/bar/foo/quux/file.go", "baz/bar/foo/quux/other.go", ], ["dir", "dir/foo.pl", "dir/file.txt"], [".", "this/file.go", "foo/also/excluded.go"], ]; for e in exclude.iter() { let dir = PathBuf::from(e[0]); let files = e[1..].iter().map(PathBuf::from).collect::>(); let name = dir.clone(); assert!( !command.should_act_on_files( ActualInvoke::Once, &files.iter().map(|f| f.as_ref()).collect::>() )?, "{}", name.display() ); } Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_file_in_project_root() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::File, ..default_command()? }; let file1 = Path::new("file1"); assert_eq!( command.operating_on(&[file1], &command.project_root)?, vec![file1], ); let file2 = Path::new("subdir/file2"); assert_eq!( command.operating_on(&[file2], &command.project_root)?, vec![file2], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_file_in_subdir() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::File, ..default_command()? }; let mut in_dir = command.project_root.clone(); in_dir.push("subdir"); let file = Path::new("subdir/file"); assert_eq!( command.operating_on(&[file], &in_dir)?, vec![PathBuf::from("file")], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_dir_in_project_root() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::Dir, ..default_command()? }; let files = [Path::new("file1"), Path::new("subdir/file2")]; assert_eq!( command.operating_on(&files, &command.project_root,)?, vec![PathBuf::from("."), PathBuf::from("subdir")], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_dir_in_subdir() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::Dir, ..default_command()? }; let files = [Path::new("subdir/file1"), Path::new("subdir/more/file2")]; let mut in_dir = command.project_root.clone(); in_dir.push("subdir"); assert_eq!( command.operating_on(&files, &in_dir)?, vec![PathBuf::from("."), PathBuf::from("more")], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_absolute_file() -> Result<()> { let cwd = env::current_dir()?; let command = LintOrTidyCommand { project_root: cwd.clone(), path_args: PathArgs::AbsoluteFile, ..default_command()? }; let mut file1 = cwd.clone(); file1.push("file1"); assert_eq!( command.operating_on(&[Path::new("file1")], &command.project_root)?, vec![file1], ); let mut file1 = cwd; file1.push("subdir/file2"); assert_eq!( command.operating_on(&[Path::new("subdir/file2")], &command.project_root)?, vec![file1], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_absolute_file_in_dir() -> Result<()> { let cwd = env::current_dir()?; let command = LintOrTidyCommand { project_root: cwd.clone(), path_args: PathArgs::AbsoluteFile, ..default_command()? }; let mut in_dir = command.project_root.clone(); in_dir.push("subdir"); let mut file1 = cwd.clone(); file1.push("file1"); assert_eq!( command.operating_on(&[Path::new("file1")], &in_dir)?, vec![file1], ); let mut file1 = cwd; file1.push("subdir/file2"); assert_eq!( command.operating_on(&[Path::new("subdir/file2")], &in_dir)?, vec![file1], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_absolute_dir_in_project_root() -> Result<()> { let cwd = env::current_dir()?; let command = LintOrTidyCommand { project_root: cwd.clone(), path_args: PathArgs::AbsoluteDir, ..default_command()? }; assert_eq!( command.operating_on(&[Path::new("file1")], &command.project_root)?, vec![cwd.clone()], ); let mut subdir = cwd; subdir.push("subdir"); assert_eq!( command.operating_on(&[Path::new("subdir/file2")], &command.project_root)?, vec![subdir], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_absolute_dir_in_dir() -> Result<()> { let cwd = env::current_dir()?; let command = LintOrTidyCommand { project_root: cwd.clone(), path_args: PathArgs::AbsoluteDir, ..default_command()? }; let mut in_dir = command.project_root.clone(); in_dir.push("subdir"); assert_eq!( command.operating_on(&[Path::new("file1")], &in_dir)?, vec![cwd.clone()], ); let mut subdir = cwd; subdir.push("subdir"); assert_eq!( command.operating_on(&[Path::new("subdir/file2")], &in_dir)?, vec![subdir], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_dot_in_project_root() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::Dot, ..default_command()? }; let files = [Path::new("file1"), Path::new("subdir/file2")]; assert_eq!( command.operating_on(&files, &command.project_root)?, vec![PathBuf::from(".")], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_dot_in_dir() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::Dot, ..default_command()? }; let mut in_dir = command.project_root.clone(); in_dir.push("subdir"); let files = [Path::new("file1"), Path::new("subdir/file2")]; assert_eq!( command.operating_on(&files, &in_dir)?, vec![PathBuf::from(".")], ); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_none_in_project_root() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::None, ..default_command()? }; let files = [Path::new("file1"), Path::new("subdir/file2")]; let expect: Vec = vec![]; assert_eq!(command.operating_on(&files, &command.project_root)?, expect); Ok(()) } #[test] #[parallel] fn operating_on_with_path_args_none_in_dir() -> Result<()> { let command = LintOrTidyCommand { path_args: PathArgs::None, ..default_command()? }; let mut in_dir = command.project_root.clone(); in_dir.push("subdir"); let files = [Path::new("file1"), Path::new("subdir/file2")]; let expect: Vec = vec![]; assert_eq!(command.operating_on(&files, &in_dir)?, expect); Ok(()) } #[test] #[parallel] fn maybe_path_metadata_for_per_file() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFile, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut file = helper.git_root(); file.push("src/bar.rs"); let metadata = command .maybe_path_metadata_for(ActualInvoke::PerFile, &[&file])? .unwrap_or_else(|| unreachable!("Should always have metadata with Invoke::PerFile")); assert!(metadata.path_map.contains_key(&file)); Ok(()) } #[test] #[parallel] fn maybe_path_metadata_for_per_dir() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFile, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, excluder: MatcherBuilder::new("/") .with(&["**/can_ignore.rs"])? .build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut dir = helper.git_root(); dir.push("src"); let metadata = command .maybe_path_metadata_for(ActualInvoke::PerFile, &[&dir])? .unwrap_or_else(|| unreachable!("Should always have metadata with Invoke::PerFile")); let expect_files = ["bar.rs", "main.rs", "module.rs"]; for name in expect_files { let mut file = dir.clone(); file.push(name); assert!( metadata.path_map.contains_key(&file), "contains {}", file.display(), ); } assert_eq!(metadata.path_map.len(), expect_files.len()); assert_eq!(metadata.dir, Some(dir)); Ok(()) } #[test] #[parallel] fn maybe_path_metadata_for_once() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::Once, ..default_command()? }; let cwd = env::current_dir()?; assert!(command .maybe_path_metadata_for(ActualInvoke::Once, &[&cwd])? .is_none()); Ok(()) } #[test] #[parallel] fn command_for_paths() -> Result<()> { let command = LintOrTidyCommand { cmd: vec![String::from("test")], ..default_command()? }; let paths = vec![PathBuf::from("app.go"), PathBuf::from("main.go")]; assert_eq!( command.command_for_paths(&None, &paths), ["test", "app.go", "main.go"] .iter() .map(|s| s.to_string()) .collect::>(), "no flags", ); let flags = vec![String::from("--flag")]; assert_eq!( command.command_for_paths(&Some(flags.clone()), &paths), ["test", "--flag", "app.go", "main.go"] .iter() .map(|s| s.to_string()) .collect::>(), "one flag", ); let command = LintOrTidyCommand { cmd: vec![String::from("test")], path_flag: Some(String::from("--path-flag")), ..default_command()? }; assert_eq!( command.command_for_paths(&Some(flags), &paths), [ "test", "--flag", "--path-flag", "app.go", "--path-flag", "main.go" ] .iter() .map(|s| s.to_string()) .collect::>(), "with path flags", ); Ok(()) } #[test] #[parallel] fn paths_were_not_changed_when_only_mtime_changes() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFile, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, excluder: MatcherBuilder::new("/") .with(&["**/can_ignore.rs"])? .build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut file = helper.git_root(); file.push("src/main.rs"); let files = vec![file.as_ref()]; let prev = command.maybe_path_metadata_for(ActualInvoke::PerFile, &files)?; assert!(prev.is_some()); assert!(!command.paths_were_changed(prev.clone().unwrap())?); filetime::set_file_mtime(&file, filetime::FileTime::from_unix_time(0, 0))?; assert!(!command.paths_were_changed(prev.unwrap())?); Ok(()) } #[test] #[parallel] fn paths_were_changed_when_size_changes() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFile, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, excluder: MatcherBuilder::new("/") .with(&["**/can_ignore.rs"])? .build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut file = helper.git_root(); file.push("src/main.rs"); let files = vec![file.as_ref()]; let prev = command.maybe_path_metadata_for(ActualInvoke::PerFile, &files)?; assert!(prev.is_some()); assert!(!command.paths_were_changed(prev.clone().unwrap())?); helper.write_file(&file, "new content that is longer than the old content")?; assert!(command.paths_were_changed(prev.unwrap())?); Ok(()) } #[test] #[parallel] fn paths_were_changed_when_content_changes() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerFile, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, excluder: MatcherBuilder::new("/") .with(&["**/can_ignore.rs"])? .build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut file = helper.git_root(); file.push("src/main.rs"); let files = vec![file.as_ref()]; let prev = command.maybe_path_metadata_for(ActualInvoke::PerFile, &files)?; assert!(prev.is_some()); assert!(!command.paths_were_changed(prev.clone().unwrap())?); // This needs to be the same size as the old content. let new_content = fs::read_to_string(&file)?.chars().rev().collect::(); helper.write_file(&file, &new_content)?; assert!(command.paths_were_changed(prev.unwrap())?); Ok(()) } #[test] #[parallel] fn paths_were_changed_when_dir_has_new_file() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerDir, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, excluder: MatcherBuilder::new("/") .with(&["**/can_ignore.rs"])? .build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut files = vec![]; for path in helper.all_files() { if path.starts_with("src/") && path.to_str().unwrap().ends_with(".rs") && path.ancestors().count() == 3 { let mut file = helper.git_root(); file.push(path); files.push(file); } } let prev = command.maybe_path_metadata_for( ActualInvoke::PerDir, &files.iter().map(|f| f.as_ref()).collect::>(), )?; assert!(prev.is_some()); let prev = prev.unwrap(); assert_eq!( prev.path_map.len(), 3, "excluded files are not in the path map", ); assert!(!command.paths_were_changed(prev.clone())?); let mut file = helper.git_root(); file.push("src/new.rs"); fs::write(&file, "a new file")?; assert!(command.paths_were_changed(prev)?); Ok(()) } #[test] #[parallel] fn paths_were_changed_when_dir_has_file_deleted() -> Result<()> { let command = LintOrTidyCommand { invoke: Invoke::PerDir, includer: MatcherBuilder::new("/").with(&["**/*.rs"])?.build()?, excluder: MatcherBuilder::new("/") .with(&["**/can_ignore.rs"])? .build()?, ..default_command()? }; let helper = TestHelper::new()?.with_git_repo()?; let mut files = vec![]; for path in helper.all_files() { if path.starts_with("src/") && path.to_str().unwrap().ends_with(".rs") && path.ancestors().count() == 3 { let mut file = helper.git_root(); file.push(path); files.push(file); } } let prev = command.maybe_path_metadata_for( ActualInvoke::PerDir, &files.iter().map(|f| f.as_ref()).collect::>(), )?; assert!(prev.is_some()); let prev = prev.unwrap(); assert_eq!( prev.path_map.len(), 3, "excluded files are not in the path map", ); assert!(!command.paths_were_changed(prev.clone())?); fs::remove_file(files.pop().unwrap())?; assert!(command.paths_were_changed(prev)?); Ok(()) } #[test_case( ActualInvoke::Once, &["**/*.go"], &["foo.go"], "foo.go"; "invoke = once with one path" )] #[test_case( ActualInvoke::Once, &["**/*.go"], &["foo.go", "bar.go"], "bar.go foo.go"; "invoke = once with two paths" )] #[test_case( ActualInvoke::Once, &["**/*.go"], &["foo.go", "bar.go", "baz.go"], "bar.go baz.go foo.go"; "invoke = once with three paths" )] #[test_case( ActualInvoke::Once, &["**/*.go"], &["foo.go", "bar.go", "baz.go", "quux.go"], "4 files matching **/*.go, starting with bar.go baz.go"; "invoke = once with four paths" )] #[test_case( ActualInvoke::Once, &["**/*.go", "!food.go"], &["foo.go", "bar.go", "baz.go", "quux.go"], "4 files matching **/*.go !food.go, starting with bar.go baz.go"; "invoke = once with four paths and two includes" )] #[test_case( ActualInvoke::PerDir, &["**/*.go"], &["foo.go"], "foo.go"; "invoke = dir with one path" )] #[test_case( ActualInvoke::PerDir, &["**/*.go"], &["foo.go", "bar.go"], "bar.go foo.go"; "invoke = dir with two paths" )] #[test_case( ActualInvoke::PerDir, &["**/*.go"], &["foo.go", "bar.go", "baz.go"], "bar.go baz.go foo.go"; "invoke = dir with three paths" )] #[test_case( ActualInvoke::PerDir, &["**/*.go"], &["foo.go", "bar.go", "baz.go", "quux.go"], "4 files matching **/*.go, starting with bar.go baz.go"; "invoke = dir with four paths" )] #[test_case( ActualInvoke::PerDir, &["**/*.go", "!food.go"], &["foo.go", "bar.go", "baz.go", "quux.go"], "4 files matching **/*.go !food.go, starting with bar.go baz.go"; "invoke = dir with four paths and two includes" )] #[test_case( ActualInvoke::PerFile, &["**/*.go", "!food.go"], &["foo.go"], "foo.go"; "invoke = file" )] #[parallel] fn paths_summary( actual_invoke: ActualInvoke, include: &[&str], paths: &[&str], expect: &str, ) -> Result<()> { let command = LintOrTidyCommand { name: String::from("Test"), invoke: actual_invoke.as_invoke(), include: include.iter().map(|i| i.to_string()).collect(), ..default_command()? }; assert_eq!( &command.paths_summary( actual_invoke, &paths.iter().map(Path::new).collect::>() ), expect, ); Ok(()) } } precious-0.7.3/precious-core/src/config.rs000066400000000000000000000736401463371203000205440ustar00rootroot00000000000000use crate::command::{self, Invoke, LintOrTidyCommandType, PathArgs, WorkingDir}; use anyhow::Result; use indexmap::IndexMap; use log::warn; use serde::{de, de::Deserializer, Deserialize}; use std::{ collections::HashMap, fmt, fs, marker::PhantomData, path::{Path, PathBuf}, }; use thiserror::Error; #[derive(Clone, Debug, Deserialize)] #[allow(clippy::module_name_repetitions)] pub struct CommandConfig { #[serde(rename = "type")] pub(crate) typ: LintOrTidyCommandType, #[serde(deserialize_with = "string_or_seq_string")] pub(crate) include: Vec, #[serde(default, deserialize_with = "string_or_seq_string")] pub(crate) exclude: Vec, #[serde(default)] pub(crate) invoke: Option, #[serde(default, alias = "working-dir", deserialize_with = "working_dir")] pub(crate) working_dir: Option, #[serde(default, alias = "path-args")] pub(crate) path_args: Option, #[serde(default, alias = "run-mode")] pub(crate) run_mode: Option, #[serde(default)] pub(crate) chdir: Option, #[serde(deserialize_with = "string_or_seq_string")] pub(crate) cmd: Vec, #[serde(default)] pub(crate) env: HashMap, #[serde( default, alias = "lint-flags", deserialize_with = "string_or_seq_string" )] pub(crate) lint_flags: Vec, #[serde( default, alias = "tidy-flags", deserialize_with = "string_or_seq_string" )] pub(crate) tidy_flags: Vec, #[serde(default = "empty_string", alias = "path-flag")] pub(crate) path_flag: String, #[serde(alias = "ok-exit-codes", deserialize_with = "u8_or_seq_u8")] pub(crate) ok_exit_codes: Vec, #[serde( default, alias = "lint-failure-exit-codes", deserialize_with = "u8_or_seq_u8" )] pub(crate) lint_failure_exit_codes: Vec, #[serde(default, alias = "expect-stderr")] pub(crate) expect_stderr: bool, #[serde( default, alias = "ignore-stderr", deserialize_with = "string_or_seq_string" )] pub(crate) ignore_stderr: Vec, #[serde(default, deserialize_with = "string_or_seq_string")] pub(crate) labels: Vec, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] pub(crate) enum OldRunMode { #[serde(rename = "files")] Files, #[serde(rename = "dirs")] Dirs, #[serde(rename = "root")] Root, } fn empty_string() -> String { String::new() } #[derive(Clone, Debug, Deserialize)] pub struct Config { #[serde(default, deserialize_with = "string_or_seq_string")] pub(crate) exclude: Vec, commands: IndexMap, } #[derive(Debug, Error, PartialEq, Eq)] pub(crate) enum ConfigError { #[error("File at {} cannot be read: {error:}", file.display())] FileCannotBeRead { file: PathBuf, error: String }, #[error( "The {name:} command mixes old command params (run_mode or chdir) with new command params (invoke, working-dir, or path-args)" )] CannotMixOldAndNewCommandParams { name: String }, #[error(r#"Cannot set invoke = "per-file" and path-args = "{path_args:}""#)] CannotInvokePerFileWithPathArgs { path_args: PathArgs }, #[error(r#"Cannot set invoke = "per-dir" and path-args = "{path_args:}""#)] CannotInvokePerDirInRootWithPathArgs { path_args: PathArgs }, #[error(r#"Cannot set invoke = "once" and working-dir = "dir""#)] CannotInvokeOnceWithWorkingDirEqDir, #[error(transparent)] Toml(#[from] toml::de::Error), } // Copied from https://stackoverflow.com/a/43627388 - CC-BY-SA 3.0 fn string_or_seq_string<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { struct StringOrVec(PhantomData>); impl<'de> de::Visitor<'de> for StringOrVec { type Value = Vec; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("string or list of strings") } fn visit_str(self, value: &str) -> Result where E: de::Error, { Ok(vec![value.to_owned()]) } fn visit_seq(self, visitor: S) -> Result where S: de::SeqAccess<'de>, { Deserialize::deserialize(de::value::SeqAccessDeserializer::new(visitor)) } } deserializer.deserialize_any(StringOrVec(PhantomData)) } #[allow(clippy::too_many_lines)] fn u8_or_seq_u8<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { struct U8OrVec(PhantomData>); #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] impl<'de> de::Visitor<'de> for U8OrVec { type Value = Vec; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("integer or list of integers, each from 0-255") } fn visit_i8(self, value: i8) -> Result where E: de::Error, { if value < 0 { return Err(de::Error::invalid_type( de::Unexpected::Signed(i64::from(value)), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_i16(self, value: i16) -> Result where E: de::Error, { if value < 0 || value > i16::from(u8::MAX) { return Err(de::Error::invalid_type( de::Unexpected::Signed(i64::from(value)), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_i32(self, value: i32) -> Result where E: de::Error, { if value < 0 || value > i32::from(u8::MAX) { return Err(de::Error::invalid_type( de::Unexpected::Signed(i64::from(value)), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_i64(self, value: i64) -> Result where E: de::Error, { if value < 0 || value > i64::from(u8::MAX) { return Err(de::Error::invalid_type( de::Unexpected::Signed(value), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_u8(self, value: u8) -> Result where E: de::Error, { Ok(vec![value]) } fn visit_u16(self, value: u16) -> Result where E: de::Error, { if value > u16::from(u8::MAX) { return Err(de::Error::invalid_type( de::Unexpected::Unsigned(u64::from(value)), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_u32(self, value: u32) -> Result where E: de::Error, { if value > u32::from(u8::MAX) { return Err(de::Error::invalid_type( de::Unexpected::Unsigned(u64::from(value)), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_u64(self, value: u64) -> Result where E: de::Error, { if value > u64::from(u8::MAX) { return Err(de::Error::invalid_type( de::Unexpected::Unsigned(value), &"an integer from 0-255", )); } Ok(vec![value as u8]) } fn visit_seq(self, visitor: S) -> Result where S: de::SeqAccess<'de>, { Deserialize::deserialize(de::value::SeqAccessDeserializer::new(visitor)) } } deserializer.deserialize_any(U8OrVec(PhantomData)) } fn working_dir<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { struct WorkingDirOrChdirTo(PhantomData>); impl<'de> de::Visitor<'de> for WorkingDirOrChdirTo { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str(r#"one of "root", "dir", or a chdir-to map"#) } fn visit_none(self) -> Result where E: de::Error, { Ok(None) } fn visit_some(self, deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(WorkingDirOrChdirTo(PhantomData)) } fn visit_str(self, value: &str) -> Result where E: de::Error, { match value { "root" => Ok(Some(WorkingDir::Root)), "dir" => Ok(Some(WorkingDir::Dir)), _ => Err(E::invalid_value( de::Unexpected::Str(value), &r#""root" or "dir""#, )), } } fn visit_map(self, mut map: A) -> Result where A: de::MapAccess<'de>, { let mut kv_pairs: Vec<(String, String)> = vec![]; while let Some((k, v)) = map.next_entry::()? { if !(&k == "chdir_to" || &k == "chdir-to") { return Err(::invalid_value( de::Unexpected::Str(&k), &r#"the only valid key for a working-dir map is "chdir-to""#, )); } if v.is_empty() { return Err(::invalid_value( de::Unexpected::Seq, &r#"the "chdir-to" key cannot be empty"#, )); } kv_pairs.push((k, v)); } if kv_pairs.is_empty() { return Err(::invalid_value( de::Unexpected::Map, &r#"the "working-dir" cannot be an empty map"#, )); } if kv_pairs.len() > 1 { return Err(::invalid_value( de::Unexpected::Map, &r#"the "working-dir" map must contain one key, "chdir-to""#, )); } Ok(Some(WorkingDir::ChdirTo(PathBuf::from(&kv_pairs[0].1)))) } } deserializer.deserialize_any(WorkingDirOrChdirTo(PhantomData)) } const DEFAULT_LABEL: &str = "default"; impl Config { pub(crate) fn new(file: &Path) -> Result { match fs::read(file) { Err(e) => Err(ConfigError::FileCannotBeRead { file: file.to_path_buf(), error: e.to_string(), } .into()), Ok(bytes) => { let s = String::from_utf8(bytes)?; Ok(toml::from_str(&s)?) } } } pub(crate) fn into_tidy_commands( self, project_root: &Path, command: Option<&str>, label: Option<&str>, ) -> Result> { self.into_commands(project_root, command, label, LintOrTidyCommandType::Tidy) } pub(crate) fn into_lint_commands( self, project_root: &Path, command: Option<&str>, label: Option<&str>, ) -> Result> { self.into_commands(project_root, command, label, LintOrTidyCommandType::Lint) } fn into_commands( self, project_root: &Path, command: Option<&str>, label: Option<&str>, typ: LintOrTidyCommandType, ) -> Result> { let mut commands: Vec = vec![]; for (name, c) in self.commands { if let Some(c) = command { if name != c { continue; } } if !c.matches_label(label.unwrap_or(DEFAULT_LABEL)) { continue; } if c.typ != typ && c.typ != LintOrTidyCommandType::Both { continue; } commands.push(c.into_command(project_root, name)?); } Ok(commands) } pub(crate) fn command_info(self) -> Vec<(String, CommandConfig)> { self.commands.into_iter().collect() } } impl CommandConfig { fn into_command(self, project_root: &Path, name: String) -> Result { let n = command::LintOrTidyCommand::new(self.into_command_params(project_root, name)?)?; Ok(n) } fn into_command_params( self, project_root: &Path, name: String, ) -> Result { let (invoke, working_dir, path_args) = Self::invoke_args( &name, self.run_mode, self.chdir, self.invoke, self.working_dir, self.path_args, )?; Ok(command::LintOrTidyCommandParams { project_root: project_root.to_owned(), name, typ: self.typ, include: self.include, exclude: self.exclude, invoke, working_dir, path_args, cmd: self.cmd, env: self.env, lint_flags: self.lint_flags, tidy_flags: self.tidy_flags, path_flag: self.path_flag, ok_exit_codes: self.ok_exit_codes, lint_failure_exit_codes: self.lint_failure_exit_codes, expect_stderr: self.expect_stderr, ignore_stderr: self.ignore_stderr, }) } fn invoke_args( name: &str, run_mode: Option, chdir: Option, invoke: Option, working_dir: Option, path_args: Option, ) -> Result<(Invoke, WorkingDir, PathArgs)> { if (run_mode.is_some() || chdir.is_some()) && (invoke.is_some() || working_dir.is_some() || path_args.is_some()) { return Err(ConfigError::CannotMixOldAndNewCommandParams { name: name.to_owned(), } .into()); } // This translates the old config options into their equivalent new // options. if run_mode.is_some() || chdir.is_some() { let (article, plural, options) = match (run_mode, chdir) { (Some(_), None) => ("a ", "", "run-mode"), (None, Some(_)) => ("a ", "", "chdir"), _ => ("", "s", "run-mode and chdir"), }; warn!("The {name} command is using {article:}deprecated config option{plural:}: {options}"); match (run_mode, chdir) { (Some(OldRunMode::Files) | None, Some(false) | None) => { return Ok((Invoke::PerFile, WorkingDir::Root, PathArgs::File)) } (Some(OldRunMode::Files) | None, Some(true)) => { return Ok((Invoke::PerFile, WorkingDir::Dir, PathArgs::File)) } (Some(OldRunMode::Dirs), Some(false) | None) => { return Ok((Invoke::PerDir, WorkingDir::Root, PathArgs::Dir)) } (Some(OldRunMode::Dirs), Some(true)) => { return Ok((Invoke::PerDir, WorkingDir::Dir, PathArgs::None)) } (Some(OldRunMode::Root), Some(false) | None) => { return Ok((Invoke::Once, WorkingDir::Root, PathArgs::Dot)) } (Some(OldRunMode::Root), Some(true)) => { return Ok((Invoke::Once, WorkingDir::Root, PathArgs::None)) } } } let invoke = invoke.unwrap_or(Invoke::PerFile); let working_dir = working_dir.unwrap_or(WorkingDir::Root); let path_args = path_args.unwrap_or(PathArgs::File); match (invoke, &working_dir, path_args) { (Invoke::PerFile, _, path_args) => { if path_args != PathArgs::File && path_args != PathArgs::AbsoluteFile { return Err(ConfigError::CannotInvokePerFileWithPathArgs { path_args }.into()); } } (Invoke::PerDir, &WorkingDir::Root | &WorkingDir::ChdirTo(_), path_args) => { if path_args == PathArgs::Dot || path_args == PathArgs::None { return Err( ConfigError::CannotInvokePerDirInRootWithPathArgs { path_args }.into(), ); } } (Invoke::Once, &WorkingDir::Dir, _) => { return Err(ConfigError::CannotInvokeOnceWithWorkingDirEqDir.into()); } _ => (), } Ok((invoke, working_dir, path_args)) } fn matches_label(&self, label: &str) -> bool { if self.labels.is_empty() { return label == DEFAULT_LABEL; } self.labels.iter().any(|l| *l == label) } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use serial_test::parallel; use test_case::test_case; #[test_case( Some("files"), Some(false), Invoke::PerFile, WorkingDir::Root, PathArgs::File ; "files + false" )] #[test_case( Some("files"), Some(true), Invoke::PerFile, WorkingDir::Dir, PathArgs::File ; "files + true" )] #[test_case( Some("dirs"), Some(false), Invoke::PerDir, WorkingDir::Root, PathArgs::Dir ; "dirs + false" )] #[test_case( Some("dirs"), Some(true), Invoke::PerDir, WorkingDir::Dir, PathArgs::None ; "dirs + true" )] #[test_case( Some("root"), Some(false), Invoke::Once, WorkingDir::Root, PathArgs::Dot ; "root + false" )] #[test_case( Some("root"), Some(true), Invoke::Once, WorkingDir::Root, PathArgs::None ; "root + true" )] #[test_case( Some("files"), None, Invoke::PerFile, WorkingDir::Root, PathArgs::File ; "files + None" )] #[test_case( Some("dirs"), None, Invoke::PerDir, WorkingDir::Root, PathArgs::Dir ; "dirs + None" )] #[test_case( Some("root"), None, Invoke::Once, WorkingDir::Root, PathArgs::Dot ; "root + None" )] #[test_case( None, Some(true), Invoke::PerFile, WorkingDir::Dir, PathArgs::File ; "None + true" )] #[parallel] fn pre_0_4_0_command_config( run_mode: Option<&str>, chdir: Option, invoke: Invoke, working_dir: WorkingDir, path_args: PathArgs, ) -> Result<()> { let root = Path::new("/does-not-matter"); let mut toml_text = String::from( r#" [commands.c1] type = "tidy" include = "**/*.rs" cmd = "cmd" ok-exit-codes = 0 "#, ); if let Some(run_mode) = run_mode { toml_text.push_str(&format!("run-mode = \"{run_mode}\"\n")); } if let Some(chdir) = chdir { toml_text.push_str(&format!("chdir = {chdir}\n")); } let config: Config = toml::from_str(&toml_text)?; let params = config .commands .into_iter() .next() .map(|(name, conf)| conf.into_command_params(root, name)) .unwrap()?; assert_eq!(params.invoke, invoke, "invoke"); assert_eq!(params.working_dir, working_dir, "working_dir"); assert_eq!(params.path_args, path_args, "path_args"); Ok(()) } #[test] #[parallel] fn command_order_is_preserved1() -> Result<()> { let toml_text = r#" [commands.rustfmt] type = "both" include = "**/*.rs" cmd = [ "rustfmt", "--skip-children", "--unstable-features" ] lint-flags = "--check" ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.clippy] type = "lint" include = "**/*.rs" run-mode = "root" chdir = true cmd = "$PRECIOUS_ROOT/dev/bin/force-clippy.sh" ok-exit-codes = 0 lint-failure-exit-codes = 101 [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = [ "omegasort", "--sort=path" ] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 "#; let config: Config = toml::from_str(toml_text)?; let keys = config .commands .keys() .map(|k| k.as_str()) .collect::>(); let expect: Vec<&str> = vec!["rustfmt", "clippy", "omegasort-gitignore"]; assert_eq!(keys, expect); Ok(()) } #[test] #[parallel] fn command_order_is_preserved2() -> Result<()> { let toml_text = r#" [commands.clippy] type = "lint" include = "**/*.rs" run-mode = "root" chdir = true cmd = "$PRECIOUS_ROOT/dev/bin/force-clippy.sh" ok-exit-codes = 0 lint-failure-exit-codes = 101 [commands.rustfmt] type = "both" include = "**/*.rs" cmd = [ "rustfmt", "--skip-children", "--unstable-features" ] lint-flags = "--check" ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = [ "omegasort", "--sort=path" ] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 "#; let config: Config = toml::from_str(toml_text)?; let keys = config .commands .keys() .map(|k| k.as_str()) .collect::>(); let expect: Vec<&str> = vec!["clippy", "rustfmt", "omegasort-gitignore"]; assert_eq!(keys, expect); Ok(()) } #[test] #[parallel] fn command_order_is_preserved3() -> Result<()> { let toml_text = r#" [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = [ "omegasort", "--sort=path" ] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.clippy] type = "lint" include = "**/*.rs" run-mode = "root" chdir = true cmd = "$PRECIOUS_ROOT/dev/bin/force-clippy.sh" ok-exit-codes = 0 lint-failure-exit-codes = 101 [commands.rustfmt] type = "both" include = "**/*.rs" cmd = [ "rustfmt", "--skip-children", "--unstable-features" ] lint-flags = "--check" ok-exit-codes = 0 lint-failure-exit-codes = 1 "#; let config: Config = toml::from_str(toml_text)?; let keys = config .commands .keys() .map(|k| k.as_str()) .collect::>(); let expect: Vec<&str> = vec!["omegasort-gitignore", "clippy", "rustfmt"]; assert_eq!(keys, expect); Ok(()) } #[test_case( Invoke::PerFile, WorkingDir::Root, PathArgs::Dir, ConfigError::CannotInvokePerFileWithPathArgs { path_args: PathArgs::Dir } ; r#"invoke = "per-file" + path-args = "dir""# )] #[test_case( Invoke::PerFile, WorkingDir::Root, PathArgs::None, ConfigError::CannotInvokePerFileWithPathArgs { path_args: PathArgs::None } ; r#"invoke = "per-file" + path-args = "none""# )] #[test_case( Invoke::PerFile, WorkingDir::Root, PathArgs::Dot, ConfigError::CannotInvokePerFileWithPathArgs { path_args: PathArgs::Dot } ; r#"invoke = "per-file" + path-args = "dot""# )] #[test_case( Invoke::PerFile, WorkingDir::Root, PathArgs::AbsoluteDir, ConfigError::CannotInvokePerFileWithPathArgs { path_args: PathArgs::AbsoluteDir } ; r#"invoke = "per-file" + path-args = "absolute-dir""# )] #[test_case( Invoke::PerDir, WorkingDir::Root, PathArgs::None, ConfigError::CannotInvokePerDirInRootWithPathArgs { path_args: PathArgs::None } ; r#"invoke = "per-dir" + working_dir = "root" + path-args = "none""# )] #[test_case( Invoke::PerDir, WorkingDir::Root, PathArgs::Dot, ConfigError::CannotInvokePerDirInRootWithPathArgs { path_args: PathArgs::Dot } ; r#"invoke = "per-dir" + working_dir = "root" + path-args = "dot""# )] #[test_case( Invoke::PerDir, WorkingDir::ChdirTo(PathBuf::from("foo")), PathArgs::None, ConfigError::CannotInvokePerDirInRootWithPathArgs { path_args: PathArgs::None } ; r#"invoke = "per-dir" + working_dir.chdir-to = "foo" + path-args = "none""# )] #[test_case( Invoke::PerDir, WorkingDir::ChdirTo(PathBuf::from("foo")), PathArgs::Dot, ConfigError::CannotInvokePerDirInRootWithPathArgs { path_args: PathArgs::Dot } ; r#"invoke = "per-dir" + working_dir.chdir-to = "foo" + path-args = "dot""# )] #[test_case( Invoke::Once, WorkingDir::Dir, PathArgs::File, ConfigError::CannotInvokeOnceWithWorkingDirEqDir ; r#"invoke = "once" + working_dir = "dir""# )] #[parallel] fn invalid_command_config( invoke: Invoke, working_dir: WorkingDir, path_args: PathArgs, expect_err: ConfigError, ) -> Result<()> { let config = CommandConfig { typ: LintOrTidyCommandType::Lint, invoke: Some(invoke), working_dir: Some(working_dir), path_args: Some(path_args), include: vec![String::from("**/*.rs")], exclude: vec![], run_mode: None, chdir: None, cmd: vec![String::from("some-linter")], env: Default::default(), lint_flags: vec![], tidy_flags: vec![], path_flag: String::new(), ok_exit_codes: vec![], lint_failure_exit_codes: vec![], expect_stderr: false, ignore_stderr: vec![], labels: vec![], }; let res = config.into_command(Path::new("."), String::from("some-linter")); let err = res.unwrap_err().downcast::().unwrap(); assert_eq!(err, expect_err); Ok(()) } #[test_case(vec![], "default", true)] #[test_case(vec!["default".to_string()], "default", true)] #[test_case(vec!["default".to_string(), "foo".to_string()], "default", true)] #[test_case(vec!["default".to_string(), "foo".to_string()], "foo", true)] #[test_case(vec!["foo".to_string()], "foo", true)] #[test_case(vec![], "foo", false)] #[test_case(vec!["foo".to_string()], "default", false)] #[test_case(vec!["default".to_string()], "foo", false)] #[parallel] fn matches_label( labels_in_config: Vec, label_to_match: &str, expect_match: bool, ) -> Result<()> { let config = CommandConfig { typ: LintOrTidyCommandType::Lint, invoke: None, working_dir: None, path_args: None, include: vec![String::from("**/*.rs")], exclude: vec![], run_mode: None, chdir: None, cmd: vec![String::from("some-linter")], env: Default::default(), lint_flags: vec![], tidy_flags: vec![], path_flag: String::new(), ok_exit_codes: vec![], lint_failure_exit_codes: vec![], expect_stderr: false, ignore_stderr: vec![], labels: labels_in_config, }; if expect_match { assert!(config.matches_label(label_to_match)); } else { assert!(!config.matches_label(label_to_match)); } Ok(()) } #[test_case( r#""per-file-or-dir" = 42"#, Invoke::PerFileOrDir(42); "per-file-or-dir" )] #[test_case( r#""per-file-or-once" = 42"#, Invoke::PerFileOrOnce(42); "per-file-or-once" )] #[test_case( r#""per-dir-or-once" = 42"#, Invoke::PerDirOrOnce(42); "per-dir-or-once" )] #[parallel] fn new_invoke_options(invoke: &str, expect: Invoke) -> Result<()> { let toml_text = format!( r#" [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" invoke = {{ {invoke:} }} cmd = [ "omegasort", "--sort=path" ] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 "# ); let config: Config = toml::from_str(&toml_text)?; assert_eq!(config.commands[0].invoke, Some(expect)); Ok(()) } } precious-0.7.3/precious-core/src/config_init.rs000066400000000000000000000400321463371203000215540ustar00rootroot00000000000000use anyhow::Result; use clap::ValueEnum; use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use log::debug; use std::{ collections::{HashMap, HashSet}, env, fs::{create_dir_all, File}, io::Write, path::{Path, PathBuf}, }; use thiserror::Error; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; pub(crate) struct Init { pub(crate) excludes: &'static [&'static str], pub(crate) commands: &'static [(&'static str, &'static str)], pub(crate) extra_files: Vec, pub(crate) tool_urls: &'static [&'static str], } pub(crate) struct ConfigInitFile { pub(crate) path: PathBuf, pub(crate) content: &'static str, pub(crate) is_executable: bool, } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, ValueEnum)] pub(crate) enum InitComponent { Go, Perl, Rust, Gitignore, Markdown, Shell, Toml, Yaml, } #[derive(Debug, Error)] enum ConfigInitError { #[error("A file already exists at the given path: {path}")] FileExists { path: PathBuf }, } const GO_COMMANDS: [(&str, &str); 3] = [ ( "golangci-lint", r#" type = "both" include = "**/*.go" # For large projects with many packages, you may want to set # `invoke.per-dir-or-once = 7`. You can experiment with different numbers of # directories to see what works best for your project. invoke = "once" path-args = "dir" # The `--allow-parallel-runners` flag is only relevant when `invoke` is not # set to `once`. # # Allowing golangci-lint to run in parallel reduces the effectiveness of its # cache when it has to parse the same code repeatedly. Depending on the # structure of your repo, you may get a better result by using the # `--allow-serial-runners` flag instead. However, if `invoke` is not `once`, # you must use one of these, as by default golangci-lint can simply timeout # and fail when multiple instances of the executable are invoked at the same # time for the same project. # # Alternatively, for smaller projects you can set `invoke = "once"` and # `path-args = "none"` to run it once for all code in the project, in which # case you can remove this flag. cmd = ["golangci-lint", "run", "-c", "--allow-parallel-runners"] tidy-flags = "--fix" env = { "FAIL_ON_WARNINGS" = "1" } ok-exit-codes = [0] lint-failure-exit-codes = [1] "#, ), ( "tidy go files", r#" type = "tidy" include = "**/*.go" cmd = ["gofumpt", "-w"] ok-exit-codes = [0] "#, ), ( "check-go-mod", r#" type = "lint" include = "**/*.go" invoke = "once" path-args = "none" cmd = ["$PRECIOUS_ROOT/dev/bin/check-go-mod.sh"] ok-exit-codes = [0] lint-failure-exit-codes = [1] "#, ), ]; const GOLANGCI_YML: &str = " linters: disable-all: true enable: - bidichk - bodyclose - decorder - dupl - dupword - durationcheck - errcheck - errchkjson - errname - errorlint - exhaustive - exportloopref - gci - gocheckcompilerdirectives - goconst - gocritic - godot - gofumpt - gomnd - gosimple - govet - importas - ineffassign - misspell - nolintlint - lll - mirror - nonamedreturns - paralleltest - revive - rowserrcheck - sloglint - sqlclosecheck - staticcheck - tenv - testifylint - thelper - typecheck - unconvert - unused - usestdlibvars - wastedassign - whitespace - wrapcheck fast: false linters-settings: errcheck: check-type-assertions: true gci: sections: - standard - default govet: check-shadowing: true importas: no-extra-aliases: true "; const CHECK_GO_MOD: &str = r#" #!/bin/bash set -e ROOT=$(git rev-parse --show-toplevel) if [ ! -f "$ROOT/go.sum" ]; then exit 0 fi BEFORE_MOD=$(md5sum "$ROOT/go.mod") BEFORE_SUM=$(md5sum "$ROOT/go.sum") OUTPUT=$(go mod tidy -v 2>&1) AFTER_MOD=$(md5sum "$ROOT/go.mod") AFTER_SUM=$(md5sum "$ROOT/go.sum") red=$'\e[1;31m' end=$'\e[0m' if [ "$BEFORE_MOD" != "$AFTER_MOD" ]; then printf "${red}Running go mod tidy changed the contents of go.mod${end}\n" git diff "$ROOT/go.mod" changed=1 fi if [ "$BEFORE_SUM" != "$AFTER_SUM" ]; then printf "${red}Running go mod tidy changed the contents of go.sum${end}\n" git diff "$ROOT/go.sum" changed=1 fi if [ -n "$changed" ]; then if [ -n "$OUTPUT" ]; then printf "\nOutput from running go mod tidy -v:\n${OUTPUT}\n" else printf "\nThere was no output from running go mod tidy -v\n\n" fi exit 1 fi exit 0 "#; pub(crate) fn go_init() -> Init { Init { excludes: &["vendor/**/*"], commands: &GO_COMMANDS, extra_files: vec![ ConfigInitFile { path: PathBuf::from("dev/bin/check-go-mod.sh"), content: CHECK_GO_MOD, is_executable: true, }, ConfigInitFile { path: PathBuf::from(".golangci.yml"), content: GOLANGCI_YML, is_executable: false, }, ], tool_urls: &[ "https://golangci-lint.run/", "https://github.com/mvdan/gofumpt", ], } } const PERL_COMMANDS: [(&str, &str); 5] = [ ( "perlimports", r#" type = "both" include = ["**/*.{pl,pm,t,psgi}"] cmd = ["perlimports"] lint-flags = ["--lint"] tidy-flags = ["-i"] ok-exit-codes = 0 expect-stderr = true "#, ), ( "perlcritic", r#" type = "lint" include = ["**/*.{pl,pm,t,psgi}"] cmd = ["perlcritic", "--profile=$PRECIOUS_ROOT/perlcriticrc"] ok-exit-codes = 0 lint-failure-exit-codes = 2 "#, ), ( "perltidy", r#" type = "both" include = ["**/*.{pl,pm,t,psgi}"] cmd = ["perltidy", "--profile=$PRECIOUS_ROOT/perltidyrc"] lint-flags = ["--assert-tidy", "--no-standard-output", "--outfile=/dev/null"] tidy-flags = ["--backup-and-modify-in-place", "--backup-file-extension=/"] ok-exit-codes = 0 lint-failure-exit-codes = 2 ignore-stderr = "Begin Error Output Stream" "#, ), ( "podchecker", r#" type = "lint" include = ["**/*.{pl,pm,pod}"] cmd = ["podchecker", "--warnings", "--warnings"] ok-exit-codes = [0, 2] lint-failure-exit-codes = 1 ignore-stderr = [".+ pod syntax OK", ".+ does not contain any pod commands"] "#, ), ( "podtidy", r#" type = "tidy" include = ["**/*.{pl,pm,pod}"] cmd = ["podtidy", "--columns", "100", "--inplace", "--nobackup"] ok-exit-codes = 0 lint-failure-exit-codes = 1 "#, ), ]; pub(crate) fn perl_init() -> Init { Init { excludes: &[".build/**", "blib/**"], commands: &PERL_COMMANDS, extra_files: vec![], tool_urls: &[ "https://metacpan.org/dist/Perl-Critic", "https://metacpan.org/dist/Perl-Tidy", "https://metacpan.org/dist/App-perlimports", "https://metacpan.org/dist/Pod-Checker", "https://metacpan.org/dist/Pod-Tidy", ], } } const RUST_COMMANDS: [(&str, &str); 2] = [ ( "rustfmt", r#" type = "both" include = "**/*.rs" cmd = ["rustfmt", "--edition", "2021"] lint-flags = "--check" ok-exit-codes = 0 lint-failure-exit-codes = 1 "#, ), ( "clippy", r#" type = "lint" include = "**/*.rs" invoke = "once" path-args = "none" cmd = [ "cargo", "clippy", "--locked", "--all-targets", "--all-features", "--workspace", "--", "-D", "clippy::all", ] ok-exit-codes = 0 lint-failure-exit-codes = 101 ignore-stderr = ["Checking.+precious", "Finished.+dev", "could not compile"] "#, ), ]; pub(crate) fn rust_init() -> Init { Init { excludes: &["target"], commands: &RUST_COMMANDS, extra_files: vec![], tool_urls: &["https://doc.rust-lang.org/clippy/"], } } const SHELL_COMMANDS: [(&str, &str); 2] = [ ( "shellcheck", r#" type = "lint" include = "**/*.sh" cmd = "shellcheck" ok_exit_codes = 0 lint_failure_exit_codes = 1 "#, ), ( "shfmt", r#" type = "both" include = "**/*.sh" cmd = ["shfmt", "--simplify", "--indent", "4"] lint_flags = "--diff" tidy_flags = "--write" ok_exit_codes = 0 lint_failure_exit_codes = 1 "#, ), ]; pub(crate) fn shell_init() -> Init { Init { excludes: &["target"], commands: &SHELL_COMMANDS, extra_files: vec![], tool_urls: &["https://www.shellcheck.net/", "https://github.com/mvdan/sh"], } } const GITIGNORE_COMMANDS: [(&str, &str); 1] = [( "omegasort-gitignore", r#" type = "both" include = "**/.gitignore" cmd = ["omegasort", "--sort", "path", "--unique"] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["The .+ file is not sorted", "The .+ file is not unique"] "#, )]; pub(crate) fn gitignore_init() -> Init { Init { excludes: &[], commands: &GITIGNORE_COMMANDS, extra_files: vec![], tool_urls: &["https://github.com/houseabsolute/omegasort"], } } const MARKDOWN_COMMANDS: [(&str, &str); 1] = [( "prettier-markdown", r#" type = "both" include = "**/*.md" cmd = [ "./node_modules/.bin/prettier", "--no-config", "--print-width", "100", "--prose-wrap", "always", ] lint-flags = "--check" tidy-flags = "--write" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["Code style issues"] "#, )]; pub(crate) fn markdown_init() -> Init { Init { excludes: &[], commands: &MARKDOWN_COMMANDS, extra_files: vec![], tool_urls: &["https://prettier.io/"], } } const TOML_COMMANDS: [(&str, &str); 1] = [( "taplo", r#" type = "both" include = "**/*.toml" cmd = ["taplo", "format", "--option", "indent_string= ", "--option", "column_width=100"] lint_flags = "--check" ok_exit_codes = 0 lint_failure_exit_codes = 1 ignore_stderr = "INFO taplo.+" "#, )]; pub(crate) fn toml_init() -> Init { Init { excludes: &[], commands: &TOML_COMMANDS, extra_files: vec![], tool_urls: &["https://taplo.tamasfe.dev/"], } } const YAML_COMMANDS: [(&str, &str); 1] = [( "prettier-yaml", r#" type = "both" include = "**/*.yml" cmd = ["./node_modules/.bin/prettier", "--no-config"] lint-flags = "--check" tidy-flags = "--write" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["Code style issues"] "#, )]; pub(crate) fn yaml_init() -> Init { Init { excludes: &[], commands: &YAML_COMMANDS, extra_files: vec![], tool_urls: &["https://prettier.io/"], } } struct ConfigElements { excludes: HashSet<&'static str>, commands: IndexMap<&'static str, &'static str>, extra_files: HashMap, tool_urls: IndexSet<&'static str>, } pub(crate) fn write_config_files( auto: bool, components: &[InitComponent], path: &Path, ) -> Result<()> { if env::current_dir()?.join(path).exists() { return Err(ConfigInitError::FileExists { path: path.to_owned(), } .into()); } let elements = config_elements(auto, components)?; let mut toml = excludes_toml(&elements.excludes); if !toml.is_empty() { toml.push_str("\n\n"); } toml.push_str(&commands_toml(elements.commands)); println!(); println!("Writing {}", path.display()); let mut precious_toml = File::create(path)?; precious_toml.write_all(toml.as_bytes())?; write_extra_files(&elements.extra_files)?; println!(); println!("The generated precious.toml requires the following tools to be installed:"); for u in elements.tool_urls { println!(" {u}"); } println!(); Ok(()) } fn config_elements(auto: bool, components: &[InitComponent]) -> Result { let mut excludes: HashSet<&'static str> = HashSet::new(); let mut commands = IndexMap::new(); let mut extra_files = HashMap::new(); let mut tool_urls: IndexSet<&'static str> = IndexSet::new(); for l in auto_or_component(auto, components)? { let init = match l { InitComponent::Go => go_init(), InitComponent::Perl => perl_init(), InitComponent::Rust => rust_init(), InitComponent::Shell => shell_init(), InitComponent::Gitignore => gitignore_init(), InitComponent::Markdown => markdown_init(), InitComponent::Toml => toml_init(), InitComponent::Yaml => yaml_init(), }; excludes.extend(init.excludes); for (name, c) in init.commands { commands.insert(*name, *c); } for f in init.extra_files { extra_files.insert(f.path.clone(), f); } tool_urls.extend(init.tool_urls); } Ok(ConfigElements { excludes, commands, extra_files, tool_urls, }) } fn auto_or_component(auto: bool, components: &[InitComponent]) -> Result> { if !auto { return Ok(components.to_vec()); } let mut components: HashSet = HashSet::new(); let cwd = env::current_dir()?; debug!( "Looking at all files under {} to determine which components to include.", cwd.display(), ); for result in ignore::WalkBuilder::new(&cwd).hidden(false).build() { let entry = result?; // The only time this is `None` is when the entry is for stdin, which // will never happen here. if !entry.file_type().unwrap().is_file() { continue; } if entry.file_name() == ".gitignore" { components.insert(InitComponent::Gitignore); continue; } let component = match entry .path() .extension() .unwrap_or_default() .to_str() .unwrap_or_default() { "go" => InitComponent::Go, "md" => InitComponent::Markdown, "pl" | "pm" => InitComponent::Perl, "rs" => InitComponent::Rust, "sh" => InitComponent::Shell, "toml" => InitComponent::Toml, "yml" | "yaml" => InitComponent::Yaml, _ => continue, }; debug!( "File {} matches component {:?}", entry.path().display(), component, ); components.insert(component); } Ok(components.into_iter().collect()) } fn excludes_toml(excludes: &HashSet<&str>) -> String { if excludes.is_empty() { return String::new(); } if excludes.len() == 1 { format!("excludes = [\"{}\"]", excludes.iter().next().unwrap(),) } else { format!( "excludes = [\n{}\n]", excludes .iter() .sorted() .map(|e| format!(r#" "{e}","#)) .collect::>() .join("\n") ) } } fn commands_toml(commands: IndexMap<&str, &str>) -> String { let mut command_strs: Vec = Vec::new(); for (name, c) in commands { let name_str = if name.contains(' ') { format!(r#""{name}""#) } else { name.to_string() }; command_strs.push(format!("[commands.{name_str}]\n{}\n", c.trim())); } command_strs.join("\n") } fn write_extra_files(extra_files: &HashMap) -> Result<()> { if extra_files.is_empty() { return Ok(()); } println!(); println!("Generating support files"); println!(); let paths = extra_files.keys().sorted().collect::>(); for p in paths { print!("{} ...", p.display()); if p.exists() { println!(" already exists, skipping - delete this file if you want to regenerate it"); continue; } println!(" generated"); if let Some(parent) = p.parent() { create_dir_all(parent)?; } let mut file = File::create(p)?; let f = extra_files.get(p).unwrap(); file.write_all(f.content.trim_start().as_bytes())?; #[cfg(unix)] if f.is_executable { let mut perms = file.metadata()?.permissions(); perms.set_mode(0o755); file.set_permissions(perms)?; } } Ok(()) } precious-0.7.3/precious-core/src/lib.rs000066400000000000000000000001341463371203000200310ustar00rootroot00000000000000pub mod precious; mod chars; mod command; mod config; mod config_init; mod paths; mod vcs; precious-0.7.3/precious-core/src/paths.rs000066400000000000000000000000571463371203000204060ustar00rootroot00000000000000pub mod finder; pub mod matcher; pub mod mode; precious-0.7.3/precious-core/src/paths/000077500000000000000000000000001463371203000200365ustar00rootroot00000000000000precious-0.7.3/precious-core/src/paths/finder.rs000066400000000000000000000743731463371203000216710ustar00rootroot00000000000000use crate::{ paths::{ matcher::{Matcher, MatcherBuilder}, mode::Mode, }, vcs, }; use anyhow::Result; use clean_path::Clean; use log::{debug, error}; use once_cell::sync::Lazy; use precious_helpers::exec; use regex::Regex; use std::{ collections::HashMap, fs, path::{Path, PathBuf}, }; use thiserror::Error; #[derive(Debug)] pub struct Finder { mode: Mode, project_root: PathBuf, git_root: Option, cwd: PathBuf, exclude_globs: Vec, stashed: bool, } #[derive(Debug, Error, Eq, PartialEq)] #[allow(clippy::module_name_repetitions)] pub enum FinderError { #[error("You cannot pass an explicit list of files when looking for {mode:}")] GotPathsFromCliWithWrongMode { mode: Mode }, #[error("Found some paths when looking for {mode:} but they were all excluded")] AllPathsWereExcluded { mode: Mode }, #[error("Path passed on the command line does not exist: {}", path.display())] NonExistentPathOnCli { path: PathBuf }, #[error("Could not determine the repo root by running \"git rev-parse --show-toplevel\"")] CouldNotDetermineRepoRoot, #[error("The path \"{}\" does not contain \"{}\" as a prefix", path.display(), prefix.display())] PrefixNotFound { path: PathBuf, prefix: PathBuf }, } static KEEP_INDEX_RE: Lazy = Lazy::new(|| Regex::new(".*").unwrap()); impl Finder { pub fn new( mode: Mode, project_root: PathBuf, cwd: PathBuf, exclude_globs: Vec, ) -> Result { Ok(Finder { mode, project_root: fs::canonicalize(project_root)?, git_root: None, cwd, exclude_globs, stashed: false, }) } pub fn files(&mut self, cli_paths: Vec) -> Result>> { match self.mode { Mode::FromCli => (), _ => { if !cli_paths.is_empty() { return Err(FinderError::GotPathsFromCliWithWrongMode { mode: self.mode.clone(), } .into()); } } }; let mut files = match self.mode.clone() { Mode::All => self.all_files()?, Mode::FromCli => self.files_from_cli(cli_paths)?, Mode::GitModified => self.git_modified_files()?, Mode::GitStaged | Mode::GitStagedWithStash => self.git_staged_files()?, Mode::GitDiffFrom(ref from) => self.git_modified_since(from)?, }; files.sort(); if files.is_empty() { return match self.mode { Mode::GitModified | Mode::GitStaged | Mode::GitStagedWithStash | Mode::GitDiffFrom(_) => Ok(None), _ => Err(FinderError::AllPathsWereExcluded { mode: self.mode.clone(), } .into()), }; } Ok(Some(files)) } fn git_root(&mut self) -> Result { if let Some(r) = &self.git_root { return Ok(r.clone()); } let res = exec::run( "git", &["rev-parse", "--show-toplevel"], &HashMap::new(), &[0], None, Some(&self.project_root), )?; let stdout = res.stdout.ok_or(FinderError::CouldNotDetermineRepoRoot)?; self.git_root = Some(PathBuf::from(stdout.trim())); Ok(self.git_root.clone().unwrap()) } fn all_files(&self) -> Result> { debug!("Getting all files under {}", self.project_root.display()); self.walkdir_files(self.project_root.as_path()) } fn files_from_cli(&self, cli_paths: Vec) -> Result> { debug!("Using the list of files passed from the command line"); let excluder = self.excluder()?; let mut files: Vec = vec![]; for rel_to_cwd in cli_paths { let full = self.cwd.clone().join(rel_to_cwd.clone()); if !full.exists() { return Err(FinderError::NonExistentPathOnCli { path: rel_to_cwd }.into()); } let rel_to_root = self.path_relative_to_project_root(&full)?; if excluder.path_matches(&rel_to_root, full.is_dir()) { continue; } if full.is_dir() { let mut contents = self.walkdir_files(&full)?; files.append(&mut contents); } else { files.push(rel_to_root); } } Ok(files) } fn git_modified_files(&mut self) -> Result> { debug!("Getting modified files according to git"); self.files_from_git(&["diff", "--name-only", "--diff-filter=ACM", "HEAD"]) } fn git_staged_files(&mut self) -> Result> { debug!("Getting staged files according to git"); self.maybe_git_stash()?; self.files_from_git(&["diff", "--cached", "--name-only", "--diff-filter=ACM"]) } fn maybe_git_stash(&mut self) -> Result<()> { if self.mode != Mode::GitStagedWithStash { return Ok(()); } let git_root = self.git_root()?; let mut mm = git_root.clone(); mm.push(".git"); mm.push("MERGE_MODE"); if !mm.exists() { exec::run( "git", &["stash", "--keep-index"], &HashMap::new(), &[0], // If there is a post-checkout hook, git will show any output // it prints to stdout on stderr instead. Some(&[KEEP_INDEX_RE.clone()]), Some(&git_root), )?; self.stashed = true; } Ok(()) } fn git_modified_since(&mut self, since: &str) -> Result> { let since_dot = format!("{since:}..."); self.files_from_git(&["diff", "--name-only", "--diff-filter=ACM", &since_dot]) } fn walkdir_files(&self, root: &Path) -> Result> { let mut exclude_globs = ignore::overrides::OverrideBuilder::new(root); for d in vcs::DIRS { exclude_globs.add(&format!("!{d}/**/*"))?; } let mut files: Vec = vec![]; for result in ignore::WalkBuilder::new(root) .hidden(false) .overrides(exclude_globs.build()?) .build() { match result { Ok(ent) => { if ent.path().is_dir() { continue; } files.push(ent.into_path()); } Err(e) => return Err(e.into()), }; } let excluder = self.excluder()?; Ok(self .paths_relative_to_project_root(&self.project_root, files)? .into_iter() .filter(|f| !excluder.path_matches(f, false)) .collect::>()) } fn files_from_git(&mut self, args: &[&str]) -> Result> { let git_root = self.git_root()?; let result = exec::run( "git", args, &HashMap::new(), &[0], None, Some(&self.project_root), )?; let excluder = self.excluder()?; match result.stdout { Some(s) => Ok( // In the common case where the git repo root and project root // are the same, this isn't necessary, because git will give // us paths relative to the project root. But if the precious // root _isn't_ the git root, we need to get the path relative // to the project root, not the repo root. self.paths_relative_to_project_root( &git_root, s.lines() .filter_map(|rel| { let pb = PathBuf::from(rel); if excluder.path_matches(&pb, false) { return None; } let mut f = git_root.clone(); f.push(&pb); if !f.exists() { debug!( "The staged file at {rel:} was deleted so it will be ignored.", ); return None; } Some(f) }) .collect(), )?, ), None => Ok(vec![]), } } fn excluder(&self) -> Result { MatcherBuilder::new(&self.project_root) .with(&self.exclude_globs)? .with(vcs::DIRS)? .build() } // We want to make all files relative. This lets us consistently produce // path names starting at the root dir (without "./"). The given root is // the _current_ root for the relative file, which can be the cwd or the // git root instead of the project root. fn paths_relative_to_project_root( &self, // This is the root to which the given paths are relative. This might // be the project root or it might be the git root, which are not // guaranteed to be the same thing. path_root: &Path, paths: Vec, ) -> Result> { let mut relative: Vec = vec![]; for mut f in paths { if !f.is_absolute() { f = path_root.join(f); } relative.push(self.path_relative_to_project_root(&f)?); } Ok(relative) } fn path_relative_to_project_root(&self, path: &Path) -> Result { // If the directory given is just "." then the first clean() removes // that and we then strip the prefix, leaving an empty string. The // second clean turns that back into ".". Ok(fs::canonicalize(path)? .clean() .strip_prefix(&self.project_root) .map_err(|_| FinderError::PrefixNotFound { path: path.to_path_buf(), prefix: self.project_root.clone(), })? .to_path_buf() .clean()) } } impl Drop for Finder { fn drop(&mut self) { if !self.stashed { return; } let res = exec::run( "git", &["stash", "pop"], &HashMap::new(), &[0], None, Some(&self.project_root), ); if res.is_ok() { return; } error!("Error popping stash: {}", res.unwrap_err()); } } #[cfg(test)] mod tests { use super::*; use anyhow::Result; use itertools::Itertools; use precious_testhelper as testhelper; use pretty_assertions::assert_eq; use serial_test::parallel; use std::fs; fn new_finder(mode: Mode, root: PathBuf) -> Result { new_finder_with_excludes(mode, root.clone(), root, vec![]) } fn new_finder_with_cwd(mode: Mode, root: PathBuf, cwd: PathBuf) -> Result { new_finder_with_excludes(mode, root, cwd, vec![]) } fn new_finder_with_excludes( mode: Mode, root: PathBuf, cwd: PathBuf, exclude: Vec, ) -> Result { Finder::new(mode, root, cwd, exclude) } #[cfg(not(target_os = "windows"))] fn set_up_post_checkout_hook(helper: &testhelper::TestHelper) -> Result<()> { use std::os::unix::fs::PermissionsExt; let hook = r#" #!/bin/sh echo "post checkout hook output" "#; let mut file_path = helper.precious_root(); file_path.push(".git/hooks/post-checkout"); helper.write_file(&file_path, hook)?; let path_string = &file_path.into_os_string(); let metadata = fs::metadata(path_string)?; let mut perms = metadata.permissions(); perms.set_mode(0o755); fs::set_permissions(path_string, perms)?; Ok(()) } #[test] #[parallel] fn all_mode() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut finder = new_finder(Mode::All, helper.precious_root())?; assert_eq!(finder.files(vec![])?, Some(helper.all_files())); Ok(()) } #[test] #[parallel] fn all_mode_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_cwd(Mode::All, helper.precious_root(), cwd)?; assert_eq!(finder.files(vec![])?, Some(helper.all_files())); Ok(()) } #[test] #[parallel] fn all_mode_with_gitignore() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut gitignores = helper.add_gitignore_files()?; let mut expect = testhelper::TestHelper::non_ignored_files(); expect.append(&mut gitignores); expect.sort(); let mut finder = new_finder(Mode::All, helper.precious_root())?; assert_eq!(finder.files(vec![])?, Some(expect)); Ok(()) } #[test] #[parallel] fn all_mode_with_excluded_files() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "new content")?; let mut finder = new_finder_with_excludes( Mode::All, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, Some(helper.all_files())); Ok(()) } #[test] #[parallel] fn git_modified_mode_empty() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut finder = new_finder(Mode::GitModified, helper.precious_root())?; let res = finder.files(vec![]); assert!(res.is_ok()); assert!(res.unwrap().is_none()); Ok(()) } #[test] #[parallel] fn git_modified_mode_with_changes() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; let mut finder = new_finder(Mode::GitModified, helper.precious_root())?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_modified_mode_with_changes_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_cwd(Mode::GitModified, helper.precious_root(), cwd)?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_modified_mode_with_changes_all_excluded() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; helper.stage_all()?; let mut finder = new_finder_with_excludes( Mode::GitModified, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, None); Ok(()) } #[test] #[parallel] fn git_modified_mode_with_excluded_files() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; helper.stage_all()?; helper.commit_all()?; let modified = helper.modify_files()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "new content")?; let mut finder = new_finder_with_excludes( Mode::GitModified, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_modified_mode_with_excluded_files_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; helper.stage_all()?; helper.commit_all()?; let modified = helper.modify_files()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "new content")?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_excludes( Mode::GitModified, helper.precious_root(), cwd, vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_modified_mode_when_repo_root_ne_precious_root() -> Result<()> { let helper = testhelper::TestHelper::new()? .with_precious_root_in_subdir("subdir") .with_git_repo()?; let modified = helper.modify_files()?; let mut project_root = helper.git_root(); project_root.push("subdir"); let mut finder = new_finder(Mode::GitModified, project_root)?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_modified_mode_includes_staged() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; helper.stage_some(&[&modified[0]])?; let mut finder = new_finder(Mode::GitModified, helper.precious_root())?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_staged_mode_empty() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut finder = new_finder(Mode::GitStaged, helper.precious_root())?; let res = finder.files(vec![]); assert!(res.is_ok()); assert!(res.unwrap().is_none()); Ok(()) } #[test] #[parallel] fn git_staged_mode_with_changes() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; { let mut finder = new_finder(Mode::GitStaged, helper.precious_root())?; let res = finder.files(vec![]); assert!(res.is_ok()); assert!(res.unwrap().is_none()); } { let mut finder = new_finder(Mode::GitStaged, helper.precious_root())?; helper.stage_all()?; assert_eq!(finder.files(vec![])?, Some(modified)); } Ok(()) } #[test] #[parallel] fn git_staged_mode_with_changes_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; let mut cwd = helper.precious_root(); cwd.push("src"); { let mut finder = new_finder_with_cwd(Mode::GitStaged, helper.precious_root(), cwd.clone())?; let res = finder.files(vec![]); assert!(res.is_ok()); assert!(res.unwrap().is_none()); } { let mut finder = new_finder_with_cwd(Mode::GitStaged, helper.precious_root(), cwd)?; helper.stage_all()?; assert_eq!(finder.files(vec![])?, Some(modified)); } Ok(()) } #[test] #[parallel] fn git_staged_mode_with_changes_all_excluded() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; helper.stage_all()?; let mut finder = new_finder_with_excludes( Mode::GitStaged, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, None); Ok(()) } #[test] #[parallel] fn git_staged_mode_with_excluded_files() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; helper.stage_all()?; let mut finder = new_finder_with_excludes( Mode::GitStaged, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_staged_mode_with_excluded_files_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; helper.stage_all()?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_excludes( Mode::GitStaged, helper.precious_root(), cwd, vec!["vendor/**/*".to_string()], )?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_staged_mode_with_stash_stashes_unindexed() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let modified = helper.modify_files()?; helper.stage_all()?; let unstaged = "tests/data/bar.txt"; helper.write_file(PathBuf::from(unstaged), "new content")?; #[cfg(not(target_os = "windows"))] set_up_post_checkout_hook(&helper)?; { let mut finder = new_finder(Mode::GitStagedWithStash, helper.precious_root())?; assert_eq!(finder.files(vec![])?, Some(modified)); assert_eq!( String::from_utf8(fs::read(helper.precious_root().join(unstaged))?)?, String::from("some text"), ); } assert_eq!( String::from_utf8(fs::read(helper.precious_root().join(unstaged))?)?, String::from("new content"), ); Ok(()) } // This tests the issue reported in // https://github.com/houseabsolute/precious/issues/9. I had tried to test // for this earlier, but I thought it was a non-issue because I couldn't // replicate it. Later, I realized that this only happens if a merge // commit leads to a conflict. Otherwise, `git diff --cached` won't report // any files at all for the commit. But if you've had a conflict and // resolved it, any files that had a conflict will be reported as having a // diff. #[test] #[parallel] fn git_staged_mode_with_stash_merge_stash() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let file = Path::new("merge-conflict-here"); helper.write_file(file, "line 1\nline 2\n")?; helper.stage_all()?; helper.commit_all()?; helper.switch_to_branch("new-branch", false)?; helper.write_file(file, "line 1\nline 1.5\nline 2\n")?; helper.commit_all()?; helper.switch_to_branch("master", true)?; helper.write_file(file, "line 1\nline 1.6\nline 2\n")?; helper.commit_all()?; helper.switch_to_branch("new-branch", true)?; helper.merge_master(true)?; helper.write_file(file, "line 1\nline 1.7\nline 2\n")?; helper.stage_all()?; let mut finder = new_finder(Mode::GitStaged, helper.precious_root())?; assert_eq!( finder.files(vec![])?, Some(vec![PathBuf::from("merge-conflict-here")]), ); assert!(!finder.stashed); Ok(()) } #[test] #[parallel] fn git_staged_mode_with_deleted_file() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut modified = helper.modify_files()?; helper.stage_all()?; helper.delete_file(modified.remove(0))?; let mut finder = new_finder(Mode::GitStaged, helper.precious_root())?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn git_modified_since() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.switch_to_branch("some-branch", false)?; // When there are no commits in the branch the diff between master and // the branch finds no files. let mut finder = new_finder( Mode::GitDiffFrom("master".to_string()), helper.precious_root(), )?; assert_eq!(finder.files(vec![])?, None); let modified = helper.modify_files()?; helper.commit_all()?; let mut finder = new_finder( Mode::GitDiffFrom("master".to_string()), helper.precious_root(), )?; assert_eq!(finder.files(vec![])?, Some(modified)); Ok(()) } #[test] #[parallel] fn cli_mode() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut finder = new_finder(Mode::FromCli, helper.precious_root())?; let expect = helper .all_files() .into_iter() .filter(|p| p.starts_with("tests/")) .sorted() .collect::>(); assert_eq!(finder.files(vec![PathBuf::from("tests")])?, Some(expect)); Ok(()) } #[test] #[parallel] fn cli_mode_given_dir_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_cwd(Mode::FromCli, helper.precious_root(), cwd)?; let expect = helper .all_files() .into_iter() .filter(|p| p.starts_with("src/")) .sorted() .collect::>(); assert_eq!(finder.files(vec![PathBuf::from(".")])?, Some(expect)); Ok(()) } #[test] #[parallel] fn cli_mode_given_files_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_cwd(Mode::FromCli, helper.precious_root(), cwd)?; let expect = ["src/main.rs", "src/module.rs"] .iter() .map(PathBuf::from) .collect::>(); assert_eq!( finder.files(vec![PathBuf::from("main.rs"), PathBuf::from("module.rs")])?, Some(expect), ); Ok(()) } #[test] #[parallel] fn cli_mode_given_dir_with_excluded_files() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; let mut finder = new_finder_with_excludes( Mode::FromCli, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; assert_eq!( finder.files(vec![PathBuf::from(".")])?, Some(helper.all_files()), ); Ok(()) } #[test] #[parallel] fn cli_mode_given_dir_with_excluded_files_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_excludes( Mode::FromCli, helper.precious_root(), cwd, vec!["src/main.rs".to_string()], )?; let expect = [ "src/bar.rs", "src/can_ignore.rs", "src/module.rs", "src/sub/mod.rs", ] .iter() .map(PathBuf::from) .collect(); assert_eq!(finder.files(vec![PathBuf::from(".")])?, Some(expect)); Ok(()) } #[test] #[parallel] fn cli_mode_given_files_with_excluded_files() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("vendor/foo/bar.txt"), "initial content")?; let mut finder = new_finder_with_excludes( Mode::FromCli, helper.precious_root(), helper.precious_root(), vec!["vendor/**/*".to_string()], )?; let expect = vec![helper.all_files().pop().unwrap()]; let cli_paths = vec![ helper.all_files().pop().unwrap(), PathBuf::from("vendor/foo/bar.txt"), ]; assert_eq!(finder.files(cli_paths)?, Some(expect)); Ok(()) } #[test] #[parallel] fn cli_mode_given_files_with_excluded_files_in_subdir() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; helper.write_file(PathBuf::from("src/main.rs"), "initial content")?; let mut cwd = helper.precious_root(); cwd.push("src"); let mut finder = new_finder_with_excludes( Mode::FromCli, helper.precious_root(), cwd, vec!["src/main.rs".to_string()], )?; let expect = ["src/module.rs"].iter().map(PathBuf::from).collect(); let cli_paths = ["main.rs", "module.rs"].iter().map(PathBuf::from).collect(); assert_eq!(finder.files(cli_paths)?, Some(expect)); Ok(()) } #[test] #[parallel] fn cli_mode_given_files_with_nonexistent_path() -> Result<()> { let helper = testhelper::TestHelper::new()?.with_git_repo()?; let mut finder = new_finder(Mode::FromCli, helper.precious_root())?; let cli_paths = vec![ helper.all_files()[0].clone(), PathBuf::from("does/not/exist"), ]; let res = finder.files(cli_paths); assert!(res.is_err()); let err = res.unwrap_err(); assert_eq!( err.downcast_ref(), Some(&FinderError::NonExistentPathOnCli { path: PathBuf::from("does/not/exist") }) ); Ok(()) } } precious-0.7.3/precious-core/src/paths/matcher.rs000066400000000000000000000055301463371203000220320ustar00rootroot00000000000000use anyhow::Result; use ignore::gitignore::{Gitignore, GitignoreBuilder}; use std::path::Path; #[derive(Debug)] #[allow(clippy::module_name_repetitions)] pub struct MatcherBuilder { builder: GitignoreBuilder, } #[allow(clippy::new_without_default)] impl MatcherBuilder { pub fn new>(root: P) -> Self { Self { builder: GitignoreBuilder::new(root), } } pub fn with(mut self, globs: &[impl AsRef]) -> Result { for g in globs { self.builder.add_line(None, g.as_ref())?; } Ok(self) } pub fn build(self) -> Result { Ok(Matcher { gitignore: self.builder.build()?, }) } } #[derive(Debug)] pub struct Matcher { gitignore: Gitignore, } impl Matcher { pub fn path_matches(&self, path: &Path, is_dir: bool) -> bool { self.gitignore.matched(path, is_dir).is_ignore() } } #[cfg(test)] mod tests { use super::*; use serial_test::parallel; #[test] #[parallel] fn path_matches() -> Result<()> { struct TestSet { globs: &'static [&'static str], yes: &'static [&'static str], no: &'static [&'static str], } let tests = &[ TestSet { globs: &["*.foo"], yes: &["file.foo", "./file.foo"], no: &["file.bar", "./file.bar"], }, TestSet { globs: &["*.foo", "**/foo/*"], yes: &[ "file.foo", "/baz/bar/file.foo", "/contains/foo/any.txt", "./file.foo", "./baz/bar/file.foo", "./contains/foo/any.txt", ], no: &[ "file.bar", "/baz/bar/file.bar", "./file.bar", "./baz/bar/file.bar", ], }, TestSet { globs: &["/foo/**/*"], yes: &["/foo/file.go", "/foo/bar/baz/file.go"], no: &["/bar/file.go"], }, TestSet { globs: &["/foo/**/*", "!/foo/bar/baz.*"], yes: &["/foo/file.go", "/foo/bar/quux/file.go"], no: &["/bar/file.go", "/foo/bar/baz.txt"], }, ]; for t in tests { let globs = t.globs.join(" "); let m = MatcherBuilder::new("/").with(t.globs)?.build()?; for y in t.yes { assert!(m.path_matches(Path::new(y), false), "{y} matches [{globs}]"); } for n in t.no { assert!( !m.path_matches(Path::new(n), false), "{n} does not match [{globs}]", ); } } Ok(()) } } precious-0.7.3/precious-core/src/paths/mode.rs000066400000000000000000000015341463371203000213330ustar00rootroot00000000000000use std::fmt; #[derive(Clone, Debug, Eq, PartialEq)] pub enum Mode { FromCli, All, GitModified, GitStaged, GitStagedWithStash, GitDiffFrom(String), } impl fmt::Display for Mode { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Mode::FromCli => write!(f, "paths passed on the command line (recursively)"), Mode::All => write!(f, "all files in the project"), Mode::GitModified => write!(f, "modified files according to git"), Mode::GitStaged => write!(f, "files staged for a git commit"), Mode::GitStagedWithStash => write!( f, "files staged for a git commit, stashing unstaged content" ), Mode::GitDiffFrom(from) => write!(f, "files modified as compared to {from:}",), } } } precious-0.7.3/precious-core/src/precious.rs000066400000000000000000001241031463371203000211170ustar00rootroot00000000000000use crate::{ chars, command::{self, ActualInvoke, TidyOutcome}, config, config_init::{self, InitComponent}, paths::{self, finder::Finder}, vcs, }; use anyhow::{Error, Result}; use clap::{ArgGroup, Parser}; use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; use fern::{ colors::{Color, ColoredLevelConfig}, Dispatch, }; use itertools::Itertools; use log::{debug, error, info}; use rayon::{prelude::*, ThreadPool, ThreadPoolBuilder}; use std::{ env, fmt::Write, io::stdout, path::{Path, PathBuf}, time::{Duration, Instant}, }; use thiserror::Error; #[derive(Debug, Error)] enum PreciousError { #[error("No mode or paths were provided in the command line args")] NoModeOrPathsInCliArgs, #[error("The path given in --config, {}, has no parent directory", file.display())] ConfigFileHasNoParent { file: PathBuf }, #[error("Could not find a VCS checkout root starting from {cwd:}")] CannotFindRoot { cwd: String }, #[error("No {what:} commands defined in your config")] NoCommands { what: String }, #[error("No {what:} commands match the given command name, {name:}")] NoCommandsMatchCommandName { what: String, name: String }, #[error("No {what:} commands match the given label, {label:}")] NoCommandsMatchLabel { what: String, label: String }, } #[derive(Debug)] struct Exit { status: i8, message: Option, error: Option, } impl From for Exit { fn from(err: Error) -> Exit { Exit { status: 1, message: None, error: Some(err.to_string()), } } } #[derive(Debug)] struct ActionFailure { error: String, config_key: String, paths: Vec, } #[derive(Debug, Parser)] #[clap(name = "precious")] #[clap(author, version)] #[clap(propagate_version = true)] #[clap(subcommand_required = true, arg_required_else_help = true)] #[clap(max_term_width = 100)] #[allow(clippy::struct_excessive_bools)] /// One code quality tool to rule them all pub struct App { /// Path to the precious config file #[clap(long, short)] config: Option, /// Number of parallel jobs (threads) to run (defaults to one per core) #[clap(long, short, default_value_t = 0)] jobs: usize, /// Replace super-fun Unicode symbols with terribly boring ASCII #[clap(long, short)] ascii: bool, /// Suppresses most output #[clap(long, short)] quiet: bool, /// Enable verbose output #[clap(long, short)] verbose: bool, /// Enable debugging output #[clap(long, short)] debug: bool, /// Enable tracing output (maximum logging) #[clap(long, short)] trace: bool, #[clap(subcommand)] subcommand: Subcommand, } #[derive(Debug, Parser)] pub enum Subcommand { Lint(CommonArgs), #[clap(alias = "fix")] Tidy(CommonArgs), Config(ConfigArgs), } #[derive(Debug, Parser)] #[clap(group( ArgGroup::new("path-spec") .required(true) .args(&["all", "git", "staged", "git_diff_from", "staged_with_stash", "paths"]), ))] #[allow(clippy::struct_excessive_bools)] pub struct CommonArgs { /// The command to run. If specified, only this command will be run. This /// should match the command name in your config file. #[clap(long)] command: Option, /// Run against all files in the current directory and below #[clap(long, short)] all: bool, /// Run against files that have been modified according to git #[clap(long, short)] git: bool, /// Run against files that are staged for a git commit #[clap(long, short)] staged: bool, /// Run against files that are different as compared with the given /// ``. This can be a branch name, like `master`, or an ref name like /// `HEAD~6` or `master@{2.days.ago}`. See `git help rev-parse` for more /// options. Note that this will _not_ see files with uncommitted changes /// in the local working directory. #[clap(long, short = 'd', value_name = "REF")] git_diff_from: Option, /// Run against file content that is staged for a git commit, stashing all /// unstaged content first. The stash push/pop tends to do weird things to /// the working directory, and is not recommended for scripting. #[clap(long)] staged_with_stash: bool, /// If this is set, then only commands matching this label will be run. If /// this isn't set then commands without a label or with the label /// "default" will be run. #[clap(long)] label: Option, /// A list of paths on which to operate #[clap(value_parser)] paths: Vec, } #[derive(Debug, Parser)] pub struct ConfigArgs { #[clap(subcommand)] subcommand: ConfigSubcommand, } #[derive(Debug, Parser)] enum ConfigSubcommand { List, Init(ConfigInitArgs), } #[derive(Debug, Parser)] #[clap(group( ArgGroup::new("components") .required(true) .args(&["component", "auto"]), ))] pub struct ConfigInitArgs { #[clap(long, short, value_enum)] component: Vec, #[clap(long, short)] auto: bool, #[clap(long, short, default_value = "precious.toml")] path: PathBuf, } #[must_use] pub fn app() -> App { App::parse() } impl App { #[allow(clippy::missing_errors_doc)] pub fn init_logger(&self) -> Result<(), log::SetLoggerError> { let line_colors = ColoredLevelConfig::new() .error(Color::Red) .warn(Color::Yellow) .info(Color::BrightBlack) .debug(Color::BrightBlack) .trace(Color::BrightBlack); let level = if self.trace { log::LevelFilter::Trace } else if self.debug { log::LevelFilter::Debug } else if self.verbose { log::LevelFilter::Info } else { log::LevelFilter::Warn }; let level_colors = line_colors.info(Color::Green).debug(Color::Black); Dispatch::new() .format(move |out, message, record| { out.finish(format_args!( "{color_line}[{target}][{level}{color_line}] {message}\x1B[0m", color_line = format_args!( "\x1B[{}m", line_colors.get_color(&record.level()).to_fg_str() ), target = record.target(), level = level_colors.color(record.level()), message = message, )); }) .level(level) .level_for("globset", log::LevelFilter::Info) .chain(std::io::stderr()) .apply() } #[allow(clippy::missing_errors_doc)] pub fn run(self) -> Result { self._run(stdout()) } #[cfg(test)] fn run_with_output(self, output: impl std::io::Write) -> Result { self._run(output) } fn _run(self, output: impl std::io::Write) -> Result { if let Subcommand::Config(config_args) = &self.subcommand { if let ConfigSubcommand::Init(init_args) = &config_args.subcommand { config_init::write_config_files( init_args.auto, &init_args.component, &init_args.path, )?; return Ok(0); } } let (cwd, project_root, config_file, config) = self.load_config()?; match self.subcommand { Subcommand::Lint(_) | Subcommand::Tidy(_) => { Ok(LintOrTidyRunner::new(self, cwd, project_root, config)?.run()) } Subcommand::Config(args) => { match args.subcommand { ConfigSubcommand::List => { print_config(output, &config_file, config)?; } ConfigSubcommand::Init(_) => { unreachable!("This is handled earlier") } } Ok(0) } } } // This exists to make writing tests of the runner easier. #[cfg(test)] fn new_lint_or_tidy_runner(self) -> Result { let (cwd, project_root, _, config) = self.load_config()?; LintOrTidyRunner::new(self, cwd, project_root, config) } fn load_config(&self) -> Result<(PathBuf, PathBuf, PathBuf, config::Config)> { let cwd = env::current_dir()?; let project_root = project_root(self.config.as_deref(), &cwd)?; let config_file = self.config_file(&project_root); let config = config::Config::new(&config_file)?; Ok((cwd, project_root, config_file, config)) } fn config_file(&self, dir: &Path) -> PathBuf { if let Some(cf) = self.config.as_ref() { debug!("Loading config from {} (set via flag)", cf.display()); return cf.clone(); } let default = default_config_file(dir); debug!( "Loading config from {} (default location)", default.display() ); default } } fn project_root(config_file: Option<&Path>, cwd: &Path) -> Result { if let Some(file) = config_file { if let Some(p) = file.parent() { return Ok(p.to_path_buf()); } return Err(PreciousError::ConfigFileHasNoParent { file: file.to_path_buf(), } .into()); } if has_config_file(cwd) { return Ok(cwd.into()); } for ancestor in cwd.ancestors() { if is_checkout_root(ancestor) { return Ok(ancestor.to_owned()); } } Err(PreciousError::CannotFindRoot { cwd: cwd.to_string_lossy().to_string(), } .into()) } fn has_config_file(dir: &Path) -> bool { default_config_file(dir).exists() } const CONFIG_FILE_NAMES: &[&str] = &["precious.toml", ".precious.toml"]; fn default_config_file(dir: &Path) -> PathBuf { // It'd be nicer to use the version of this provided by itertools, but // that requires itertools 0.10.1, and we want to keep the version at // 0.9.0 for the benefit of Debian. find_or_first( CONFIG_FILE_NAMES.iter().map(|n| { let mut path = dir.to_path_buf(); path.push(n); path }), Path::exists, ) } fn find_or_first(mut iter: I, pred: P) -> PathBuf where I: Iterator, P: Fn(&Path) -> bool, { let first = iter.next().unwrap(); if pred(&first) { return first; } iter.find(|i| pred(i)).unwrap_or(first) } fn is_checkout_root(dir: &Path) -> bool { for subdir in vcs::DIRS { let mut poss = PathBuf::from(dir); poss.push(subdir); if poss.exists() { return true; } } false } fn print_config( mut output: impl std::io::Write, config_file: &Path, config: config::Config, ) -> Result<()> { writeln!(output, "Found config file at: {}", config_file.display())?; writeln!(output)?; let mut table = Table::new(); table .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic) .set_header(vec![ Cell::new("Name"), Cell::new("Type"), Cell::new("Runs"), ]); for (name, c) in config.command_info() { table.add_row(vec![ Cell::new(name), Cell::new(c.typ), Cell::new(c.cmd.join(" ")), ]); } writeln!(output, "{table}")?; Ok(()) } #[derive(Debug)] pub struct LintOrTidyRunner { mode: paths::mode::Mode, project_root: PathBuf, cwd: PathBuf, config: config::Config, command: Option, chars: chars::Chars, quiet: bool, thread_pool: ThreadPool, should_lint: bool, paths: Vec, label: Option, } impl LintOrTidyRunner { fn new( app: App, cwd: PathBuf, project_root: PathBuf, config: config::Config, ) -> Result { if log::log_enabled!(log::Level::Debug) { if let Some(path) = env::var_os("PATH") { debug!("PATH = {}", path.to_string_lossy()); } } let c = if app.ascii { chars::BORING_CHARS } else { chars::FUN_CHARS }; let mode = Self::mode(&app)?; let quiet = app.quiet; let jobs = app.jobs; let (should_lint, paths, command, label) = match app.subcommand { Subcommand::Lint(a) => (true, a.paths, a.command, a.label), Subcommand::Tidy(a) => (false, a.paths, a.command, a.label), Subcommand::Config(_) => unreachable!("this is handled in App::run"), }; Ok(LintOrTidyRunner { mode, project_root, cwd, config, command, chars: c, quiet, thread_pool: ThreadPoolBuilder::new().num_threads(jobs).build()?, should_lint, paths, label, }) } fn mode(app: &App) -> Result { let common = match &app.subcommand { Subcommand::Lint(c) | Subcommand::Tidy(c) => c, Subcommand::Config(_) => unreachable!("this is handled in App::run"), }; if common.all { return Ok(paths::mode::Mode::All); } else if common.git { return Ok(paths::mode::Mode::GitModified); } else if common.staged { return Ok(paths::mode::Mode::GitStaged); } else if let Some(from) = &common.git_diff_from { return Ok(paths::mode::Mode::GitDiffFrom(from.clone())); } else if common.staged_with_stash { return Ok(paths::mode::Mode::GitStagedWithStash); } if common.paths.is_empty() { return Err(PreciousError::NoModeOrPathsInCliArgs.into()); } Ok(paths::mode::Mode::FromCli) } fn run(&mut self) -> i8 { match self.run_subcommand() { Ok(e) => { debug!("{:?}", e); if let Some(err) = e.error { print!("{err}"); } if let Some(msg) = e.message { println!("{} {}", self.chars.empty, msg); } e.status } Err(e) => { error!("Failed to run precious: {}", e); 42 } } } fn run_subcommand(&mut self) -> Result { if self.should_lint { self.lint() } else { self.tidy() } } fn tidy(&mut self) -> Result { println!("{} Tidying {}", self.chars.ring, self.mode); let tidiers = self .config // XXX - This clone can be removed if config is passed into this // method instead of being a field of self. .clone() .into_tidy_commands( &self.project_root, self.command.as_deref(), self.label.as_deref(), )?; self.run_all_commands( "tidying", tidiers, |self_: &mut Self, files: &[PathBuf], tidier: &command::LintOrTidyCommand| { self_.run_one_tidier(files, tidier) }, ) } fn lint(&mut self) -> Result { println!("{} Linting {}", self.chars.ring, self.mode); let linters = self .config // XXX - same as above. .clone() .into_lint_commands( &self.project_root, self.command.as_deref(), self.label.as_deref(), )?; self.run_all_commands( "linting", linters, |self_: &mut Self, files: &[PathBuf], linter: &command::LintOrTidyCommand| { self_.run_one_linter(files, linter) }, ) } fn run_all_commands( &mut self, action: &str, commands: Vec, run_command: R, ) -> Result where R: Fn( &mut Self, &[PathBuf], &command::LintOrTidyCommand, ) -> Result>>, { if commands.is_empty() { if let Some(c) = &self.command { return Err(PreciousError::NoCommandsMatchCommandName { what: action.into(), name: c.into(), } .into()); } if let Some(l) = &self.label { return Err(PreciousError::NoCommandsMatchLabel { what: action.into(), label: l.into(), } .into()); } return Err(PreciousError::NoCommands { what: action.into(), } .into()); } let cli_paths = match self.mode { paths::mode::Mode::FromCli => self.paths.clone(), _ => vec![], }; match self.finder()?.files(cli_paths)? { None => Ok(Self::no_files_exit()), Some(files) => { let mut all_failures: Vec = vec![]; for c in commands { debug!(r#"Command config for {}: {}"#, c.name, c.config_debug()); if let Some(mut failures) = run_command(self, &files, &c)? { all_failures.append(&mut failures); } } Ok(self.make_exit(&all_failures, action)) } } } fn finder(&mut self) -> Result { Finder::new( self.mode.clone(), self.project_root.clone(), self.cwd.clone(), self.config.exclude.clone(), ) } fn make_exit(&self, failures: &[ActionFailure], action: &str) -> Exit { let (status, error) = if failures.is_empty() { (0, None) } else { let red = format!("\x1B[{}m", Color::Red.to_fg_str()); let ansi_off = "\x1B[0m"; let plural = if failures.len() > 1 { 's' } else { '\0' }; let error = format!( "{}Error{} when {} files:{}\n{}", red, plural, action, ansi_off, failures.iter().fold(String::new(), |mut out, af| { let _ = write!( out, " {} [{}] failed for [{}]\n {}\n", self.chars.bullet, af.config_key, af.paths.iter().map(|p| p.to_string_lossy()).join(" "), af.error, ); out }), ); (1, Some(error)) }; Exit { status, message: None, error, } } fn run_one_tidier( &mut self, files: &[PathBuf], t: &command::LintOrTidyCommand, ) -> Result>> { let runner = |s: &Self, actual_invoke: ActualInvoke, files: &[&Path]| -> Option> { match t.tidy(actual_invoke, files) { Ok(Some(TidyOutcome::Changed)) => { if !s.quiet { println!( "{} Tidied by {}: {}", s.chars.tidied, t.name, t.paths_summary(actual_invoke, files), ); } Some(Ok(())) } Ok(Some(TidyOutcome::Unchanged)) => { if !s.quiet { println!( "{} Unchanged by {}: {}", s.chars.unchanged, t.name, t.paths_summary(actual_invoke, files), ); } Some(Ok(())) } Ok(Some(TidyOutcome::Unknown)) => { if !s.quiet { println!( "{} Maybe changed by {}: {}", s.chars.unknown, t.name, t.paths_summary(actual_invoke, files), ); } Some(Ok(())) } Ok(None) => None, Err(e) => { println!( "{} Error from {}: {}", s.chars.execution_error, t.name, t.paths_summary(actual_invoke, files), ); Some(Err(ActionFailure { error: format!("{e:#}"), config_key: t.config_key(), paths: files.iter().map(|f| f.to_path_buf()).collect(), })) } } }; self.run_parallel("Tidying", files, t, runner) } fn run_one_linter( &mut self, files: &[PathBuf], l: &command::LintOrTidyCommand, ) -> Result>> { let runner = |s: &Self, actual_invoke: ActualInvoke, files: &[&Path]| -> Option> { match l.lint(actual_invoke, files) { Ok(Some(lo)) => { if lo.ok { if !s.quiet { println!( "{} Passed {}: {}", s.chars.lint_free, l.name, l.paths_summary(actual_invoke, files), ); } Some(Ok(())) } else { println!( "{} Failed {}: {}", s.chars.lint_dirty, l.name, l.paths_summary(actual_invoke, files), ); if let Some(s) = lo.stdout { println!("{s}"); } if let Some(s) = lo.stderr { println!("{s}"); } if let Ok(ga) = env::var("GITHUB_ACTIONS") { if !ga.is_empty() { if files.len() == 1 { println!( "::error file={}::Linting with {} failed", files[0].display(), l.name ); } else { println!("::error::Linting with {} failed", l.name); } } } Some(Err(ActionFailure { error: "linting failed".into(), config_key: l.config_key(), paths: files.iter().map(|f| f.to_path_buf()).collect(), })) } } Ok(None) => None, Err(e) => { println!( "{} error {}: {}", s.chars.execution_error, l.name, l.paths_summary(actual_invoke, files), ); Some(Err(ActionFailure { error: format!("{e:#}"), config_key: l.config_key(), paths: files.iter().map(|f| f.to_path_buf()).collect(), })) } } }; self.run_parallel("Linting", files, l, runner) } fn run_parallel( &mut self, what: &str, files: &[PathBuf], c: &command::LintOrTidyCommand, runner: R, ) -> Result>> where R: Fn(&Self, ActualInvoke, &[&Path]) -> Option> + Sync, { let (sets, actual_invoke) = c.files_to_args_sets(files)?; let start = Instant::now(); let results = self .thread_pool .install(|| -> Result>> { let mut res: Vec> = vec![]; res.append( &mut sets .into_par_iter() .filter_map(|set| runner(self, actual_invoke, &set)) .collect::>>(), ); Ok(res) })?; if !results.is_empty() { info!( "{} with {} on {} path{}, elapsed time = {}", what, c.name, results.len(), if results.len() > 1 { "s" } else { "" }, format_duration(&start.elapsed()) ); } let failures = results .into_iter() .filter_map(|r| match r { Ok(()) => None, Err(e) => Some(e), }) .collect::>(); if failures.is_empty() { Ok(None) } else { Ok(Some(failures)) } } fn no_files_exit() -> Exit { Exit { status: 0, message: Some(String::from("No files found")), error: None, } } } // I tried the humantime crate but it doesn't do what I want. It formats each // element separately ("1s 243ms 179us 984ns"), which is _way_ more detail // than I want for this. This algorithm will format to the most appropriate of: // // Xm Y.YYs // X.XXs // X.XXms // X.XXus // X.XXns #[allow( clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss )] fn format_duration(d: &Duration) -> String { let s = (d.as_secs_f64() * 100.0).round() / 100.0; if s >= 60.0 { let minutes = (s / 60.0).floor() as u64; let secs = s - (minutes as f64 * 60.0); return format!("{minutes}m {secs:.2}s"); } else if s >= 0.01 { return format!("{s:.2}s"); } let n = d.as_nanos(); if n > 1_000_000 { return format!("{:.2}ms", n as f64 / 1_000_000.0); } else if n > 1_000 { return format!("{:.2}us", n as f64 / 1_000.0); } format!("{n}ns") } #[cfg(test)] mod tests { use super::*; use itertools::Itertools; use precious_testhelper::TestHelper; use pretty_assertions::assert_eq; use pushd::Pushd; // Anything that does pushd must be run serially or else chaos ensues. use serial_test::serial; #[cfg(not(target_os = "windows"))] use std::str::FromStr; use std::{collections::HashMap, path::PathBuf}; use test_case::test_case; #[cfg(not(target_os = "windows"))] use which::which; const SIMPLE_CONFIG: &str = r#" [commands.rustfmt] type = "both" include = "**/*.rs" cmd = ["rustfmt"] lint-flags = "--check" ok-exit-codes = [0] lint-failure-exit-codes = [1] "#; const DEFAULT_CONFIG_FILE_NAME: &str = super::CONFIG_FILE_NAMES[0]; #[test] #[serial] fn new() -> Result<()> { for name in super::CONFIG_FILE_NAMES { let helper = TestHelper::new()?.with_config_file(name, SIMPLE_CONFIG)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "tidy", "--all"])?; let (_, project_root, config_file, _) = app.load_config()?; let mut expect_config_file = project_root; expect_config_file.push(name); assert_eq!(config_file, expect_config_file); } Ok(()) } #[test] #[serial] fn new_with_ascii_flag() -> Result<()> { let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "--ascii", "tidy", "--all"])?; let lt = app.new_lint_or_tidy_runner()?; assert_eq!(lt.chars, chars::BORING_CHARS); Ok(()) } #[test] #[serial] fn new_with_config_path() -> Result<()> { let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from([ "precious", "--config", helper .config_file(DEFAULT_CONFIG_FILE_NAME) .to_str() .unwrap(), "tidy", "--all", ])?; let (_, project_root, config_file, _) = app.load_config()?; let mut expect_config_file = project_root; expect_config_file.push(DEFAULT_CONFIG_FILE_NAME); assert_eq!(config_file, expect_config_file); Ok(()) } #[test] #[serial] fn set_root_prefers_config_file() -> Result<()> { let helper = TestHelper::new()?.with_git_repo()?; let mut src_dir = helper.precious_root(); src_dir.push("src"); let mut subdir_config = src_dir.clone(); subdir_config.push(DEFAULT_CONFIG_FILE_NAME); helper.write_file(&subdir_config, SIMPLE_CONFIG)?; let _pushd = Pushd::new(src_dir.clone())?; let app = App::try_parse_from(["precious", "--quiet", "tidy", "--all"])?; let lt = app.new_lint_or_tidy_runner()?; assert_eq!(lt.project_root, src_dir); Ok(()) } type FinderTestAction = Box Result<()>>; #[test_case( "--all", &[], Box::new(|_| Ok(())), &[ "README.md", "can_ignore.x", "merge-conflict-file", "precious.toml", "src/bar.rs", "src/can_ignore.rs", "src/main.rs", "src/module.rs", "src/sub/mod.rs", "tests/data/bar.txt", "tests/data/foo.txt", "tests/data/generated.txt", ] ; "--all" )] #[test_case( "--git", &[], Box::new(|th| { th.modify_files()?; Ok(()) }), &["src/module.rs", "tests/data/foo.txt"] ; "--git" )] #[test_case( "--staged", &[], Box::new(|th| { th.modify_files()?; th.stage_all()?; Ok(()) }), &["src/module.rs", "tests/data/foo.txt"] ; "--staged" )] #[test_case( "", &["main.rs", "module.rs"], Box::new(|_| Ok(())), &["src/main.rs", "src/module.rs"] ; "file paths from cli" )] #[test_case( "", &["."], Box::new(|_| Ok(())), &[ "src/bar.rs", "src/can_ignore.rs", "src/main.rs", "src/module.rs", "src/sub/mod.rs", ] ; "dir paths from cli" )] #[serial] fn finder_uses_project_root( flag: &str, paths: &[&str], action: FinderTestAction, expect: &[&str], ) -> Result<()> { let helper = TestHelper::new()? .with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)? .with_git_repo()?; (action)(&helper)?; let mut src_dir = helper.precious_root(); src_dir.push("src"); let _pushd = Pushd::new(src_dir)?; let mut cmd = vec!["precious", "--quiet", "tidy"]; if !flag.is_empty() { cmd.push(flag); } else { cmd.append(&mut paths.to_vec()); } let app = App::try_parse_from(&cmd)?; let mut lt = app.new_lint_or_tidy_runner()?; assert_eq!( lt.finder()? .files(paths.iter().map(PathBuf::from).collect())?, Some(expect.iter().map(PathBuf::from).collect::>()), "finder_uses_project_root: {} [{}]", if flag.is_empty() { "" } else { flag }, paths.join(" ") ); Ok(()) } #[test] #[serial] #[cfg(not(target_os = "windows"))] fn tidy_succeeds() -> Result<()> { let config = r#" [commands.precious] type = "tidy" include = "**/*" cmd = ["true"] ok-exit-codes = [0] "#; let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "--quiet", "tidy", "--all"])?; let mut lt = app.new_lint_or_tidy_runner()?; let status = lt.run(); assert_eq!(status, 0); Ok(()) } #[test] #[serial] #[cfg(not(target_os = "windows"))] fn tidy_fails() -> Result<()> { let config = r#" [commands.false] type = "tidy" include = "**/*" cmd = ["false"] ok-exit-codes = [0] "#; let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "--quiet", "tidy", "--all"])?; let mut lt = app.new_lint_or_tidy_runner()?; let status = lt.run(); assert_eq!(status, 1); Ok(()) } #[test] #[serial] #[cfg(not(target_os = "windows"))] fn lint_succeeds() -> Result<()> { let config = r#" [commands.true] type = "lint" include = "**/*" cmd = ["true"] ok-exit-codes = [0] lint-failure-exit-codes = [1] "#; let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "--quiet", "lint", "--all"])?; let mut lt = app.new_lint_or_tidy_runner()?; let status = lt.run(); assert_eq!(status, 0); Ok(()) } #[test] #[serial] fn one_command_given() -> Result<()> { let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from([ "precious", "--quiet", "lint", "--command", "rustfmt", "--all", ])?; let mut lt = app.new_lint_or_tidy_runner()?; let status = lt.run(); assert_eq!(status, 0); Ok(()) } #[test] #[serial] fn one_command_given_which_does_not_exist() -> Result<()> { let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from([ "precious", "--quiet", "lint", "--command", "no-such-command", "--all", ])?; let mut lt = app.new_lint_or_tidy_runner()?; let status = lt.run(); assert_eq!(status, 42); Ok(()) } #[test] #[serial] // This fails in CI on Windows with a confusing error - "Cannot complete // in-place edit of test.replace: Work file is missing - did you change // directory?" I don't know what this means, and it's not really important // to run this test on every OS. #[cfg(not(target_os = "windows"))] fn command_order_is_preserved_when_running() -> Result<()> { if which("perl").is_err() { println!("Skipping test since perl is not in path"); return Ok(()); } let config = r#" [commands.perl-replace-a-with-b] type = "tidy" include = "test.replace" cmd = ["perl", "-pi", "-e", "s/a/b/i"] ok-exit-codes = [0] [commands.perl-replace-a-with-c] type = "tidy" include = "test.replace" cmd = ["perl", "-pi", "-e", "s/a/c/i"] ok-exit-codes = [0] lint-failure-exit-codes = [1] [commands.perl-replace-a-with-d] type = "tidy" include = "test.replace" cmd = ["perl", "-pi", "-e", "s/a/d/i"] ok-exit-codes = [0] lint-failure-exit-codes = [1] "#; let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?; let test_replace = PathBuf::from_str("test.replace")?; helper.write_file(&test_replace, "The letter A")?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "--quiet", "tidy", "-a"])?; let status = app.run()?; assert_eq!(status, 0); let content = helper.read_file(test_replace.as_ref())?; assert_eq!(content, "The letter b".to_string()); Ok(()) } #[test] fn print_config() -> Result<()> { let config = r#" [commands.foo] type = "lint" include = "*.foo" cmd = ["foo", "--lint", "--with-vigor"] ok-exit-codes = [0] [commands.bar] type = "tidy" include = "*.bar" cmd = ["bar", "--fix-broken-things", "--aggressive"] ok-exit-codes = [0] [commands.baz] type = "both" include = "*.baz" cmd = ["baz", "--fast-mode", "--no-verify"] lint-flags = "--lint" ok-exit-codes = [0] "#; let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?; let _pushd = helper.pushd_to_git_root()?; let app = App::try_parse_from(["precious", "config", "list"])?; let mut buffer = Vec::new(); let status = app.run_with_output(&mut buffer)?; assert_eq!(status, 0); let output = String::from_utf8(buffer)?; let expect = format!( r#"Found config file at: {} ┌──────┬──────┬──────────────────────────────────────┐ │ Name ┆ Type ┆ Runs │ ╞══════╪══════╪══════════════════════════════════════╡ │ foo ┆ lint ┆ foo --lint --with-vigor │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ bar ┆ tidy ┆ bar --fix-broken-things --aggressive │ ├╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ │ baz ┆ both ┆ baz --fast-mode --no-verify │ └──────┴──────┴──────────────────────────────────────┘ "#, helper.config_file(DEFAULT_CONFIG_FILE_NAME).display(), ); assert_eq!(output, expect); Ok(()) } #[test] fn format_duration_output() { let mut tests: HashMap = HashMap::new(); tests.insert(Duration::new(0, 24), "24ns"); tests.insert(Duration::new(0, 124), "124ns"); tests.insert(Duration::new(0, 1_243), "1.24us"); tests.insert(Duration::new(0, 12_443), "12.44us"); tests.insert(Duration::new(0, 124_439), "124.44us"); tests.insert(Duration::new(0, 1_244_392), "1.24ms"); tests.insert(Duration::new(0, 12_443_924), "0.01s"); tests.insert(Duration::new(0, 124_439_246), "0.12s"); tests.insert(Duration::new(1, 1), "1.00s"); tests.insert(Duration::new(1, 12), "1.00s"); tests.insert(Duration::new(1, 124), "1.00s"); tests.insert(Duration::new(1, 1_243), "1.00s"); tests.insert(Duration::new(1, 12_443), "1.00s"); tests.insert(Duration::new(1, 124_439), "1.00s"); tests.insert(Duration::new(1, 1_244_392), "1.00s"); tests.insert(Duration::new(1, 12_443_926), "1.01s"); tests.insert(Duration::new(1, 124_439_267), "1.12s"); tests.insert(Duration::new(59, 1), "59.00s"); tests.insert(Duration::new(59, 1_000_000), "59.00s"); tests.insert(Duration::new(59, 10_000_000), "59.01s"); tests.insert(Duration::new(59, 90_000_000), "59.09s"); tests.insert(Duration::new(59, 99_999_999), "59.10s"); tests.insert(Duration::new(59, 100_000_000), "59.10s"); tests.insert(Duration::new(59, 900_000_000), "59.90s"); tests.insert(Duration::new(59, 990_000_000), "59.99s"); tests.insert(Duration::new(59, 999_000_000), "1m 0.00s"); tests.insert(Duration::new(59, 999_999_999), "1m 0.00s"); tests.insert(Duration::new(60, 0), "1m 0.00s"); tests.insert(Duration::new(60, 10_000_000), "1m 0.01s"); tests.insert(Duration::new(60, 100_000_000), "1m 0.10s"); tests.insert(Duration::new(60, 110_000_000), "1m 0.11s"); tests.insert(Duration::new(60, 990_000_000), "1m 0.99s"); tests.insert(Duration::new(60, 999_000_000), "1m 1.00s"); tests.insert(Duration::new(61, 10_000_000), "1m 1.01s"); tests.insert(Duration::new(61, 100_000_000), "1m 1.10s"); tests.insert(Duration::new(61, 120_000_000), "1m 1.12s"); tests.insert(Duration::new(61, 990_000_000), "1m 1.99s"); tests.insert(Duration::new(61, 999_000_000), "1m 2.00s"); tests.insert(Duration::new(120, 99_000_000), "2m 0.10s"); tests.insert(Duration::new(120, 530_000_000), "2m 0.53s"); tests.insert(Duration::new(120, 990_000_000), "2m 0.99s"); tests.insert(Duration::new(152, 240_123_456), "2m 32.24s"); for k in tests.keys().sorted() { let f = format_duration(k); let e = tests.get(k).unwrap().to_string(); assert_eq!(f, e, "{}s {}ns", k.as_secs(), k.as_nanos()); } } } precious-0.7.3/precious-core/src/vcs.rs000066400000000000000000000000641463371203000200600ustar00rootroot00000000000000pub const DIRS: &[&str] = &[".git", ".hg", ".svn"]; precious-0.7.3/precious-helpers/000077500000000000000000000000001463371203000166425ustar00rootroot00000000000000precious-0.7.3/precious-helpers/Cargo.toml000066400000000000000000000010201463371203000205630ustar00rootroot00000000000000[package] name = "precious-helpers" authors.workspace = true description = "A helper library for precious - not for external use" edition.workspace = true license.workspace = true readme.workspace = true repository.workspace = true version.workspace = true [dependencies] anyhow.workspace = true itertools.workspace = true log.workspace = true regex.workspace = true thiserror.workspace = true which.workspace = true [dev-dependencies] pretty_assertions.workspace = true serial_test.workspace = true tempfile.workspace = true precious-0.7.3/precious-helpers/src/000077500000000000000000000000001463371203000174315ustar00rootroot00000000000000precious-0.7.3/precious-helpers/src/exec.rs000066400000000000000000000403411463371203000207250ustar00rootroot00000000000000use anyhow::{Context, Result}; use itertools::Itertools; use log::{ Level::Debug, {debug, error, log_enabled}, }; use regex::Regex; use std::{collections::HashMap, env, fs, path::Path, process}; use thiserror::Error; use which::which; #[cfg(target_family = "unix")] use std::os::unix::prelude::*; #[derive(Debug, Error)] pub enum Error { #[error(r#"Could not find "{exe:}" in your path ({path:}"#)] ExecutableNotInPath { exe: String, path: String }, #[error( "Got unexpected exit code {code:} from `{cmd:}`.{}", exec_output_summary(stdout, stderr) )] UnexpectedExitCode { cmd: String, code: i32, stdout: String, stderr: String, }, #[error("Ran `{cmd:}` and it was killed by signal {signal:}")] ProcessKilledBySignal { cmd: String, signal: i32 }, #[error("Got unexpected stderr output from `{cmd:}` with exit code {code:}:\n{stderr:}")] UnexpectedStderr { cmd: String, code: i32, stderr: String, }, } fn exec_output_summary(stdout: &str, stderr: &str) -> String { let mut output = if stdout.is_empty() { String::from("\nStdout was empty.") } else { format!("\nStdout:\n{stdout}") }; if stderr.is_empty() { output.push_str("\nStderr was empty."); } else { output.push_str("\nStderr:\n"); output.push_str(stderr); }; output.push('\n'); output } #[derive(Debug)] pub struct Output { pub exit_code: i32, pub stdout: Option, pub stderr: Option, } #[allow(clippy::implicit_hasher, clippy::missing_errors_doc)] pub fn run( exe: &str, args: &[&str], env: &HashMap, ok_exit_codes: &[i32], ignore_stderr: Option<&[Regex]>, in_dir: Option<&Path>, ) -> Result { if which(exe).is_err() { let path = match env::var("PATH") { Ok(p) => p, Err(e) => format!(""), }; return Err(Error::ExecutableNotInPath { exe: exe.to_string(), path, } .into()); } let mut c = process::Command::new(exe); for a in args { c.arg(a); } // We are canonicalizing this primarily for the benefit of our debugging // output, because otherwise we might see the current dir as just `.`, // which is not helpful. let cwd = if let Some(d) = in_dir { fs::canonicalize(d)? } else { fs::canonicalize(env::current_dir()?)? }; c.current_dir(cwd.clone()); c.envs(env); if log_enabled!(Debug) { debug!( "Running command [{}] with cwd = {}", exec_string(exe, args), cwd.display() ); for k in env.keys().sorted() { debug!( r#" with env: {k} = "{}""#, env.get(k).unwrap_or(&"".to_string()), ); } } let output = output_from_command(c, ok_exit_codes, exe, args) .with_context(|| format!(r#"Failed to execute command `{}`"#, exec_string(exe, args)))?; if log_enabled!(Debug) && !output.stdout.is_empty() { debug!("Stdout was:\n{}", String::from_utf8(output.stdout.clone())?); } let code = output.status.code().unwrap_or(-1); if !output.stderr.is_empty() { let stderr = String::from_utf8(output.stderr.clone())?; if log_enabled!(Debug) { debug!("Stderr was:\n{stderr}"); } let ok = if let Some(ignore) = ignore_stderr { ignore.iter().any(|i| i.is_match(&stderr)) } else { false }; if !ok { return Err(Error::UnexpectedStderr { cmd: exec_string(exe, args), code, stderr, } .into()); } } Ok(Output { exit_code: code, stdout: to_option_string(&output.stdout), stderr: to_option_string(&output.stderr), }) } fn output_from_command( mut c: process::Command, ok_exit_codes: &[i32], exe: &str, args: &[&str], ) -> Result { let output = c.output()?; if let Some(code) = output.status.code() { let estr = exec_string(exe, args); debug!("Ran {} and got exit code of {}", estr, code); if !ok_exit_codes.contains(&code) { return Err(Error::UnexpectedExitCode { cmd: estr, code, stdout: String::from_utf8(output.stdout)?, stderr: String::from_utf8(output.stderr)?, } .into()); } } else { let estr = exec_string(exe, args); if output.status.success() { error!("Ran {} successfully but it had no exit code", estr); } else { let signal = signal_from_status(output.status); debug!("Ran {} which exited because of signal {}", estr, signal); return Err(Error::ProcessKilledBySignal { cmd: estr, signal }.into()); } } Ok(output) } fn exec_string(exe: &str, args: &[&str]) -> String { let mut estr = exe.to_string(); if !args.is_empty() { estr.push(' '); estr.push_str(args.join(" ").as_str()); } estr } fn to_option_string(v: &[u8]) -> Option { if v.is_empty() { None } else { Some(String::from_utf8_lossy(v).into_owned()) } } #[cfg(target_family = "unix")] fn signal_from_status(status: process::ExitStatus) -> i32 { status.signal().unwrap_or(0) } #[cfg(target_family = "windows")] fn signal_from_status(_: process::ExitStatus) -> i32 { 0 } #[cfg(test)] mod tests { use super::Error; use anyhow::{format_err, Result}; use pretty_assertions::assert_eq; use regex::Regex; // Anything that does pushd must be run serially or else chaos ensues. use serial_test::{parallel, serial}; use std::{ collections::HashMap, env, fs, path::{Path, PathBuf}, }; use tempfile::tempdir; #[test] #[parallel] fn exec_string() { assert_eq!( super::exec_string("foo", &[]), String::from("foo"), "command without args", ); assert_eq!( super::exec_string("foo", &["bar"],), String::from("foo bar"), "command with one arg" ); assert_eq!( super::exec_string("foo", &["--bar", "baz"],), String::from("foo --bar baz"), "command with multiple args", ); } #[test] #[parallel] fn run_exit_0() -> Result<()> { let res = super::run("echo", &["foo"], &HashMap::new(), &[0], None, None)?; assert_eq!(res.exit_code, 0, "process exits 0"); Ok(()) } #[test] #[parallel] fn run_exit_0_with_unexpected_stderr() -> Result<()> { let args = ["-c", "echo 'some stderr output' 1>&2"]; let res = super::run("sh", &args, &HashMap::new(), &[0], None, None); assert!(res.is_err(), "run returned Err"); match error_from_run(res)? { Error::UnexpectedStderr { cmd: _, code, stderr, } => { assert_eq!(code, 0, "process exited 0"); assert_eq!(stderr, "some stderr output\n", "process had no stderr"); } e => return Err(e.into()), } Ok(()) } #[test] #[parallel] fn run_exit_0_with_matching_ignore_stderr() -> Result<()> { let args = ["-c", "echo 'some stderr output' 1>&2"]; let res = super::run( "sh", &args, &HashMap::new(), &[0], Some(&[Regex::new("some.+output").unwrap()]), None, )?; assert_eq!(res.exit_code, 0, "process exits 0"); assert!(res.stdout.is_none(), "process has no stdout output"); assert_eq!( res.stderr.unwrap(), "some stderr output\n", "process has stderr output", ); Ok(()) } #[test] #[parallel] fn run_exit_0_with_non_matching_ignore_stderr() -> Result<()> { let args = ["-c", "echo 'some stderr output' 1>&2"]; let res = super::run( "sh", &args, &HashMap::new(), &[0], Some(&[Regex::new("some.+output is ok").unwrap()]), None, ); assert!(res.is_err(), "run returned Err"); match error_from_run(res)? { Error::UnexpectedStderr { cmd: _, code, stderr, } => { assert_eq!(code, 0, "process exited 0"); assert_eq!(stderr, "some stderr output\n", "process had no stderr"); } e => return Err(e.into()), } Ok(()) } #[test] #[parallel] fn run_exit_0_with_multiple_ignore_stderr() -> Result<()> { let args = ["-c", "echo 'some stderr output' 1>&2"]; let res = super::run( "sh", &args, &HashMap::new(), &[0], Some(&[ Regex::new("will not match").unwrap(), Regex::new("some.+output is ok").unwrap(), ]), None, ); assert!(res.is_err(), "run returned Err"); match error_from_run(res)? { Error::UnexpectedStderr { cmd: _, code, stderr, } => { assert_eq!(code, 0, "process exited 0"); assert_eq!(stderr, "some stderr output\n", "process had no stderr"); } e => return Err(e.into()), } Ok(()) } #[test] #[parallel] fn run_with_env() -> Result<()> { let env_key = "PRECIOUS_ENV_TEST"; let mut env = HashMap::new(); env.insert(String::from(env_key), String::from("foo")); let res = super::run( "sh", &["-c", &format!("echo ${env_key}")], &env, &[0], None, None, )?; assert_eq!(res.exit_code, 0, "process exits 0"); assert!(res.stdout.is_some(), "process has stdout output"); assert_eq!( res.stdout.unwrap(), String::from("foo\n"), "{} env var was set when process was run", env_key, ); let val = env::var(env_key); assert_eq!( val.err().unwrap(), std::env::VarError::NotPresent, "{} env var is not set after process was run", env_key, ); Ok(()) } #[test] #[parallel] fn run_exit_32() -> Result<()> { let res = super::run("sh", &["-c", "exit 32"], &HashMap::new(), &[0], None, None); assert!(res.is_err(), "process exits non-zero"); match error_from_run(res)? { Error::UnexpectedExitCode { cmd: _, code, stdout, stderr, } => { assert_eq!(code, 32, "process unexpectedly exits 32"); assert_eq!(stdout, "", "process had no stdout"); assert_eq!(stderr, "", "process had no stderr"); } e => return Err(e.into()), } Ok(()) } #[test] #[parallel] fn run_exit_32_with_stdout() -> Result<()> { let res = super::run( "sh", &["-c", r#"echo "STDOUT" && exit 32"#], &HashMap::new(), &[0], None, None, ); assert!(res.is_err(), "process exits non-zero"); let e = error_from_run(res)?; let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && exit 32`. Stdout: STDOUT Stderr was empty. "#; assert_eq!(format!("{e}"), expect, "error display output"); match e { Error::UnexpectedExitCode { cmd: _, code, stdout, stderr, } => { assert_eq!(code, 32, "process unexpectedly exits 32"); assert_eq!(stdout, "STDOUT\n", "stdout was captured"); assert_eq!(stderr, "", "stderr was empty"); } e => return Err(e.into()), } Ok(()) } #[test] #[parallel] fn run_exit_32_with_stderr() -> Result<()> { let res = super::run( "sh", &["-c", r#"echo "STDERR" 1>&2 && exit 32"#], &HashMap::new(), &[0], None, None, ); assert!(res.is_err(), "process exits non-zero"); let e = error_from_run(res)?; let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDERR" 1>&2 && exit 32`. Stdout was empty. Stderr: STDERR "#; assert_eq!(format!("{e}"), expect, "error display output"); match e { Error::UnexpectedExitCode { cmd: _, code, stdout, stderr, } => { assert_eq!( code, 32, "process unexpectedly exits 32" ); assert_eq!(stdout, "", "stdout was empty"); assert_eq!(stderr, "STDERR\n", "stderr was captured"); } e => return Err(e.into()), } Ok(()) } #[test] #[parallel] fn run_exit_32_with_stdout_and_stderr() -> Result<()> { let res = super::run( "sh", &["-c", r#"echo "STDOUT" && echo "STDERR" 1>&2 && exit 32"#], &HashMap::new(), &[0], None, None, ); assert!(res.is_err(), "process exits non-zero"); let e = error_from_run(res)?; let expect = r#"Got unexpected exit code 32 from `sh -c echo "STDOUT" && echo "STDERR" 1>&2 && exit 32`. Stdout: STDOUT Stderr: STDERR "#; assert_eq!(format!("{e}"), expect, "error display output"); match e { Error::UnexpectedExitCode { cmd: _, code, stdout, stderr, } => { assert_eq!(code, 32, "process unexpectedly exits 32"); assert_eq!(stdout, "STDOUT\n", "stdout was captured"); assert_eq!(stderr, "STDERR\n", "stderr was captured"); } e => return Err(e.into()), } Ok(()) } fn error_from_run(result: Result) -> Result { match result { Ok(_) => Err(format_err!("did not get an error in the returned Result")), Err(e) => e.downcast::(), } } #[test] #[serial] fn run_in_dir() -> Result<()> { // On windows the path we get from `pwd` is a Windows path (C:\...) // but `td.path()` contains a Unix path (/tmp/...). Very confusing. if cfg!(windows) { return Ok(()); } let td = tempdir()?; let td_path = maybe_canonicalize(td.path())?; let res = super::run("pwd", &[], &HashMap::new(), &[0], None, Some(&td_path))?; assert_eq!(res.exit_code, 0, "process exits 0"); assert!(res.stdout.is_some(), "process produced stdout output"); let stdout = res.stdout.unwrap(); let stdout_trimmed = stdout.trim_end(); assert_eq!( stdout_trimmed, td_path.to_string_lossy(), "process runs in another dir", ); Ok(()) } #[test] #[parallel] fn executable_does_not_exist() { let exe = "I hope this binary does not exist on any system!"; let args = ["--arg", "42"]; let res = super::run(exe, &args, &HashMap::new(), &[0], None, None); assert!(res.is_err()); if let Err(e) = res { assert!(e.to_string().contains( r#"Could not find "I hope this binary does not exist on any system!" in your path"#, )); } } // The temp directory on macOS in GitHub Actions appears to be a symlink, but // canonicalizing on Windows breaks tests for some reason. pub fn maybe_canonicalize(path: &Path) -> Result { if cfg!(windows) { return Ok(path.to_owned()); } Ok(fs::canonicalize(path)?) } } precious-0.7.3/precious-helpers/src/lib.rs000066400000000000000000000000161463371203000205420ustar00rootroot00000000000000pub mod exec; precious-0.7.3/precious-integration/000077500000000000000000000000001463371203000175235ustar00rootroot00000000000000precious-0.7.3/precious-integration/Cargo.toml000066400000000000000000000012051463371203000214510ustar00rootroot00000000000000[package] name = "precious-integration" authors.workspace = true description = "Integration tests for precious - not for external use" edition.workspace = true license.workspace = true readme.workspace = true repository.workspace = true version.workspace = true [dev-dependencies] anyhow.workspace = true itertools.workspace = true regex.workspace = true precious-core.workspace = true precious-helpers.workspace = true precious-testhelper.workspace = true pretty_assertions.workspace = true pushd.workspace = true serial_test.workspace = true tempfile.workspace = true [[test]] name = "integration_tests" path = "src/tests.rs" harness = true precious-0.7.3/precious-integration/src/000077500000000000000000000000001463371203000203125ustar00rootroot00000000000000precious-0.7.3/precious-integration/src/config_init.rs000066400000000000000000000127231463371203000231550ustar00rootroot00000000000000use crate::shared::{compile_precious, precious_path}; use anyhow::Result; use precious_helpers::exec::{self, Output}; use pushd::Pushd; use regex::Regex; use serial_test::serial; #[cfg(target_family = "unix")] use std::os::unix::fs::PermissionsExt; use std::{ collections::HashMap, fs::{self, File}, path::Path, }; use tempfile::TempDir; #[test] #[serial] fn init_go() -> Result<()> { compile_precious()?; let (_td, _pd) = chdir_to_tempdir()?; let output = init_with_components(&["go"], None)?; assert_eq!(output.exit_code, 0); assert!(output.stderr.is_none()); assert_file_exists("precious.toml")?; assert_file_contains("precious.toml", &["golangci-lint", "check-go-mod.sh"])?; assert_file_exists(".golangci.yml")?; assert_file_contains( ".golangci.yml", &["gofumpt", "govet", "check-type-assertions"], )?; assert_file_exists("dev/bin/check-go-mod.sh")?; #[cfg(target_family = "unix")] assert_file_is_executable("dev/bin/check-go-mod.sh")?; let stdout = output.stdout.unwrap(); assert!(stdout.contains("dev/bin/check-go-mod.sh")); assert!(stdout.contains("https://golangci-lint.run")); Ok(()) } #[test] #[serial] fn init_rust() -> Result<()> { compile_precious()?; let (_td, _pd) = chdir_to_tempdir()?; let output = init_with_components(&["rust"], None)?; assert_eq!(output.exit_code, 0); assert!(output.stderr.is_none()); assert_file_exists("precious.toml")?; assert_file_contains("precious.toml", &["clippy", "rustfmt"])?; let stdout = output.stdout.unwrap(); assert!(stdout.contains("clippy")); Ok(()) } #[test] #[serial] fn init_perl() -> Result<()> { compile_precious()?; let (_td, _pd) = chdir_to_tempdir()?; let output = init_with_components(&["perl"], None)?; assert_eq!(output.exit_code, 0); assert!(output.stderr.is_none()); assert_file_exists("precious.toml")?; assert_file_contains("precious.toml", &["perlcritic", "perlimports", "perltidy"])?; let stdout = output.stdout.unwrap(); assert!(stdout.contains("App-perlimports")); Ok(()) } #[test] #[serial] fn init_does_not_overwrite_existing_file() -> Result<()> { compile_precious()?; let (_td, _pd) = chdir_to_tempdir()?; File::create("precious.toml")?; let output = init_with_components(&["rust"], None)?; assert_eq!(output.exit_code, 42); assert!(output.stderr.is_some()); assert!(output .stderr .unwrap() .contains("A file already exists at the given path: precious.toml")); Ok(()) } #[test] #[serial] fn init_does_not_overwrite_existing_file_with_nonstandard_name() -> Result<()> { compile_precious()?; let (_td, _pd) = chdir_to_tempdir()?; File::create("my-precious.toml")?; let output = init_with_components(&["rust"], Some("my-precious.toml"))?; assert_eq!(output.exit_code, 42); assert!(output.stderr.is_some()); assert!(output .stderr .unwrap() .contains("A file already exists at the given path: my-precious.toml")); Ok(()) } #[test] #[serial] fn init_auto() -> Result<()> { compile_precious()?; let (_td, _pd) = chdir_to_tempdir()?; for path in ["src/foo.rs", "README.md", ".github/workflows/ci.yml"] .iter() .map(Path::new) { fs::create_dir_all(path.parent().unwrap())?; File::create(path)?; } let output = init_with_auto()?; assert_eq!(output.exit_code, 0); assert_file_exists("precious.toml")?; assert_file_contains("precious.toml", &["clippy", "prettier"])?; let stdout = output.stdout.unwrap(); assert!(stdout.contains("clippy")); assert!(stdout.contains("prettier")); Ok(()) } fn chdir_to_tempdir() -> Result<(TempDir, Pushd)> { let td = tempfile::Builder::new() .prefix("precious-integration-") .tempdir()?; let pd = Pushd::new(td.path())?; Ok((td, pd)) } fn init_with_components(components: &[&str], init_path: Option<&str>) -> Result { let precious = precious_path()?; let env = HashMap::new(); let mut args = vec!["config", "init"]; for c in components { args.push("--component"); args.push(c); } if let Some(p) = init_path { args.push("--path"); args.push(p); } exec::run( &precious, &args, &env, &[0, 42], Some(&[Regex::new(".*")?]), None, ) } fn init_with_auto() -> Result { let precious = precious_path()?; let env = HashMap::new(); exec::run( &precious, &["config", "init", "--auto"], &env, &[0, 42], Some(&[Regex::new(".*")?]), None, ) } fn assert_file_exists(path: impl AsRef) -> Result<()> { let path = path.as_ref(); assert!(path.exists(), "file {:?} does not exist", path); Ok(()) } fn assert_file_contains(path: impl AsRef, contains: &[&str]) -> Result<()> { let path = path.as_ref(); let contents = std::fs::read_to_string(path)?; for c in contains { assert!( contents.contains(c), "file {:?} does not contain {:?}:\n{contents}", path, c, ); } Ok(()) } #[cfg(target_family = "unix")] fn assert_file_is_executable(path: impl AsRef) -> Result<()> { let path = path.as_ref(); let perms = path.metadata()?.permissions(); assert!( perms.mode() & 0o111 != 0, "file {:?} is not executable", path, ); Ok(()) } precious-0.7.3/precious-integration/src/lint_tidy.rs000066400000000000000000000374111463371203000226650ustar00rootroot00000000000000use crate::shared::{compile_precious, precious_path}; use anyhow::{Context, Result}; use itertools::Itertools; use precious_helpers::exec; use precious_testhelper::TestHelper; use pretty_assertions::{assert_eq, assert_str_eq}; use regex::{Captures, Regex}; use serial_test::serial; use std::{collections::HashMap, env, fs, path::PathBuf}; const CONFIG: &str = r#" exclude = [ "target", ] [commands.rustfmt] type = "both" include = "**/*.rs" cmd = [ "rustfmt", "--edition", "2021" ] lint-flags = "--check" ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.true] type = "lint" include = "**/*.rs" cmd = [ "true" ] ok-exit-codes = 0 lint-failure-exit-codes = 1 [commands.stderr] type = "lint" include = "**/*.rs" cmd = [ "sh", "-c", "echo 'some stderr output' 1>&2" ] ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = "some.+output" "#; const GOOD_RUST: &str = r#" fn good_func() { let a = 1 + 2; println!("a = {}", a); } "#; #[test] #[serial] fn all() -> Result<()> { let helper = set_up_for_tests()?; let precious = precious_path()?; let env = HashMap::new(); exec::run( &precious, &["lint", "--all"], &env, &[0], None, Some(&helper.precious_root()), )?; exec::run( &precious, &["tidy", "--all"], &env, &[0], None, Some(&helper.precious_root()), )?; Ok(()) } #[test] #[serial] fn git() -> Result<()> { let helper = set_up_for_tests()?; helper.modify_files()?; let precious = precious_path()?; let env = HashMap::new(); exec::run( &precious, &["lint", "--git"], &env, &[0], None, Some(&helper.precious_root()), )?; exec::run( &precious, &["tidy", "--git"], &env, &[0], None, Some(&helper.precious_root()), )?; Ok(()) } #[test] #[serial] fn staged() -> Result<()> { let helper = set_up_for_tests()?; helper.modify_files()?; helper.stage_all()?; let precious = precious_path()?; let env = HashMap::new(); exec::run( &precious, &["lint", "--staged"], &env, &[0], None, Some(&helper.precious_root()), )?; exec::run( &precious, &["tidy", "--staged"], &env, &[0], None, Some(&helper.precious_root()), )?; Ok(()) } #[test] #[serial] fn cli_paths() -> Result<()> { let helper = set_up_for_tests()?; let files = helper.modify_files()?; let precious = precious_path()?; let env = HashMap::new(); let mut args = vec!["lint"]; args.append(&mut files.iter().map(|p| p.to_str().unwrap()).collect()); exec::run( &precious, &args, &env, &[0], None, Some(&helper.precious_root()), )?; let mut args = vec!["tidy"]; args.append(&mut files.iter().map(|p| p.to_str().unwrap()).collect()); exec::run( &precious, &args, &env, &[0], None, Some(&helper.precious_root()), )?; Ok(()) } #[test] #[serial] fn all_in_subdir() -> Result<()> { let helper = set_up_for_tests()?; let precious = precious_path()?; let env = HashMap::new(); let mut cwd = helper.precious_root(); cwd.push("src"); exec::run(&precious, &["lint", "--all"], &env, &[0], None, Some(&cwd))?; exec::run(&precious, &["tidy", "--all"], &env, &[0], None, Some(&cwd))?; Ok(()) } #[test] #[serial] fn git_in_subdir() -> Result<()> { let helper = set_up_for_tests()?; helper.modify_files()?; let precious = precious_path()?; let env = HashMap::new(); let mut cwd = helper.precious_root(); cwd.push("src"); exec::run(&precious, &["lint", "--git"], &env, &[0], None, Some(&cwd))?; exec::run(&precious, &["tidy", "--git"], &env, &[0], None, Some(&cwd))?; Ok(()) } #[test] #[serial] fn staged_in_subdir() -> Result<()> { let helper = set_up_for_tests()?; helper.modify_files()?; helper.stage_all()?; let precious = precious_path()?; let env = HashMap::new(); let mut cwd = helper.precious_root(); cwd.push("src"); exec::run( &precious, &["lint", "--staged"], &env, &[0], None, Some(&cwd), )?; exec::run( &precious, &["tidy", "--staged"], &env, &[0], None, Some(&cwd), )?; Ok(()) } #[test] #[serial] fn cli_paths_in_subdir() -> Result<()> { let helper = set_up_for_tests()?; helper.modify_files()?; let precious = precious_path()?; let env = HashMap::new(); let mut cwd = helper.precious_root(); cwd.push("src"); exec::run( &precious, &["lint", "module.rs", "../README.md", "../tests/data/foo.txt"], &env, &[0], None, Some(&cwd), )?; exec::run( &precious, &["tidy", "module.rs", "../README.md", "../tests/data/foo.txt"], &env, &[0], None, Some(&cwd), )?; Ok(()) } #[test] #[serial] fn one_command() -> Result<()> { let helper = set_up_for_tests()?; let content = r#" fn foo() -> u8 { 42 } "#; helper.write_file("src/module.rs", content)?; let precious = precious_path()?; let env = HashMap::new(); let mut cwd = helper.precious_root(); cwd.push("src"); // This succeeds because we're not checking with rustfmt. exec::run( &precious, &["lint", "--command", "true", "module.rs"], &env, &[0], None, Some(&cwd), )?; // This fails now that we check with rustfmt. exec::run( &precious, &["lint", "module.rs"], &env, &[1], None, Some(&cwd), )?; Ok(()) } #[test] #[serial] fn exit_codes() -> Result<()> { let helper = set_up_for_tests()?; let all_codes = Vec::from_iter(0..=255); let match_all_re = Regex::new(".*")?; let precious = precious_path()?; let env = HashMap::new(); let out = exec::run( &precious, &["lint", "--all"], &env, &all_codes, Some(&[match_all_re.clone()]), Some(&helper.precious_root()), )?; assert_eq!(out.exit_code, 0); helper.write_file("src/good.rs", "this is not valid rust")?; let out = exec::run( &precious, &["lint", "--all"], &env, &all_codes, Some(&[match_all_re.clone()]), Some(&helper.precious_root()), )?; assert_eq!(out.exit_code, 1); let out = exec::run( &precious, &["foo", "--all"], &env, &all_codes, Some(&[match_all_re.clone()]), Some(&helper.precious_root()), )?; assert_eq!(out.exit_code, 2); let out = exec::run( &precious, &["lint", "--foo"], &env, &all_codes, Some(&[match_all_re.clone()]), Some(&helper.precious_root()), )?; assert_eq!(out.exit_code, 2); helper.write_file("precious.toml", "this is not valid config")?; let out = exec::run( &precious, &["lint", "--all"], &env, &all_codes, Some(&[match_all_re.clone()]), Some(&helper.precious_root()), )?; assert_eq!(out.exit_code, 42); let config_missing_key = r#" [commands.rustfmt] type = "both" include = "**/*.rs" cmd = [ "rustfmt", "--edition", "2021" ] ok-exit-codes = 0 lint-failure-exit-codes = 1 "#; helper.write_file("precious.toml", config_missing_key)?; let out = exec::run( &precious, &["lint", "--all"], &env, &all_codes, Some(&[match_all_re.clone()]), Some(&helper.precious_root()), )?; assert_eq!(out.exit_code, 42); Ok(()) } #[test] #[serial] fn all_invocation_options() -> Result<()> { let helper = set_up_for_tests()?; write_perl_script(&helper)?; create_file_tree(&helper)?; let docs = fs::read_to_string(PathBuf::from("../docs/invocation-examples.md"))?.replace("\r\n", "\n"); let docs_re = Regex::new( r"(?xsm) ```toml\n \[commands\.some-linter\]\n (?P.+?) ``` \n+ ```\n (?P.+?) ``` ", )?; let mut count = 0; for caps in docs_re.captures_iter(&docs) { let config = &caps["config"]; match run_one_invocation_test(&helper, config, &caps["output"]) { Ok(..) => (), Err(e) => { eprintln!("Error from this config:\n{config}"); return Err(e); } } count += 1; } const EXPECT_COUNT: u8 = 28; assert_eq!(count, EXPECT_COUNT, "tested {EXPECT_COUNT} examples"); Ok(()) } #[test] #[serial] fn fix_is_tidy() -> Result<()> { let helper = set_up_for_tests()?; let precious = precious_path()?; let env = HashMap::new(); exec::run( &precious, &["fix", "--all"], &env, &[0], None, Some(&helper.precious_root()), )?; Ok(()) } // Since precious runs the linter in parallel on different files we to force // the execution to be serialized. On Linux we can use the flock command but // that doesn't exist on macOS so we'll use this Perl script instead. fn write_perl_script(helper: &TestHelper) -> Result<()> { let script = r#" use strict; use warnings; use Cwd qw( abs_path ); use File::Spec; my $output_dir = $ENV{PRECIOUS_INTEGRATION_TEST_OUTPUT_DIR} or die "The PRECIOUS_INTEGRATION_TEST_OUTPUT_DIR env var is not set"; my $test_root = $ENV{PRECIOUS_INTEGRATION_TEST_ROOT} or die "The PRECIOUS_INTEGRATION_TEST_ROOT env var is not set"; my $output_file = File::Spec->catfile($output_dir, "invocation.$$"); open my $output_fh, '>>', $output_file or die "Cannot open $output_file: $!"; my $cwd = abs_path('.'); print {$output_fh} <<"EOF" or die "Cannot write to $output_file: $!"; ---- cwd = $cwd some-linter @ARGV EOF close $output_fh or die "Cannot close $output_file: $!"; "#; let mut script_file = helper.precious_root(); script_file.push("some-linter"); fs::write(&script_file, script)?; #[cfg(not(windows))] { use std::os::unix::fs::PermissionsExt; let mut perms = script_file.metadata()?.permissions(); perms.set_mode(0o0755); fs::set_permissions(&script_file, perms)?; } Ok(()) } // example // ├── app.go // ├── main.go // ├── pkg1 // │ ├── pkg1.go // ├── pkg2 // │ ├── pkg2.go // │ ├── pkg2_test.go // │ └── subpkg // │ └── subpkg.go fn create_file_tree(helper: &TestHelper) -> Result<()> { let root = helper.precious_root(); for path in [ "app.go", "main.go", "pkg1/pkg1.go", "pkg2/pkg2.go", "pkg2/pkg2_test.go", "pkg2/subpkg/subpkg.go", ] { let mut file = root.clone(); file.push(path); fs::create_dir_all(file.parent().unwrap())?; fs::write(&file, "x")?; } Ok(()) } fn run_one_invocation_test(helper: &TestHelper, config: &str, expect: &str) -> Result<()> { let mut precious_toml = helper.precious_root(); precious_toml.push("precious.toml"); let precious = precious_path()?; let full_config = format!( r#" [commands.some-linter] type = "lint" include = "**/*.go" cmd = [ "perl", "$PRECIOUS_ROOT/some-linter" ] ok-exit-codes = 0 {config} "# ); if cfg!(windows) { fs::write(&precious_toml, full_config.replace('\n', "\r\n"))?; } else { fs::write(&precious_toml, full_config)?; } let td = tempfile::Builder::new() .prefix("precious-integration-") .tempdir()?; let td_path = td.path().to_path_buf(); let (_output_dir, _preserved_tempdir) = match env::var("PRECIOUS_TESTS_PRESERVE_TEMPDIR") { Ok(v) if !(v.is_empty() || v == "0") => (None, Some(td.into_path())), _ => (Some(td), None), }; let env = HashMap::from([ ( String::from("PRECIOUS_INTEGRATION_TEST_OUTPUT_DIR"), td_path.to_string_lossy().to_string(), ), ( String::from("PRECIOUS_INTEGRATION_TEST_ROOT"), helper.precious_root().to_string_lossy().to_string(), ), ]); let _result = exec::run( &precious, &[ //"--debug", "lint", "--all", ], &env, &[0], None, // Some(&[Regex::new(".*")?]), Some(&helper.precious_root()), )?; // println!("STDERR"); // println!("{}", _result.stderr.as_deref().unwrap_or("")); let got = munge_invocation_output(td_path)?; let expect = expect.replace(" \\\n ", " "); // println!("GOT"); // println!("{got}"); // println!("EXPECT"); // println!("{expect}"); assert_str_eq!(got, expect, "\n{config}"); Ok(()) } fn munge_invocation_output(output_dir: PathBuf) -> Result { let mut got = String::new(); for entry in fs::read_dir(output_dir)? { let entry = entry?; let meta = entry.metadata()?; if !meta.is_file() { continue; } let path = entry.path(); let mut output = fs::read_to_string(&path) .with_context(|| format!("Could not read file {}", path.display()))? .replace("\r\n", "\n"); if cfg!(windows) { output = output.replace('\\', "/"); } got.push_str(&output); } // println!("RAW GOT"); // println!("{got}"); let output_re = Regex::new( r"(?x) ----\n # We strip off the actual leading path, since on Windows this can # end up in a different form from what we expect. cwd\ =\ .+?[/\\]precious-testhelper-[^/\\]+?(?:[/\\](?P.+?))?\n (?Psome-linter)(?:\ (?P.+?)?)\n ", )?; #[derive(Debug)] struct Invocation<'a> { cwd: &'a str, cmd: &'a str, paths: Option<&'a str>, } let mut invocations: Vec = vec![]; for caps in output_re.captures_iter(&got) { invocations.push(Invocation { cwd: caps.name("cwd").map(|c| c.as_str()).unwrap_or(""), cmd: caps.name("cmd").unwrap().as_str(), paths: caps.name("paths").map(|p| p.as_str()), }); } invocations.sort_by(|a, b| { if a.cwd != b.cwd { return a.cwd.cmp(b.cwd); } a.paths.unwrap_or("").cmp(b.paths.unwrap_or("")) }); // This will match the portion of the path up to the temp dir in which we // ran `precious`. This will be replaced with "/example" so it matches the // docs. let path_re = Regex::new(r"[^ ]+?[/\\]precious-testhelper-[^/\\ ]+(?P[/\\][^/\\ ]+\b)?")?; let mut last_cd = ""; Ok(invocations .iter() .map(|i| { let mut output = String::new(); if last_cd != i.cwd { output.push_str("cd /example/"); output.push_str(i.cwd); output.push('\n'); } last_cd = i.cwd; output.push_str(i.cmd); if let Some(paths) = i.paths { output.push(' '); output.push_str(&path_re.replace_all(paths, |caps: &Captures| { format!( "/example{}", caps.name("path").map(|p| p.as_str()).unwrap_or(""), ) })); } output.push('\n'); output }) .join("")) } pub(crate) fn set_up_for_tests() -> Result { compile_precious()?; let helper = TestHelper::new()? .with_git_repo()? .with_config_file("precious.toml", CONFIG)?; helper.write_file("src/good.rs", GOOD_RUST.trim_start())?; Ok(helper) } precious-0.7.3/precious-integration/src/shared.rs000066400000000000000000000014651463371203000221340ustar00rootroot00000000000000use anyhow::Result; use precious_helpers::exec; use regex::Regex; use std::{collections::HashMap, env, fs, path::PathBuf}; pub(crate) fn compile_precious() -> Result<()> { let cargo_build_re = Regex::new("Finished.+dev.+target")?; let env = HashMap::new(); exec::run( "cargo", &["build", "--package", "precious"], &env, &[0], Some(&[cargo_build_re]), Some(&PathBuf::from("..")), )?; Ok(()) } pub(crate) fn precious_path() -> Result { let man_dir = env::var("CARGO_MANIFEST_DIR")?; assert_ne!(man_dir, ""); let mut precious = PathBuf::from(man_dir); precious.push(".."); precious.push("target"); precious.push("debug"); precious.push("precious"); Ok(fs::canonicalize(precious)?.to_string_lossy().to_string()) } precious-0.7.3/precious-integration/src/tests.rs000066400000000000000000000000541463371203000220210ustar00rootroot00000000000000mod config_init; mod lint_tidy; mod shared; precious-0.7.3/precious-testhelper/000077500000000000000000000000001463371203000173575ustar00rootroot00000000000000precious-0.7.3/precious-testhelper/Cargo.toml000066400000000000000000000007501463371203000213110ustar00rootroot00000000000000[package] name = "precious-testhelper" authors.workspace = true description = "A helper library for precious tests - not for external use" edition.workspace = true license.workspace = true readme.workspace = true repository.workspace = true version.workspace = true [dependencies] anyhow.workspace = true env_logger.workspace = true log.workspace = true once_cell.workspace = true precious-helpers.workspace = true pushd.workspace = true regex.workspace = true tempfile.workspace = true precious-0.7.3/precious-testhelper/src/000077500000000000000000000000001463371203000201465ustar00rootroot00000000000000precious-0.7.3/precious-testhelper/src/lib.rs000066400000000000000000000216651463371203000212740ustar00rootroot00000000000000use anyhow::{Context, Result}; use log::debug; use once_cell::sync::{Lazy, OnceCell}; use precious_helpers::exec; use pushd::Pushd; use regex::Regex; use std::{ collections::HashMap, env, ffi::OsString, fs, io::prelude::*, path::{Path, PathBuf}, }; use tempfile::TempDir; pub struct TestHelper { // While we never access this field we need to hold onto the tempdir or // else the directory it references will be deleted. _tempdir: Option, _preserved_tempdir: Option, git_root: PathBuf, precious_root: PathBuf, paths: Vec, root_gitignore_file: PathBuf, tests_data_gitignore_file: PathBuf, } static RERERE_RE: Lazy = Lazy::new(|| Regex::new("Recorded preimage").unwrap()); impl TestHelper { const PATHS: &'static [&'static str] = &[ "README.md", "can_ignore.x", "merge-conflict-file", "src/bar.rs", "src/can_ignore.rs", "src/main.rs", "src/module.rs", "src/sub/mod.rs", "tests/data/bar.txt", "tests/data/foo.txt", "tests/data/generated.txt", ]; pub fn new() -> Result { static LOGGER_INIT: OnceCell = OnceCell::new(); LOGGER_INIT.get_or_init(|| { env_logger::builder().is_test(true).init(); true }); let td = tempfile::Builder::new() .prefix("precious-testhelper-") .tempdir()?; let root = maybe_canonicalize(td.path())?; let (tempdir, preserved_tempdir) = match env::var("PRECIOUS_TESTS_PRESERVE_TEMPDIR") { Ok(v) if !(v.is_empty() || v == "0") => (None, Some(td.into_path())), _ => (Some(td), None), }; let helper = TestHelper { _tempdir: tempdir, _preserved_tempdir: preserved_tempdir, git_root: root.clone(), precious_root: root, paths: Self::PATHS.iter().map(PathBuf::from).collect(), root_gitignore_file: PathBuf::from(".gitignore"), tests_data_gitignore_file: PathBuf::from("tests/data/.gitignore"), }; Ok(helper) } pub fn with_precious_root_in_subdir>(mut self, subdir: P) -> Self { self.precious_root.push(subdir); self } pub fn with_git_repo(self) -> Result { self.create_git_repo()?; Ok(self) } fn create_git_repo(&self) -> Result<()> { debug!("Creating git repo in {}", self.git_root.display()); for p in self.paths.iter() { let content = if is_rust_file(p) { "fn foo() {}\n" } else { "some text" }; self.write_file(p, content)?; } self.run_git(&["init", "--initial-branch", "master"])?; // If the tests are run in a totally clean environment they will blow // up if this isnt't set. This fixes // https://github.com/houseabsolute/precious/issues/15. self.run_git(&["config", "user.email", "precious@example.com"])?; // With this on I get line ending warnings from git on Windows if I // don't write out files with CRLF. Disabling this simplifies things // greatly. self.run_git(&["config", "core.autocrlf", "false"])?; self.stage_all()?; self.run_git(&["commit", "-m", "initial commit"])?; Ok(()) } pub fn with_config_file(self, file_name: &str, content: &str) -> Result { if cfg!(windows) { self.write_file(self.config_file(file_name), &content.replace('\n', "\r\n"))?; } else { self.write_file(self.config_file(file_name), content)?; } Ok(self) } pub fn pushd_to_git_root(&self) -> Result { Ok(Pushd::new(self.git_root.clone())?) } pub fn git_root(&self) -> PathBuf { self.git_root.clone() } pub fn precious_root(&self) -> PathBuf { self.precious_root.clone() } pub fn config_file(&self, file_name: &str) -> PathBuf { let mut path = self.precious_root.clone(); path.push(file_name); path } pub fn all_files(&self) -> Vec { let mut files = self.paths.clone(); files.sort(); files } pub fn stage_all(&self) -> Result<()> { self.run_git(&["add", "."]) } pub fn stage_some(&self, files: &[&Path]) -> Result<()> { let mut cmd = vec!["add"]; cmd.append(&mut files.iter().map(|f| f.to_str().unwrap()).collect()); self.run_git(&cmd) } pub fn commit_all(&self) -> Result<()> { self.run_git(&["commit", "-a", "-m", "committed"]) } const ROOT_GITIGNORE: &'static str = " /**/bar.* can_ignore.* "; const TESTS_DATA_GITIGNORE: &'static str = " generated.* "; pub fn non_ignored_files() -> Vec { Self::PATHS .iter() .filter_map(|&p| { if p.contains("can_ignore") || p.contains("bar.") || p.contains("generated.txt") { None } else { Some(PathBuf::from(p)) } }) .collect() } pub fn switch_to_branch(&self, branch: &str, exists: bool) -> Result<()> { let mut args: Vec<&str> = vec!["checkout", "--quiet"]; if !exists { args.push("-b"); } args.push(branch); exec::run( "git", &args, &HashMap::new(), &[0], None, Some(&self.git_root), )?; Ok(()) } pub fn merge_master(&self, expect_fail: bool) -> Result<()> { let mut expect_codes = [0].to_vec(); if expect_fail { expect_codes.push(1); } exec::run( "git", &["merge", "--quiet", "--no-ff", "--no-commit", "master"], &HashMap::new(), &expect_codes, // If rerere is enabled, it prints to stderr. Some(&[RERERE_RE.clone()]), Some(&self.git_root), )?; Ok(()) } pub fn add_gitignore_files(&self) -> Result> { self.write_file(&self.root_gitignore_file, Self::ROOT_GITIGNORE)?; self.write_file(&self.tests_data_gitignore_file, Self::TESTS_DATA_GITIGNORE)?; Ok(vec![ self.root_gitignore_file.clone(), self.tests_data_gitignore_file.clone(), ]) } fn run_git(&self, args: &[&str]) -> Result<()> { exec::run( "git", args, &HashMap::new(), &[0], None, Some(&self.git_root), )?; Ok(()) } const TO_MODIFY: &'static [&'static str] = &["src/module.rs", "tests/data/foo.txt"]; pub fn modify_files(&self) -> Result> { let mut paths: Vec = vec![]; for p in Self::TO_MODIFY.iter().map(PathBuf::from) { let content = if is_rust_file(&p) { "fn bar() {}\n" } else { "new text" }; self.write_file(&p, content)?; paths.push(p.clone()); } paths.sort(); Ok(paths) } pub fn write_file>(&self, rel: P, content: &str) -> Result<()> { let mut full = self.precious_root.clone(); full.push(rel.as_ref()); let parent = full.parent().unwrap(); debug!("creating dir at {}", parent.display()); fs::create_dir_all(parent) .with_context(|| format!("Creating dir at {}", parent.display(),))?; debug!("writing file at {}", full.display()); let mut file = fs::File::create(full.clone()) .context(format!("Creating file at {}", full.display()))?; file.write_all(content.as_bytes()) .context(format!("Writing to file at {}", full.display()))?; Ok(()) } pub fn delete_file>(&self, rel: P) -> Result<()> { let mut full = self.precious_root.clone(); full.push(rel.as_ref()); debug!("deleting path at {}", full.display()); if full.is_file() { return Ok(fs::remove_file(full)?); } Ok(fs::remove_dir_all(full)?) } #[cfg(not(target_os = "windows"))] pub fn read_file(&self, rel: &Path) -> Result { let mut full = self.precious_root.clone(); full.push(rel); let content = fs::read_to_string(full.clone()) .context(format!("Reading file at {}", full.display()))?; Ok(content) } } fn is_rust_file(p: &Path) -> bool { if let Some(e) = p.extension() { let rs = OsString::from("rs"); return *e == rs; } false } // The temp directory on macOS in GitHub Actions appears to be a symlink, but // canonicalizing on Windows breaks tests for some reason. pub fn maybe_canonicalize(path: &Path) -> Result { if cfg!(windows) { return Ok(path.to_owned()); } Ok(fs::canonicalize(path)?) } precious-0.7.3/precious.toml000066400000000000000000000035371463371203000161070ustar00rootroot00000000000000exclude = ["target"] [commands.clippy] type = "lint" include = "**/*.rs" invoke = "once" path-args = "none" working-dir = "root" cmd = [ "cargo", "clippy", "--color", "always", "--locked", "--all-targets", "--all-features", "--", "-D", "clippy::pedantic", ] ok_exit_codes = 0 lint_failure_exit_codes = 101 expect_stderr = true [commands."clippy --fix"] type = "tidy" include = "**/*.rs" invoke = "once" path-args = "none" working-dir = "root" cmd = [ "cargo", "clippy", "--fix", "--allow-dirty", "--locked", "--all-targets", "--all-features", "--", "-D", "clippy::pedantic", ] ok_exit_codes = 0 lint_failure_exit_codes = 101 expect_stderr = true [commands.rustfmt] type = "both" include = "**/*.rs" cmd = ["rustfmt", "--edition", "2021"] lint_flags = "--check" ok_exit_codes = [0] lint_failure_exit_codes = [1] [commands.prettier] type = "both" include = ["**/*.md", "**/*.yml"] cmd = [ "./node_modules/.bin/prettier", "--no-config", "--print-width", "100", "--prose-wrap", "always", ] lint-flags = "--check" tidy-flags = "--write" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["Code style issues"] [commands.omegasort-gitignore] type = "both" include = "**/.gitignore" cmd = ["omegasort", "--sort", "path", "--unique"] lint-flags = "--check" tidy-flags = "--in-place" ok-exit-codes = 0 lint-failure-exit-codes = 1 ignore-stderr = ["The .+ file is not sorted", "The .+ file is not unique"] [commands.typos] type = "lint" include = "**/*" cmd = "typos" invoke = "once" ok-exit-codes = 0 lint-failure-exit-codes = 2 [commands.taplo] type = "both" include = "**/*.toml" cmd = ["taplo", "format", "--option", "indent_string= ", "--option", "column_width=100"] lint_flags = "--check" ok_exit_codes = 0 lint_failure_exit_codes = 1 ignore_stderr = "INFO taplo.+" precious-0.7.3/precious/000077500000000000000000000000001463371203000152025ustar00rootroot00000000000000precious-0.7.3/precious/src/000077500000000000000000000000001463371203000157715ustar00rootroot00000000000000precious-0.7.3/precious/src/main.rs000066400000000000000000000006511463371203000172650ustar00rootroot00000000000000#![recursion_limit = "1024"] use log::error; use precious_core::precious; fn main() { let app = precious::app(); if let Err(e) = app.init_logger() { eprintln!("Error creating logger: {e}"); std::process::exit(42); } let status = match app.run() { Ok(s) => s, Err(e) => { error!("{e}"); 42 } }; std::process::exit(i32::from(status)); } precious-0.7.3/release.toml000066400000000000000000000007331463371203000156710ustar00rootroot00000000000000allow-branch = ["master"] shared-version = true sign-commit = true sign-tag = true # [[pre-release-replacements]] # file = "Changes.md" # search = "" # replace = "\n\n## {{Unreleased}} - {{ReleaseDate}}\n" # exactly = 1 # [[pre-release-replacements]] # file = "Changes.md" # search = "{{Unreleased}}" # replace = "{{version}}" # [[pre-release-replacements]] # file = "Changes.md" # search = "{{ReleaseDate}}" # replace = "{{date}}"