diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..cbcaaef --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,11 @@ +allow-dbg-in-tests = true +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true +allow-print-in-tests = true +allow-unwrap-in-tests = true +cognitive-complexity-threshold = 30 +large-error-threshold = 256 +msrv = "1.95.0" +too-many-arguments-threshold = 8 +too-many-lines-threshold = 120 +type-complexity-threshold = 300 diff --git a/.editorconfig b/.editorconfig index f15441a..79f08d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space -indent_size = 2 +indent_size = 4 -[*.md] -trim_trailing_whitespace = false +[*.{toml,yml,yaml}] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b864a83 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + branches: + - main + merge_group: + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + name: Validate ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - name: ubuntu-24.04 + os: ubuntu-24.04 + + - name: windows-2025 + os: windows-2025 + + - name: macos-15-arm64 + os: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Show Rust toolchain + run: rustup show + + - name: Cargo metadata + run: cargo metadata --locked --format-version 1 --no-deps + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Test + run: cargo test --all-features --locked + + - name: Build + run: cargo build --release --locked + + required: + name: CI Required + runs-on: ubuntu-24.04 + needs: + - validate + if: always() + + steps: + - name: Check required jobs + shell: bash + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "One or more required jobs failed." + exit 1 + fi + + if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more required jobs were cancelled." + exit 1 + fi + + echo "All required jobs passed." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..adf0688 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,131 @@ +name: Release binaries + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + + - os: windows-2025 + target: x86_64-pc-windows-msvc + + - os: macos-15-intel + target: x86_64-apple-darwin + + - os: macos-15 + target: aarch64-apple-darwin + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Show Rust toolchain + run: rustup show + + - name: Add target + run: rustup target add ${{ matrix.target }} + + - name: Test + run: cargo test --all-features --locked + + - name: Build binaries + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package Unix binaries + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + + rm -rf dist package + mkdir -p dist package + + cp "target/${{ matrix.target }}/release/rustuse" package/ + cp "target/${{ matrix.target }}/release/cargo-rustuse" package/ + + cp README.md package/ || true + cp LICENSE-MIT package/ || true + cp LICENSE-APACHE package/ || true + + tar -C package -czf "dist/rustuse-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" . + + - name: Package Windows binaries + if: runner.os == 'Windows' + shell: pwsh + run: | + Remove-Item -Recurse -Force dist, package -ErrorAction SilentlyContinue + + New-Item -ItemType Directory -Force dist | Out-Null + New-Item -ItemType Directory -Force package | Out-Null + + Copy-Item "target/${{ matrix.target }}/release/rustuse.exe" package/ + Copy-Item "target/${{ matrix.target }}/release/cargo-rustuse.exe" package/ + + if (Test-Path README.md) { + Copy-Item README.md package/ + } + + if (Test-Path LICENSE-MIT) { + Copy-Item LICENSE-MIT package/ + } + + if (Test-Path LICENSE-APACHE) { + Copy-Item LICENSE-APACHE package/ + } + + Compress-Archive ` + -Path package/* ` + -DestinationPath "dist/rustuse-${{ github.ref_name }}-${{ matrix.target }}.zip" ` + -Force + + - name: Upload packaged binary + uses: actions/upload-artifact@v6 + with: + name: rustuse-${{ matrix.target }} + path: dist/* + if-no-files-found: error + + publish: + name: Publish GitHub Release + needs: + - build + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download packaged binaries + uses: actions/download-artifact@v7 + with: + path: dist + merge-multiple: true + + - name: Publish release + shell: bash + run: | + set -euo pipefail + + gh release create "${GITHUB_REF_NAME}" \ + dist/* \ + --verify-tag \ + --generate-notes + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..319e0e1 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,20 @@ +title = "RustUse Gitleaks configuration" + +[extend] +useDefault = true + +# Keep allowlists narrow and reviewed. +# Prefer, in order: +# 1. `gitleaks:allow` for explicit inline test fixtures. +# 2. rule-scoped allowlists in this file for stable repository fixtures. +# 3. `.gitleaksignore` fingerprints only for historical findings that cannot be removed immediately. +# +# Example rule extension shape: +# [[rules]] +# id = "generic-api-key" +# [[rules.allowlists]] +# description = "Reviewed test fixture" +# condition = "AND" +# paths = ['''^tests/fixtures/example\.txt$'''] +# regexTarget = "line" +# regexes = ['''example-token'''] diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..f10ae28 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,9 @@ +edition = "2024" +hard_tabs = false +match_block_trailing_comma = true +max_width = 100 +newline_style = "Unix" +style_edition = "2024" +tab_spaces = 4 +use_field_init_shorthand = true +use_small_heuristics = "Default" diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..b3a9952 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,31 @@ +exclude = ["target/**"] +include = [ + ".cargo/*.toml", + ".clippy.toml", + ".rustfmt.toml", + ".taplo.toml", + "Cargo.toml", + "deny.toml", + "shared/**/*.toml", + "tools/**/*.toml", +] + +[formatting] +align_entries = false +allowed_blank_lines = 2 +array_auto_collapse = true +array_auto_expand = true +array_trailing_comma = true +column_width = 100 +compact_arrays = true +compact_entries = false +compact_inline_tables = false +crlf = false +indent_entries = false +indent_string = " " +indent_tables = false +inline_table_expand = true +reorder_arrays = true +reorder_inline_tables = true +reorder_keys = true +trailing_newline = true diff --git a/Cargo.lock b/Cargo.lock index 91ea9b9..7ee8c02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,9 +54,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "clap" @@ -138,12 +138,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -161,16 +155,16 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "rustuse-cli" -version = "0.1.0" +version = "0.2.0-beta.1" dependencies = [ "anyhow", "clap", @@ -210,11 +204,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -225,9 +219,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -236,44 +230,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "unicode-ident" @@ -304,9 +296,6 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" diff --git a/Cargo.toml b/Cargo.toml index f2fd497..3e05e7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,16 @@ [package] -name = "rustuse-cli" -version = "0.1.0" -edition = "2024" +autobins = false description = "RustUse command-line adoption helper." +edition = "2024" +homepage = "https://rustuse.org/cli" +keywords = ["cli", "command-line", "rustuse"] license = "MIT OR Apache-2.0" -autobins = false +name = "rustuse-cli" +publish = false +repository = "https://github.com/RustUse/cli" +rust-version = "1.95.0" +# version = "0.2.0" +version = "0.2.0-beta.1" [lib] name = "rustuse_cli" @@ -19,7 +25,8 @@ name = "cargo-rustuse" path = "src/bin/cargo-rustuse.rs" [dependencies] -clap = { version = "4", features = ["derive"] } anyhow = "1" +clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } -toml = "0.8" +toml = "1" +# use-rust = { version = "0.2.0", path = "../use-rust/crates/use-rust" } diff --git a/README.md b/README.md index d9ff45d..138ee03 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,172 @@ # rustuse CLI -`rustuse` is the command-line tool for RustUse adoption workflows. +`rustuse` is the command-line tool for RustUse adoption and development workflows. -RustUse supports three distribution modes: +RustUse supports three adoption modes: -- Cargo mode installs a RustUse crate as a normal dependency. RustUse owns the crate; your project depends on it. -- Copy mode copies RustUse source into your project. Your project owns the copied source after adoption. -- CLI-assisted adoption helps find, add, copy, track, and inspect RustUse primitives. The CLI is not a package manager and does not replace Cargo. +- **Cargo mode** adds a RustUse crate as a normal Rust dependency. RustUse owns the crate; your project depends on it through Cargo. +- **Copy mode** copies RustUse source into your project. Your project owns the copied source after adoption. +- **CLI-assisted adoption** helps find, add, copy, track, inspect, and validate RustUse primitives. -This v0.1 scaffold is intentionally small. It parses the intended command surface, searches a placeholder in-memory index, prints docs URLs, and reports what commands would do. It does not edit `Cargo.toml`, copy source files, fetch network data, or create `rustuse.lock` yet. +The `rustuse` CLI is not a package manager and does not replace Cargo. It is a workflow tool for adopting RustUse crates, inspecting RustUse packages, and maintaining RustUse repositories. -## Project tracking +## Configuration model -`rustuse.toml` is optional human-editable project configuration. Cargo-only workflows and copy-only workflows do not require it. +RustUse supports two configuration surfaces: -Run `rustuse init` when you want managed RustUse project state. The command creates `rustuse.toml`, `.rustuse/cache/`, and `.rustuse/snapshots/`. It is idempotent, does not overwrite an existing config, and does not modify `Cargo.toml`, copy RustUse source, add dependencies, or create `rustuse.lock`. +- `Cargo.toml` metadata for Cargo-native package and workspace metadata. +- `rustuse.toml` for RustUse CLI project state and RustUse-specific workflow policy. -Use `rustuse init --copy-first` to make copy mode the default in generated project configuration. Use `rustuse init --dry-run` to preview the files and directories without writing them. +Both are supported intentionally. + +The Cargo-native metadata path is useful for Rust projects that want RustUse information to live inside the normal Cargo manifest. The `rustuse.toml` path is useful when a project wants explicit RustUse-managed state without adding extra tool-specific configuration to `Cargo.toml`. + +RustUse-owned facade repositories should generally include both: + +```text +Cargo.toml +rustuse.toml +.rustuse/ +``` + +External projects adopting RustUse do not need `rustuse.toml` unless they want managed RustUse project state. + +## Cargo metadata + +RustUse metadata may be stored in `Cargo.toml` using standard Cargo metadata tables. + +For a workspace or facade repository: + +```toml +[workspace.metadata.rustuse] +kind = "facade" +facade = "use-geometry" +homepage = "https://rustuse.org/use-geometry" +default_adoption = "cargo" +copy_supported = true +``` + +For an individual package: + +```toml +[package.metadata.rustuse] +kind = "primitive" +facade = "use-geometry" +slug = "use-point" +homepage = "https://rustuse.org/use-geometry/use-point" +copy_supported = true +``` + +Cargo metadata is best for information that belongs to the crate or workspace itself: + +- facade name +- primitive slug +- RustUse homepage +- documentation path +- adoption support +- copy-mode support +- category or domain hints +- generated catalog hints + +RustUse should avoid duplicating normal Cargo fields such as `name`, `version`, `edition`, `license`, `repository`, `homepage`, and `documentation` unless the RustUse-specific value has a different meaning. + +## `rustuse.toml` + +`rustuse.toml` is optional, human-editable project configuration. + +Cargo-only and copy-only workflows do not require `rustuse.toml`. Run `rustuse init` when you want managed RustUse project state. + +```bash +rustuse init +``` + +The command creates: + +```text +rustuse.toml +.rustuse/cache/ +.rustuse/snapshots/ +``` + +`rustuse init` is idempotent. It does not overwrite an existing config, modify `Cargo.toml`, copy RustUse source, add dependencies, or create `rustuse.lock`. + +A minimal `rustuse.toml` for an external adopting project may look like this: + +```toml +[project] +name = "my-project" +default_adoption = "cargo" + +[tracking] +snapshots = true +cache = true +``` + +A minimal `rustuse.toml` for a RustUse-owned facade repository may look like this: + +```toml +[project] +name = "use-geometry" +kind = "facade" +default_adoption = "cargo" + +[facade] +name = "use-geometry" +crates_dir = "crates" +homepage = "https://rustuse.org/use-geometry" + +[dev] +standard_files = true +manifest_checks = true +facade_wiring_checks = true +``` + +Use copy-first mode when you want copied source to be the default adoption strategy: + +```bash +rustuse init --copy-first +``` + +Preview generated files and directories without writing them: + +```bash +rustuse init --dry-run +``` + +## Configuration precedence + +When both `Cargo.toml` metadata and `rustuse.toml` are present, RustUse should read both. + +Recommended rules: + +1. Cargo package fields remain authoritative for Cargo package identity. +2. `Cargo.toml` RustUse metadata describes public crate and workspace metadata. +3. `rustuse.toml` describes RustUse CLI behavior, local workflow policy, and managed project state. +4. If the same RustUse-specific setting exists in both files, `rustuse.toml` should override local behavior. +5. If the files disagree about crate identity, facade identity, or workspace shape, the CLI should warn or fail validation instead of silently choosing one. ## Cargo subcommand Installing `rustuse-cli` provides two binaries: -- `rustuse` -- `cargo-rustuse` +```bash +cargo install --path . --force +``` + +Installed binaries: -The second binary lets Cargo run RustUse as: +```text +rustuse +cargo-rustuse +``` + +The `cargo-rustuse` binary lets Cargo run RustUse as a native Cargo subcommand: ```bash cargo rustuse ``` -These are equivalent: +These commands are equivalent: ```bash rustuse search geometry @@ -44,21 +179,26 @@ rustuse copy use-slug cargo rustuse copy use-slug ``` -`cargo rustuse` is a Cargo-native entry point, not a separate package manager. It uses the same RustUse CLI implementation. +`cargo rustuse` uses the same CLI implementation as `rustuse`. -## Examples +## Current command surface ```bash rustuse init rustuse init --copy-first +rustuse init --dry-run + rustuse search geometry rustuse info use-geometry +rustuse list + rustuse add use-geometry rustuse copy use-slug rustuse copy use-slug --with-tests + rustuse add use-slug --copy rustuse add use-slug --copy --with-tests -rustuse list + rustuse docs use-math --workspace rustuse doctor ``` @@ -70,3 +210,216 @@ rustuse --verbose search geometry rustuse --quiet doctor rustuse --json info use-geometry ``` + +## Planned adoption API + +The long-term RustUse adoption workflow is expected to support: + +```bash +rustuse search +rustuse info +rustuse add +rustuse copy +rustuse list +rustuse docs +rustuse doctor +``` + +Planned behavior: + +- `search` finds RustUse facades and primitives. +- `info` prints crate metadata, docs links, adoption options, and feature information. +- `add` adds a RustUse crate through Cargo mode. +- `copy` copies RustUse source into the current project. +- `list` shows adopted RustUse crates and copied primitives. +- `docs` opens or prints RustUse documentation URLs. +- `doctor` validates local RustUse configuration and adoption state. + +Future managed-state behavior may include: + +- reading `Cargo.toml` RustUse metadata +- reading `rustuse.toml` +- writing `rustuse.lock` +- tracking copied source snapshots +- checking copied source drift +- validating facade workspace shape +- validating standard RustUse repository files +- validating crates.io metadata +- validating docs links and homepage links + +## Development + +This section covers development workflows for the `rustuse` CLI itself. + +### CLI development workflow + +Format the code: + +```bash +cargo fmt +``` + +Build the CLI: + +```bash +cargo build +``` + +Run tests: + +```bash +cargo test +``` + +Generate documentation without opening a browser: + +```bash +cargo doc --no-deps --document-private-items +``` + +Generate documentation and open it in a browser: + +```bash +cargo doc --no-deps --document-private-items --open +``` + +Install the local CLI build: + +```bash +cargo install --path . --force +``` + +## Root development commands + +Root development commands are intended to run against a RustUse development root that contains multiple `use-*` facade repositories. + +Generate a root development report: + +```bash +rustuse dev root report . +``` + +The root report is intended to be saved as: + +```text +rustuse-report.md +``` + +The root report includes: + +- root repository discovery +- `use-*` facade discovery +- Git repository checks +- child crate counts +- Cargo manifest health +- crates.io category validation +- standard file consistency +- facade inventory +- recommended action plan + +Run a root manifest check from the CLI repository against the parent development root: + +```bash +rustuse dev root manifests .. +``` + +Apply facade wiring fixes for a specific facade: + +```bash +rustuse dev root manifests .. --fix --code facade-wiring --facade use-geometry --write +rustuse dev root manifests .. --fix --code facade-wiring --facade use-math --write +rustuse dev root manifests .. --fix --code facade-wiring --facade use-quant --write +``` + +## Facade development commands + +Facade development commands are intended to run from inside a `use-*` facade repository. + +Generate a facade development report: + +```bash +rustuse dev facade report +``` + +## Planned development API + +The RustUse development command surface is intended to grow around three scopes: + +```bash +rustuse dev root +rustuse dev facade +rustuse dev github +``` + +Root-level commands inspect a development root containing many RustUse repositories: + +```bash +rustuse dev root report . +rustuse dev root manifests . +``` + +Facade-level commands inspect one `use-*` facade repository: + +```bash +rustuse dev facade report +``` + +GitHub-level commands inspect or prepare GitHub repository metadata, labels, issue policy, and generated reports: + +```bash +rustuse dev github report +``` + +Planned development checks include: + +- facade repository shape +- child crate discovery +- workspace member consistency +- workspace dependency consistency +- facade-to-child wiring +- crates.io category validity +- package metadata completeness +- standard file consistency +- README and docs link consistency +- release configuration +- GitHub issue label policy +- generated root and facade reports + +## Generated artifacts + +RustUse may generate local development artifacts during inspection, reporting, and managed adoption workflows. + +Expected generated artifacts include: + +```text +rustuse-report.md +.rustuse/cache/ +.rustuse/snapshots/ +``` + +Future generated artifacts may include: + +```text +rustuse.lock +``` + +Generated artifacts should be deterministic where possible. Files that represent useful repository health snapshots, such as `rustuse-report.md`, may be committed when they are intentionally part of the repository maintenance workflow. + +Cache and snapshot directories should be treated as managed RustUse state. + +## Notes + +The CLI is intentionally conservative. Commands that inspect, report, or preview changes should be safe to run repeatedly. Commands that write changes require explicit write-oriented flags such as `--write` where applicable. + +RustUse should prefer explicit configuration over hidden behavior, but it should not require configuration for simple Cargo-only usage. + +## License + +RustUse is dual-licensed under either of: + +- Apache License, Version 2.0 +- MIT License + +You may choose either license, at your option. + +See `LICENSE-APACHE` and `LICENSE-MIT` for details. diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..2f56867 --- /dev/null +++ b/deny.toml @@ -0,0 +1,42 @@ +[graph] +all-features = true + +[advisories] +# cargo-deny 0.19+ removed `vulnerability` and `notice`; vulnerability advisories +# remain denied by default. +# `unmaintained` now uses scope values (`all|workspace|transitive|none`), so +# warning-only behavior is not available in this key. +# Keep unmaintained advisories non-blocking in CI while still surfacing yanked crates. +unmaintained = "none" +yanked = "warn" +ignore = [] + +[licenses] +confidence-threshold = 0.93 +unused-allowed-license = "warn" + +# Allow only third-party licenses you accept globally. +allow = [ + "Apache-2.0", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", +] + +[licenses.private] +# Ignore license-expression checks for your unpublished private workspace crates. +ignore = true + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-git = "deny" +unknown-registry = "deny" +allow-registry = [ + "https://github.com/rust-lang/crates.io-index", + "sparse+https://index.crates.io/", +] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..4e2acd4 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/src/cli.rs b/src/cli.rs index add03be..1ea53cc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,11 @@ -use clap::{Args, Parser, Subcommand}; +/* use clap::{Args, Parser, Subcommand}; + +use crate::commands::add::AddArgs; +use crate::commands::copy::CopyArgs; +use crate::commands::dev::DevArgs; +use crate::commands::docs::DocsArgs; +use crate::commands::init::InitArgs; +use crate::commands::search::SearchArgs; #[derive(Debug, Parser)] #[command( @@ -40,53 +47,30 @@ pub struct Cli { pub enum Commands { /// Opt into optional RustUse project tracking with rustuse.toml. Init(InitArgs), + /// Search the placeholder RustUse index. Search(SearchArgs), + /// Show placeholder metadata for a RustUse crate or primitive. Info(NamedCommandArgs), + /// Plan adding a Cargo dependency or a copy-mode primitive without requiring rustuse.toml. Add(AddArgs), + /// Plan copying RustUse source without requiring RustUse project state. Copy(CopyArgs), + /// Show optional rustuse.toml project tracking state. List, + /// Print RustUse docs URLs. Docs(DocsArgs), + /// Check this directory for Cargo and RustUse project tracking state. Doctor, -} -#[derive(Debug, Args)] -pub struct InitArgs { - /// Prefer copy-mode defaults in rustuse.toml. - #[arg(long)] - pub copy_first: bool, - - /// Prefer Cargo-mode defaults in rustuse.toml. - #[arg(long)] - pub cargo_first: bool, - - /// Accept the v0.1 defaults without prompting. - #[arg(long)] - pub yes: bool, - - /// Show what would be created without writing files. - #[arg(long)] - pub dry_run: bool, - - /// Override the configured copy root. - #[arg(long, value_name = "PATH")] - pub copy_root: Option, - - /// Override the configured test root. - #[arg(long, value_name = "PATH")] - pub test_root: Option, -} - -#[derive(Debug, Args)] -pub struct SearchArgs { - /// Search text, such as geometry or use-slug. - pub query: String, + /// Development commands for RustUse itself. + Dev(DevArgs), } #[derive(Debug, Args)] @@ -94,50 +78,96 @@ pub struct NamedCommandArgs { /// RustUse crate or primitive name. pub name: String, } + */ -#[derive(Debug, Args)] -pub struct CopyOptions { - /// Include tests when planning copy mode. - #[arg(long)] - pub with_tests: bool, -} +use clap::{Args, Parser, Subcommand}; -#[derive(Debug, Args)] -pub struct AddArgs { - #[command(flatten)] - pub target: NamedCommandArgs, +use crate::commands::add::AddArgs; +use crate::commands::copy::CopyArgs; +use crate::commands::dev::DevArgs; +use crate::commands::docs::DocsArgs; +use crate::commands::init::InitArgs; +use crate::commands::search::SearchArgs; - /// Plan copy mode instead of Cargo dependency mode. - #[arg(long)] - pub copy: bool, +const CLI_ABOUT: &str = "RustUse command-line adoption helper."; - #[command(flatten)] - pub copy_options: CopyOptions, -} +const CLI_LONG_ABOUT: &str = "\ +rustuse helps find, inspect, and plan RustUse adoption. -#[derive(Debug, Args)] -pub struct CopyArgs { - #[command(flatten)] - pub target: NamedCommandArgs, +rustuse.toml is optional: Cargo-only and copy-only workflows do not require +project state. Use `rustuse init` to opt into managed tracking."; - #[command(flatten)] - pub options: CopyOptions, +#[derive(Debug, Parser)] +#[command( + name = "rustuse", + version, + about = CLI_ABOUT, + long_about = CLI_LONG_ABOUT, + arg_required_else_help = true, + propagate_version = true +)] +pub struct Cli { + #[arg( + short, + long, + global = true, + help = "Show extra detail about planned actions." + )] + pub verbose: bool, - /// Track the planned copied primitive in rustuse.toml; requires rustuse init first. - #[arg(long)] - pub track: bool, + #[arg( + short, + long, + global = true, + conflicts_with = "verbose", + help = "Only print essential output." + )] + pub quiet: bool, + + #[arg( + long, + global = true, + help = "Print machine-oriented output where supported." + )] + pub json: bool, + + #[command(subcommand)] + pub command: Commands, } -#[derive(Debug, Args)] -pub struct DocsArgs { - #[command(flatten)] - pub target: NamedCommandArgs, +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Opt into optional RustUse project tracking with rustuse.toml. + Init(InitArgs), + + /// Search the RustUse crate and primitive index. + Search(SearchArgs), + + /// Show metadata for a RustUse crate or primitive. + Info(NamedCommandArgs), + + /// Plan adding a Cargo dependency or copy-mode primitive. + Add(AddArgs), + + /// Plan copying RustUse source without requiring RustUse project state. + Copy(CopyArgs), - /// Print API RustDocs URL. - #[arg(long, conflicts_with = "workspace")] - pub api: bool, + /// Show optional rustuse.toml project tracking state. + List, + + /// Print RustUse documentation URLs. + Docs(DocsArgs), - /// Print workspace RustDocs URL. - #[arg(long, conflicts_with = "api")] - pub workspace: bool, + /// Check this directory for Cargo and RustUse project tracking state. + Doctor, + + /// Development commands for maintaining RustUse repositories. + Dev(DevArgs), +} + +#[derive(Debug, Args)] +pub struct NamedCommandArgs { + /// RustUse crate or primitive name. + #[arg(value_name = "NAME")] + pub name: String, } diff --git a/src/commands/mod.rs b/src/commands.rs similarity index 78% rename from src/commands/mod.rs rename to src/commands.rs index c4295eb..0888ef8 100644 --- a/src/commands/mod.rs +++ b/src/commands.rs @@ -1,11 +1,14 @@ -mod add; -mod copy; -mod docs; -mod doctor; -mod info; -mod init; -mod list; -mod search; +//! Command dispatch for the `rustuse` CLI. + +pub mod add; +pub mod copy; +pub mod dev; +pub mod docs; +pub mod doctor; +pub mod info; +pub mod init; +pub mod list; +pub mod search; use anyhow::{Context, Result}; @@ -13,6 +16,7 @@ use crate::cli::{Cli, Commands}; use crate::index::{self, RustUseEntry}; use crate::output::Output; +/// Runs the parsed CLI command. pub fn run(cli: Cli) -> Result<()> { let output = Output::new(cli.json, cli.quiet, cli.verbose); @@ -25,12 +29,13 @@ pub fn run(cli: Cli) -> Result<()> { Commands::List => list::run(output), Commands::Docs(args) => docs::run(args, output), Commands::Doctor => doctor::run(output), + Commands::Dev(args) => dev::run(args, output), } } pub(crate) fn entry_for(name: &str) -> Result { index::find_by_name(name) - .with_context(|| format!("unknown RustUse entry `{name}` in the v0.1 placeholder index")) + .with_context(|| format!("unknown RustUse entry `{name}` in the RustUse index")) } pub(crate) fn tests_label(with_tests: bool) -> &'static str { diff --git a/src/commands/add.rs b/src/commands/add.rs index 225dee5..af2b85f 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -1,12 +1,27 @@ use anyhow::{Result, ensure}; +use clap::Args; -use crate::cli::AddArgs; +use crate::cli::NamedCommandArgs; +use crate::commands::copy::CopyOptions; use crate::index::DistributionMode; use crate::output::Output; use crate::project; use super::{entry_for, tests_label}; +#[derive(Debug, Args)] +pub struct AddArgs { + #[command(flatten)] + pub target: NamedCommandArgs, + + /// Plan copy mode instead of Cargo dependency mode. + #[arg(long)] + pub copy: bool, + + #[command(flatten)] + pub copy_options: CopyOptions, +} + pub fn run(args: AddArgs, output: Output) -> Result<()> { let entry = entry_for(&args.target.name)?; diff --git a/src/commands/copy.rs b/src/commands/copy.rs index 6548d77..64c3a7c 100644 --- a/src/commands/copy.rs +++ b/src/commands/copy.rs @@ -1,12 +1,34 @@ use anyhow::{Result, bail, ensure}; +use clap::Args; -use crate::cli::CopyArgs; +// use crate::commands::copy::CopyArgs; +use crate::cli::NamedCommandArgs; use crate::index::DistributionMode; use crate::output::Output; use crate::project; use super::{entry_for, tests_label}; +#[derive(Debug, Args)] +pub struct CopyArgs { + #[command(flatten)] + pub target: NamedCommandArgs, + + #[command(flatten)] + pub options: CopyOptions, + + /// Track the planned copied primitive in rustuse.toml; requires rustuse init first. + #[arg(long)] + pub track: bool, +} + +#[derive(Debug, Args)] +pub struct CopyOptions { + /// Include tests when planning copy mode. + #[arg(long)] + pub with_tests: bool, +} + pub fn run(args: CopyArgs, output: Output) -> Result<()> { let entry = entry_for(&args.target.name)?; let state = project::current_state()?; diff --git a/src/commands/dev.rs b/src/commands/dev.rs new file mode 100644 index 0000000..557e9b8 --- /dev/null +++ b/src/commands/dev.rs @@ -0,0 +1,52 @@ +use anyhow::{Result, bail}; +use clap::{Args, Subcommand}; + +use crate::output::Output; + +pub(crate) mod check; +pub(crate) mod facade; +pub(crate) mod info; +pub(crate) mod root; +pub(crate) mod utils; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct DevArgs { + #[command(subcommand)] + pub command: DevCommand, +} + +#[derive(Debug, Subcommand)] +pub enum DevCommand { + /// Check a RustUse facade/workspace for standard project shape. + Check(check::DevCheckArgs), + + /// Show RustUse development info for a workspace. + Info(info::DevInfoArgs), + + /// Manage a root of RustUse facades. + Root(root::DevRootArgs), + + /// Manage one RustUse facade repository. + Facade(facade::DevFacadeArgs), +} + +pub fn run(args: DevArgs, output: Output) -> Result<()> { + match args.command { + DevCommand::Check(args) => { + let options = check::CheckOptions::new(args.workspace); + let report = check::run(options)?; + + println!("{}", report.to_text()); + + if report.is_clean() { + Ok(()) + } else { + bail!("RustUse dev check failed") + } + }, + DevCommand::Facade(args) => facade::run(args, output), + DevCommand::Info(args) => info::run(args, output), + DevCommand::Root(args) => root::run(args, output), + } +} diff --git a/src/commands/dev/check.rs b/src/commands/dev/check.rs new file mode 100644 index 0000000..1b67a2a --- /dev/null +++ b/src/commands/dev/check.rs @@ -0,0 +1,251 @@ +use anyhow::{Context, Result}; +use clap::Args; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Args)] +pub struct DevCheckArgs { + /// Workspace root to inspect. + #[arg(default_value = ".")] + pub workspace: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct CheckOptions { + pub root: PathBuf, +} + +impl CheckOptions { + pub(crate) fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CheckReport { + pub root: PathBuf, + pub findings: Vec, +} + +impl CheckReport { + pub(crate) fn new(root: PathBuf) -> Self { + Self { + root, + findings: Vec::new(), + } + } + + pub(crate) fn is_clean(&self) -> bool { + self.findings + .iter() + .all(|finding| finding.severity != CheckSeverity::Error) + } + + pub(crate) fn error_count(&self) -> usize { + self.findings + .iter() + .filter(|finding| finding.severity == CheckSeverity::Error) + .count() + } + + pub(crate) fn warning_count(&self) -> usize { + self.findings + .iter() + .filter(|finding| finding.severity == CheckSeverity::Warning) + .count() + } + + fn error(&mut self, message: impl Into) { + self.findings.push(CheckFinding { + severity: CheckSeverity::Error, + message: message.into(), + }); + } + + fn warning(&mut self, message: impl Into) { + self.findings.push(CheckFinding { + severity: CheckSeverity::Warning, + message: message.into(), + }); + } + + fn info(&mut self, message: impl Into) { + self.findings.push(CheckFinding { + severity: CheckSeverity::Info, + message: message.into(), + }); + } + + pub(crate) fn to_text(&self) -> String { + let mut lines = Vec::new(); + + lines.push(format!("RustUse dev check: {}", self.root.display())); + + if self.findings.is_empty() { + lines.push("ok: no findings".to_owned()); + return lines.join("\n"); + } + + for finding in &self.findings { + let label = match finding.severity { + CheckSeverity::Error => "error", + CheckSeverity::Warning => "warning", + CheckSeverity::Info => "info", + }; + + lines.push(format!("{label}: {}", finding.message)); + } + + lines.push(format!( + "summary: {} error(s), {} warning(s)", + self.error_count(), + self.warning_count() + )); + + lines.join("\n") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CheckFinding { + pub severity: CheckSeverity, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CheckSeverity { + Error, + Warning, + Info, +} + +pub(crate) fn run(options: CheckOptions) -> Result { + let root = normalize_root(&options.root)?; + let mut report = CheckReport::new(root.clone()); + + check_root_exists(&root, &mut report); + check_workspace_manifest(&root, &mut report)?; + check_crates_directory(&root, &mut report)?; + check_facade_shape(&root, &mut report)?; + + Ok(report) +} + +fn normalize_root(root: &Path) -> Result { + if root.is_absolute() { + Ok(root.to_path_buf()) + } else { + std::env::current_dir() + .context("failed to read current directory")? + .join(root) + .canonicalize() + .with_context(|| format!("failed to resolve workspace root `{}`", root.display())) + } +} + +fn check_root_exists(root: &Path, report: &mut CheckReport) { + if !root.exists() { + report.error(format!("workspace root does not exist: {}", root.display())); + return; + } + + if !root.is_dir() { + report.error(format!( + "workspace root is not a directory: {}", + root.display() + )); + } +} + +fn check_workspace_manifest(root: &Path, report: &mut CheckReport) -> Result<()> { + let manifest_path = root.join("Cargo.toml"); + + if !manifest_path.exists() { + report.error("missing root Cargo.toml"); + return Ok(()); + } + + let manifest = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read `{}`", manifest_path.display()))?; + + if !manifest.contains("[workspace]") { + report.warning("root Cargo.toml does not contain a [workspace] table"); + } + + if !manifest.contains("resolver = \"3\"") { + report.warning("workspace resolver is not explicitly set to \"3\""); + } + + if !manifest.contains("[workspace.package]") { + report.warning("root Cargo.toml does not contain a [workspace.package] table"); + } + + if !manifest.contains("edition = \"2024\"") && !manifest.contains("edition.workspace = true") { + report.warning("Rust 2024 edition was not detected"); + } + + Ok(()) +} + +fn check_crates_directory(root: &Path, report: &mut CheckReport) -> Result<()> { + let crates_dir = root.join("crates"); + + if !crates_dir.exists() { + report.warning("missing crates/ directory"); + return Ok(()); + } + + if !crates_dir.is_dir() { + report.error("crates exists but is not a directory"); + return Ok(()); + } + + let mut crate_count = 0usize; + + for entry in fs::read_dir(&crates_dir) + .with_context(|| format!("failed to read `{}`", crates_dir.display()))? + { + let entry = + entry.with_context(|| format!("failed to read entry in `{}`", crates_dir.display()))?; + + let path = entry.path(); + + if path.is_dir() && path.join("Cargo.toml").exists() { + crate_count += 1; + } + } + + if crate_count == 0 { + report.warning("crates/ exists but no crate manifests were found"); + } else { + report.info(format!("found {crate_count} crate(s) in crates/")); + } + + Ok(()) +} + +fn check_facade_shape(root: &Path, report: &mut CheckReport) -> Result<()> { + let Some(root_name) = root.file_name().and_then(|name| name.to_str()) else { + report.warning("could not infer workspace name from root directory"); + return Ok(()); + }; + + if !root_name.starts_with("use-") { + report.info(format!( + "workspace root `{root_name}` is not a use-* facade workspace" + )); + return Ok(()); + } + + let facade_manifest = root.join("crates").join(root_name).join("Cargo.toml"); + + if facade_manifest.exists() { + report.info(format!("found facade crate: crates/{root_name}")); + } else { + report.warning(format!( + "expected facade crate at crates/{root_name}/Cargo.toml" + )); + } + + Ok(()) +} diff --git a/src/commands/dev/facade.rs b/src/commands/dev/facade.rs new file mode 100644 index 0000000..ec9cf31 --- /dev/null +++ b/src/commands/dev/facade.rs @@ -0,0 +1,34 @@ +//! Facade-level development commands for one RustUse facade repository. + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::output::Output; + +pub(crate) mod discover; +pub(crate) mod inspect; +pub(crate) mod report; +pub(crate) mod run; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct DevFacadeArgs { + #[command(subcommand)] + pub command: DevFacadeCommand, +} + +#[derive(Debug, Subcommand)] +pub enum DevFacadeCommand { + /// Run facade-level development checks. + Run(run::DevFacadeRunArgs), + + /// Generate a report for one RustUse facade repository. + Report(report::DevFacadeReportArgs), +} + +pub(crate) fn run(args: DevFacadeArgs, output: Output) -> Result<()> { + match args.command { + DevFacadeCommand::Run(args) => run::run(args, output), + DevFacadeCommand::Report(args) => report::run(args, output), + } +} diff --git a/src/commands/dev/facade/discover.rs b/src/commands/dev/facade/discover.rs new file mode 100644 index 0000000..e476f0d --- /dev/null +++ b/src/commands/dev/facade/discover.rs @@ -0,0 +1,94 @@ +//! Facade repository discovery helpers. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +#[derive(Debug, Clone)] +pub(crate) struct FacadeInfo { + pub(crate) root: PathBuf, + pub(crate) name: String, + pub(crate) git_dir: PathBuf, + pub(crate) manifest_path: PathBuf, + pub(crate) crates_dir: PathBuf, + pub(crate) crate_manifest_paths: Vec, +} + +impl FacadeInfo { + pub(crate) fn has_git(&self) -> bool { + self.git_dir.is_dir() + } + + pub(crate) fn has_manifest(&self) -> bool { + self.manifest_path.is_file() + } + + pub(crate) fn has_crates_dir(&self) -> bool { + self.crates_dir.is_dir() + } + + pub(crate) fn crate_count(&self) -> usize { + self.crate_manifest_paths.len() + } + + pub(crate) fn status(&self) -> &'static str { + if self.has_git() && self.has_manifest() && self.has_crates_dir() { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn discover_facade(root: impl AsRef) -> Result { + let input_root = root.as_ref(); + let root = fs::canonicalize(input_root).unwrap_or_else(|_| input_root.to_path_buf()); + + let name = root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_owned(); + + let git_dir = root.join(".git"); + let manifest_path = root.join("Cargo.toml"); + let crates_dir = root.join("crates"); + let crate_manifest_paths = discover_crate_manifest_paths(&crates_dir)?; + + Ok(FacadeInfo { + root, + name, + git_dir, + manifest_path, + crates_dir, + crate_manifest_paths, + }) +} + +fn discover_crate_manifest_paths(crates_dir: &Path) -> Result> { + if !crates_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut manifests = Vec::new(); + + for entry in fs::read_dir(crates_dir) + .with_context(|| format!("failed to read `{}`", crates_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + let manifest_path = path.join("Cargo.toml"); + + if manifest_path.is_file() { + manifests.push(manifest_path); + } + } + } + + manifests.sort(); + + Ok(manifests) +} diff --git a/src/commands/dev/facade/inspect.rs b/src/commands/dev/facade/inspect.rs new file mode 100644 index 0000000..84fc686 --- /dev/null +++ b/src/commands/dev/facade/inspect.rs @@ -0,0 +1,61 @@ +//! Facade repository surface inspection. + +use crate::commands::dev::utils::scan::{ + RepositorySurfaceReport, SurfaceProfile, inspect_repository_surface, +}; + +use super::discover::FacadeInfo; + +const REQUIRED_ROOT_FILES: &[(&str, &str)] = &[ + (".clippy.toml", "Clippy configuration"), + (".editorconfig", "EditorConfig"), + (".gitattributes", "Git attributes"), + (".gitignore", "Git ignore rules"), + (".gitleaks.toml", "Gitleaks configuration"), + (".rustfmt.toml", "Rustfmt configuration"), + (".taplo.toml", "Taplo configuration"), + (".trivyignore", "Trivy ignore rules"), + ("Cargo.lock", "Cargo lockfile"), + ("Cargo.toml", "Workspace manifest"), + ("CHANGELOG.md", "Changelog"), + ("CONTRIBUTING.md", "Contribution guide"), + ("deny.toml", "cargo-deny configuration"), + ("GOVERNANCE.md", "Governance"), + ("LICENSE-APACHE", "Apache license"), + ("LICENSE-MIT", "MIT license"), + ("MAINTAINERS.md", "Maintainers"), + ("Makefile", "Make targets"), + ("README.md", "Facade README"), + ("release-plz.toml", "release-plz configuration"), + ("RELEASE.md", "Release notes"), + ("RELEASING.md", "Release process"), + ("rust-toolchain.toml", "Rust toolchain pin"), +]; + +const OPTIONAL_ROOT_FILES: &[(&str, &str)] = &[(".gitlab-ci.yml", "GitLab CI")]; + +const REQUIRED_DIRECTORIES: &[(&str, &str)] = &[ + (".cargo", "Cargo configuration"), + (".github", "GitHub configuration"), + ("crates", "Crate workspace members"), +]; + +const OPTIONAL_DIRECTORIES: &[(&str, &str)] = &[ + (".devcontainer", "Dev container"), + (".gitlab", "GitLab configuration"), + (".vscode", "VS Code workspace settings"), + ("scripts", "Repository scripts"), +]; + +const FACADE_SURFACE_PROFILE: SurfaceProfile = SurfaceProfile { + required_files: REQUIRED_ROOT_FILES, + optional_files: OPTIONAL_ROOT_FILES, + required_directories: REQUIRED_DIRECTORIES, + optional_directories: OPTIONAL_DIRECTORIES, +}; + +pub(crate) type FacadeRepositoryReport = RepositorySurfaceReport; + +pub(crate) fn inspect_facade_repository(facade: &FacadeInfo) -> FacadeRepositoryReport { + inspect_repository_surface(&facade.root, &FACADE_SURFACE_PROFILE) +} diff --git a/src/commands/dev/facade/report.rs b/src/commands/dev/facade/report.rs new file mode 100644 index 0000000..36fca7f --- /dev/null +++ b/src/commands/dev/facade/report.rs @@ -0,0 +1,968 @@ +//! Markdown report generation for one RustUse facade repository. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Result; +use clap::Args; + +use super::discover::{FacadeInfo, discover_facade}; +use super::inspect::{FacadeRepositoryReport, inspect_facade_repository}; +use crate::commands::dev::utils::artifacts::{ + GeneratedArtifactReport, inspect_generated_artifacts, +}; +use crate::commands::dev::utils::development::{ + DevelopmentSurfaceReport, inspect_development_surface, +}; +use crate::commands::dev::utils::documentation::{ + DocumentationSurfaceReport, inspect_documentation_surface, +}; +use crate::commands::dev::utils::github::workflows::{ + GitHubWorkflowReport, inspect_github_workflows, +}; +use crate::commands::dev::utils::gitlab::{GitLabReport, inspect_gitlab}; +use crate::commands::dev::utils::manifest::{ + FacadeManifestReport, analyze_facade_repository_manifests, +}; +use crate::commands::dev::utils::nonstandard::{ + NonStandardPathKind, NonStandardPathReport, NonStandardPathRule, inspect_non_standard_paths, +}; +use crate::commands::dev::utils::release::{ReleaseSurfaceReport, inspect_release_surface}; +use crate::commands::dev::utils::report::{ + markdown_path, resolve_report_path, write_markdown_report, write_presence_table, yes_no, +}; +use crate::commands::dev::utils::tooling::{ToolingSurfaceReport, inspect_tooling_surface}; +use crate::output::Output; + +const FACADE_NON_STANDARD_PATHS: &[NonStandardPathRule] = &[NonStandardPathRule { + path: "docs/", + kind: NonStandardPathKind::Directory, + recommendation: "Move facade documentation to the central docs repository.", +}]; + +#[derive(Debug, Args)] +pub struct DevFacadeReportArgs { + /// Facade repository root. + #[arg(default_value = ".", value_name = "ROOT")] + pub root: PathBuf, + + /// Write the report to this path instead of rustuse-report.md in the facade root. + #[arg(long, value_name = "PATH")] + pub output: Option, + + /// Print the Markdown report to stdout instead of writing a file. + #[arg(long)] + pub stdout: bool, +} + +pub(crate) fn run(args: DevFacadeReportArgs, output: Output) -> Result<()> { + let facade = discover_facade(&args.root)?; + let manifest_report = analyze_facade_repository_manifests(&facade.root, &facade.name)?; + let repository_report = inspect_facade_repository(&facade); + let github_workflow_report = inspect_github_workflows(&facade.root); + let gitlab_report = inspect_gitlab(&facade.root); + let release_report = inspect_release_surface(&facade.root); + let documentation_report = inspect_documentation_surface(&facade.root); + let development_report = inspect_development_surface(&facade.root); + let tooling_report = inspect_tooling_surface(&facade.root); + let non_standard_path_report = + inspect_non_standard_paths(&facade.root, FACADE_NON_STANDARD_PATHS); + let artifact_report = inspect_generated_artifacts(&facade.root); + + let report = build_report( + &facade, + &manifest_report, + &repository_report, + &github_workflow_report, + &gitlab_report, + &release_report, + &documentation_report, + &development_report, + &tooling_report, + &non_standard_path_report, + &artifact_report, + ); + + output.line(format!( + "RustUse dev facade report - root: {}", + facade.root.display() + )); + + if args.stdout { + print!("{report}"); + return Ok(()); + } + + let output_path = resolve_report_path(&facade.root, args.output); + + write_markdown_report(&output_path, &report)?; + + output.line(format!("wrote: {}", output_path.display())); + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn build_report( + facade: &FacadeInfo, + manifest_report: &FacadeManifestReport, + repository_report: &FacadeRepositoryReport, + github_workflow_report: &GitHubWorkflowReport, + gitlab_report: &GitLabReport, + release_report: &ReleaseSurfaceReport, + documentation_report: &DocumentationSurfaceReport, + development_report: &DevelopmentSurfaceReport, + tooling_report: &ToolingSurfaceReport, + non_standard_path_report: &NonStandardPathReport, + artifact_report: &GeneratedArtifactReport, +) -> String { + let mut markdown = String::new(); + + markdown.push_str("# RustUse Facade Report\n\n"); + markdown.push_str("## Summary\n\n"); + markdown.push_str(&format!("- Root: `{}`\n", facade.root.display())); + markdown.push_str(&format!("- Facade: `{}`\n", facade.name)); + markdown.push_str(&format!("- Git: `{}`\n", yes_no(facade.has_git()))); + markdown.push_str(&format!( + "- Cargo.toml: `{}`\n", + yes_no(facade.has_manifest()) + )); + markdown.push_str(&format!( + "- crates/: `{}`\n", + yes_no(facade.has_crates_dir()) + )); + markdown.push_str(&format!( + "- Child crate manifests: `{}`\n", + facade.crate_count() + )); + markdown.push_str(&format!( + "- Status: **{}**\n\n", + overall_status( + facade, + manifest_report, + repository_report, + github_workflow_report, + release_report, + documentation_report, + ) + )); + + write_contents(&mut markdown); + write_action_plan( + &mut markdown, + facade, + manifest_report, + repository_report, + github_workflow_report, + release_report, + documentation_report, + ); + write_facade_shape(&mut markdown, facade); + write_repository_surface(&mut markdown, repository_report); + write_manifest_health(&mut markdown, facade, manifest_report); + write_child_crates(&mut markdown, facade, manifest_report); + write_crate_documentation_consistency(&mut markdown, facade); + write_standard_file_consistency(&mut markdown, repository_report); + write_non_standard_paths(&mut markdown, non_standard_path_report); + write_tooling_configuration(&mut markdown, tooling_report); + write_development_environment(&mut markdown, development_report); + write_ci_cd_surface( + &mut markdown, + github_workflow_report, + gitlab_report, + release_report, + ); + write_documentation_surface(&mut markdown, documentation_report); + write_release_surface(&mut markdown, release_report); + write_generated_artifacts(&mut markdown, artifact_report); + write_notes(&mut markdown); + + markdown +} + +fn write_contents(markdown: &mut String) { + markdown.push_str("## Contents\n\n"); + markdown.push_str("- [Action Plan](#action-plan)\n"); + markdown.push_str("- [Facade Shape](#facade-shape)\n"); + markdown.push_str("- [Repository Surface](#repository-surface)\n"); + markdown.push_str("- [Cargo Manifest Health](#cargo-manifest-health)\n"); + markdown.push_str("- [Child Crates](#child-crates)\n"); + markdown.push_str("- [Crate Documentation Consistency](#crate-documentation-consistency)\n"); + markdown.push_str("- [Standard File Consistency](#standard-file-consistency)\n"); + markdown.push_str("- [Non-standard Paths](#non-standard-paths)\n"); + markdown.push_str("- [Tooling Configuration](#tooling-configuration)\n"); + markdown.push_str("- [Development Environment](#development-environment)\n"); + markdown.push_str("- [CI/CD Surface](#cicd-surface)\n"); + markdown.push_str("- [Documentation Surface](#documentation-surface)\n"); + markdown.push_str("- [Release Surface](#release-surface)\n"); + markdown.push_str("- [Generated / Local Artifacts](#generated--local-artifacts)\n"); + markdown.push_str("- [Notes](#notes)\n\n"); +} + +fn write_action_plan( + markdown: &mut String, + facade: &FacadeInfo, + manifest_report: &FacadeManifestReport, + repository_report: &FacadeRepositoryReport, + github_workflow_report: &GitHubWorkflowReport, + release_report: &ReleaseSurfaceReport, + documentation_report: &DocumentationSurfaceReport, +) { + markdown.push_str("## Action Plan\n\n"); + + let missing_files = repository_report.missing_required_files(); + let missing_directories = repository_report.missing_required_directories(); + let crate_documentation_warnings = crate_documentation_warning_count(facade); + let missing_ci_cd_paths = github_workflow_report.missing_required_paths(); + let has_release_surface_warnings = release_report.status() != "ok"; + let has_documentation_surface_warnings = documentation_report.status() != "ok"; + + let has_action = !facade.has_git() + || !facade.has_manifest() + || !facade.has_crates_dir() + || facade.crate_count() == 0 + || manifest_report.error_count() > 0 + || manifest_report.warning_count() > 0 + || crate_documentation_warnings > 0 + || !missing_ci_cd_paths.is_empty() + || has_release_surface_warnings + || !missing_files.is_empty() + || !missing_directories.is_empty(); + + if !has_action { + markdown.push_str("- No required action.\n"); + + let missing_optional_directories = missing_optional_directories(repository_report); + + if !missing_optional_directories.is_empty() { + markdown.push_str("- Optional gaps:\n"); + + for path in missing_optional_directories { + markdown.push_str(&format!(" - `{path}`\n")); + } + } + + markdown.push('\n'); + return; + } + + if !missing_ci_cd_paths.is_empty() { + markdown.push_str(&format!( + "- Restore {} missing required GitHub CI/CD file(s).\n", + missing_ci_cd_paths.len() + )); + + for path in missing_ci_cd_paths.iter().take(12) { + markdown.push_str(&format!(" - Missing `{path}`\n")); + } + } + + if has_release_surface_warnings { + markdown.push_str("- Restore missing release surface files.\n"); + } + + if has_documentation_surface_warnings { + markdown.push_str("- Address documentation surface warnings.\n"); + } + + if crate_documentation_warnings > 0 { + markdown.push_str(&format!( + "- Fix crate documentation consistency warnings. There are {crate_documentation_warnings} crate documentation warning(s).\n", + )); + } + + if manifest_report.error_count() > 0 { + markdown.push_str(&format!( + "- Fix manifest errors first. There are {} manifest error(s).\n", + manifest_report.error_count() + )); + } + + if manifest_report.warning_count() > 0 { + markdown.push_str(&format!( + "- Clean up manifest warnings. There are {} manifest warning(s).\n", + manifest_report.warning_count() + )); + } + + if !missing_files.is_empty() { + markdown.push_str(&format!( + "- Restore {} missing required standard file(s).\n", + missing_files.len() + )); + + for check in missing_files.iter().take(8) { + markdown.push_str(&format!(" - Missing `{}` ({})\n", check.path, check.label)); + } + } + + if !missing_directories.is_empty() { + markdown.push_str(&format!( + "- Restore {} missing required standard directory/directories.\n", + missing_directories.len() + )); + + for check in missing_directories.iter().take(8) { + markdown.push_str(&format!(" - Missing `{}` ({})\n", check.path, check.label)); + } + } + + if !facade.has_git() { + markdown.push_str("- Initialize or restore the facade `.git` repository.\n"); + } + + if !facade.has_manifest() { + markdown.push_str("- Add the facade root `Cargo.toml`.\n"); + } + + if !facade.has_crates_dir() { + markdown.push_str("- Add the facade `crates/` directory.\n"); + } + + if facade.has_crates_dir() && facade.crate_count() == 0 { + markdown.push_str("- Add child crates under `crates/`, each with its own `Cargo.toml`.\n"); + } + + markdown.push('\n'); +} + +fn write_facade_shape(markdown: &mut String, facade: &FacadeInfo) { + markdown.push_str("## Facade Shape\n\n"); + markdown.push_str("| Check | Status |\n"); + markdown.push_str("|---|---:|\n"); + markdown.push_str(&format!("| `.git` | {} |\n", yes_no(facade.has_git()))); + markdown.push_str(&format!( + "| `Cargo.toml` | {} |\n", + yes_no(facade.has_manifest()) + )); + markdown.push_str(&format!( + "| `crates/` | {} |\n", + yes_no(facade.has_crates_dir()) + )); + markdown.push_str(&format!( + "| Child crate manifests | {} |\n", + facade.crate_count() + )); + markdown.push('\n'); +} + +fn write_repository_surface(markdown: &mut String, report: &FacadeRepositoryReport) { + markdown.push_str("## Repository Surface\n\n"); + + let required_files = report.files.iter().filter(|check| check.required).count(); + let present_required_files = report + .files + .iter() + .filter(|check| check.required && check.present) + .count(); + + let optional_files = report.files.iter().filter(|check| !check.required).count(); + let present_optional_files = report + .files + .iter() + .filter(|check| !check.required && check.present) + .count(); + + let required_directories = report + .directories + .iter() + .filter(|check| check.required) + .count(); + let present_required_directories = report + .directories + .iter() + .filter(|check| check.required && check.present) + .count(); + + let optional_directories = report + .directories + .iter() + .filter(|check| !check.required) + .count(); + let present_optional_directories = report + .directories + .iter() + .filter(|check| !check.required && check.present) + .count(); + + markdown.push_str(&format!("- Status: **{}**\n", report.status().as_str())); + markdown.push_str(&format!( + "- Required files: `{present_required_files}/{required_files}`\n" + )); + markdown.push_str(&format!( + "- Optional files: `{present_optional_files}/{optional_files}`\n" + )); + markdown.push_str(&format!( + "- Required directories: `{present_required_directories}/{required_directories}`\n" + )); + markdown.push_str(&format!( + "- Optional directories: `{present_optional_directories}/{optional_directories}`\n\n" + )); +} + +fn write_manifest_health( + markdown: &mut String, + facade: &FacadeInfo, + report: &FacadeManifestReport, +) { + markdown.push_str("## Cargo Manifest Health\n\n"); + + markdown.push_str(&format!("- Status: **{}**\n", report.status())); + markdown.push_str(&format!( + "- Manifests inspected: `{}`\n", + report.manifest_count() + )); + markdown.push_str(&format!("- Issues: `{}`\n", report.issue_count())); + markdown.push_str(&format!("- Errors: `{}`\n", report.error_count())); + markdown.push_str(&format!("- Warnings: `{}`\n", report.warning_count())); + markdown.push_str(&format!( + "- Invalid crates.io category slugs: `{}`\n\n", + report.invalid_category_count() + )); + + write_manifest_issue_summary(markdown, report); + write_manifest_inventory(markdown, report); + + if report.issue_count() == 0 { + return; + } + + markdown.push_str("### Manifest Issues\n\n"); + + for manifest in &report.manifests { + if manifest.issues.is_empty() { + continue; + } + + let display_path = markdown_path(&manifest.path); + + markdown.push_str(&format!("#### `{display_path}`\n\n")); + markdown.push_str(&format!("- Kind: `{}`\n", manifest.kind.as_str())); + markdown.push_str(&format!("- Status: **{}**\n", manifest.status())); + + if let Some(package_name) = &manifest.package_name { + markdown.push_str(&format!("- Package: `{package_name}`\n")); + } + + markdown.push_str("- Issues:\n"); + + for issue in &manifest.issues { + markdown.push_str(&format!( + " - **{}** `{}`: {}\n", + issue.severity.as_str(), + issue.code, + issue.message + )); + } + + markdown.push('\n'); + } + + if facade.crate_count() == 0 { + markdown.push_str("- No child crate manifests were discovered.\n\n"); + } +} + +fn write_manifest_issue_summary(markdown: &mut String, report: &FacadeManifestReport) { + let mut rows = report + .manifests + .iter() + .flat_map(|manifest| manifest.issues.iter()) + .fold( + BTreeMap::<(&'static str, &'static str), usize>::new(), + |mut summary, issue| { + let key = (issue.severity.as_str(), issue.code); + *summary.entry(key).or_default() += 1; + summary + }, + ) + .into_iter() + .map(|((severity, code), count)| (severity, code, count)) + .collect::>(); + + if rows.is_empty() { + return; + } + + rows.sort_by(|left, right| { + right + .2 + .cmp(&left.2) + .then_with(|| left.0.cmp(right.0)) + .then_with(|| left.1.cmp(right.1)) + }); + + markdown.push_str("### Manifest Issue Summary\n\n"); + markdown.push_str("| Severity | Code | Count |\n"); + markdown.push_str("|---|---|---:|\n"); + + for (severity, code, count) in rows { + markdown.push_str(&format!("| `{severity}` | `{code}` | {count} |\n")); + } + + markdown.push('\n'); +} + +fn write_manifest_inventory(markdown: &mut String, report: &FacadeManifestReport) { + markdown.push_str("### Manifest Inventory\n\n"); + markdown.push_str("| Kind | Package | Status | Issues | Manifest |\n"); + markdown.push_str("|---|---|---:|---:|---|\n"); + + for manifest in &report.manifests { + let package = manifest.package_name.as_deref().unwrap_or(""); + let display_path = markdown_path(&manifest.path); + + markdown.push_str(&format!( + "| `{}` | `{}` | {} | {} | `{}` |\n", + manifest.kind.as_str(), + package, + manifest.status(), + manifest.issue_count(), + display_path + )); + } + + markdown.push('\n'); +} + +fn write_child_crates( + markdown: &mut String, + facade: &FacadeInfo, + manifest_report: &FacadeManifestReport, +) { + markdown.push_str("## Child Crates\n\n"); + + if facade.crate_manifest_paths.is_empty() { + markdown.push_str("- No child crate manifests found.\n\n"); + return; + } + + markdown + .push_str("| Kind | Crate | Manifest | README | lib.rs | prelude.rs | Status | Issues |\n"); + markdown.push_str("|---|---|---|---:|---:|---:|---:|---:|\n"); + + for manifest_path in &facade.crate_manifest_paths { + let crate_dir = manifest_path.parent().unwrap_or(&facade.root); + + let crate_name = crate_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let kind = if crate_name == facade.name { + "facade" + } else { + "child" + }; + + let display_path = manifest_path + .strip_prefix(&facade.root) + .unwrap_or(manifest_path); + + let display_path = markdown_path(display_path); + + let manifest_report_item = manifest_report + .manifests + .iter() + .find(|manifest| markdown_path(&manifest.path) == display_path); + + let manifest_status = manifest_report_item + .map(|manifest| manifest.status()) + .unwrap_or("unknown"); + + let issue_count = manifest_report_item + .map(|manifest| manifest.issue_count()) + .unwrap_or(0); + + markdown.push_str(&format!( + "| `{kind}` | `{crate_name}` | `{display_path}` | {} | {} | {} | {} | {} |\n", + yes_no(crate_dir.join("README.md").is_file()), + yes_no(crate_dir.join("src/lib.rs").is_file()), + yes_no(crate_dir.join("src/prelude.rs").is_file()), + manifest_status, + issue_count + )); + } + + markdown.push('\n'); +} + +fn write_standard_file_consistency(markdown: &mut String, report: &FacadeRepositoryReport) { + markdown.push_str("## Standard File Consistency\n\n"); + + markdown.push_str("| Status | Required | Present | Path | Purpose |\n"); + markdown.push_str("|---|---:|---:|---|---|\n"); + + for check in &report.files { + markdown.push_str(&format!( + "| {} | {} | {} | `{}` | {} |\n", + check.status.as_str(), + yes_no(check.required), + yes_no(check.present), + check.path, + check.label + )); + } + + markdown.push('\n'); +} + +fn write_tooling_configuration(markdown: &mut String, tooling_report: &ToolingSurfaceReport) { + markdown.push_str("## Tooling Configuration\n\n"); + + markdown.push_str(&format!("- Status: **{}**\n", tooling_report.status())); + markdown.push_str(&format!( + "- Present: `{}/{}`\n\n", + tooling_report.present_count(), + tooling_report.total_count() + )); + + write_presence_table(markdown, &tooling_report.surface); +} + +fn write_development_environment( + markdown: &mut String, + development_report: &DevelopmentSurfaceReport, +) { + markdown.push_str("## Development Environment\n\n"); + + markdown.push_str(&format!("- Status: **{}**\n", development_report.status())); + markdown.push_str(&format!( + "- Present: `{}/{}`\n\n", + development_report.present_count(), + development_report.total_count() + )); + + write_presence_table(markdown, &development_report.surface); +} + +fn write_ci_cd_surface( + markdown: &mut String, + github_report: &GitHubWorkflowReport, + gitlab_report: &GitLabReport, + release_report: &ReleaseSurfaceReport, +) { + markdown.push_str("## CI/CD Surface\n\n"); + + markdown.push_str(&format!("- Status: **{}**\n", github_report.status())); + markdown.push_str(&format!( + "- Required GitHub CI/CD surface: `{}/{}`\n", + github_report.present_required_count(), + github_report.required_count() + )); + markdown.push_str(&format!( + "- Required GitHub workflows: `{}/{}`\n", + github_report.present_workflow_count(), + github_report.workflow_count() + )); + markdown.push_str(&format!( + "- GitLab CI surface: `{}/{}`\n", + gitlab_report.present_count(), + gitlab_report.total_count() + )); + markdown.push_str(&format!( + "- Release CI/CD surface: `{}/{}`\n\n", + release_report.ci_present_count(), + release_report.ci_total_count() + )); + + let missing = github_report.missing_required_paths(); + + if !missing.is_empty() { + markdown.push_str("- Missing required CI/CD files:\n"); + + for path in &missing { + markdown.push_str(&format!(" - `{path}`\n")); + } + + markdown.push('\n'); + } + + markdown.push_str("### GitHub CI/CD Surface\n\n"); + markdown.push_str("| Required | Surface | Present |\n"); + markdown.push_str("|---:|---|---:|\n"); + + for check in &github_report.required_surface { + markdown.push_str(&format!( + "| yes | `{}` | {} |\n", + check.path, + yes_no(check.present) + )); + } + + markdown.push('\n'); + + markdown.push_str("### Required GitHub Workflows\n\n"); + markdown.push_str("| Workflow | Present |\n"); + markdown.push_str("|---|---:|\n"); + + for check in &github_report.required_workflows { + markdown.push_str(&format!( + "| `{}` | {} |\n", + check.path, + yes_no(check.present) + )); + } + + markdown.push('\n'); + + markdown.push_str("### GitLab CI Surface\n\n"); + write_presence_table(markdown, &gitlab_report.surface); + + markdown.push_str("### Release CI/CD Surface\n\n"); + write_presence_table(markdown, &release_report.ci_surface); +} + +fn write_documentation_surface( + markdown: &mut String, + documentation_report: &DocumentationSurfaceReport, +) { + markdown.push_str("## Documentation Surface\n\n"); + + markdown.push_str(&format!( + "- Status: **{}**\n", + documentation_report.status() + )); + markdown.push_str(&format!( + "- Present: `{}/{}`\n\n", + documentation_report.present_count(), + documentation_report.total_count() + )); + + write_presence_table(markdown, &documentation_report.surface); +} + +fn write_release_surface(markdown: &mut String, release_report: &ReleaseSurfaceReport) { + markdown.push_str("## Release Surface\n\n"); + + markdown.push_str(&format!("- Status: **{}**\n", release_report.status())); + markdown.push_str(&format!( + "- Present: `{}/{}`\n\n", + release_report.present_count(), + release_report.total_count() + )); + + write_presence_table(markdown, &release_report.surface); +} + +fn write_generated_artifacts(markdown: &mut String, artifact_report: &GeneratedArtifactReport) { + markdown.push_str("## Generated / Local Artifacts\n\n"); + + if artifact_report.is_empty() { + markdown.push_str("- No generated/local artifacts detected.\n\n"); + return; + } + + markdown.push_str("| Path | Meaning |\n"); + markdown.push_str("|---|---|\n"); + + for artifact in &artifact_report.artifacts { + markdown.push_str(&format!( + "| `{}` | {} |\n", + markdown_path(&artifact.path), + artifact.label + )); + } + + markdown.push('\n'); +} + +fn write_notes(markdown: &mut String) { + markdown.push_str("## Notes\n\n"); + markdown.push_str("- This report is generated from the local filesystem.\n"); + markdown.push_str( + "- A facade repository is expected to contain `.git`, `Cargo.toml`, and `crates/`.\n", + ); + markdown.push_str( + "- Child crates are direct directories under `crates/` with their own `Cargo.toml`.\n", + ); + markdown.push_str( + "- Generated and local artifacts are reported separately from required standard files.\n", + ); +} + +fn overall_status( + facade: &FacadeInfo, + manifest_report: &FacadeManifestReport, + repository_report: &FacadeRepositoryReport, + github_workflow_report: &GitHubWorkflowReport, + release_report: &ReleaseSurfaceReport, + documentation_report: &DocumentationSurfaceReport, +) -> &'static str { + if manifest_report.error_count() > 0 { + return "error"; + } + + if manifest_report.warning_count() > 0 { + return "warning"; + } + + if crate_documentation_warning_count(facade) > 0 { + return "warning"; + } + + if github_workflow_report.status() != "ok" { + return "warning"; + } + + if release_report.status() != "ok" { + return "warning"; + } + + if documentation_report.status() != "ok" { + return "warning"; + } + + if repository_report.status().as_str() != "ok" { + return repository_report.status().as_str(); + } + + facade.status() +} + +fn missing_optional_directories(report: &FacadeRepositoryReport) -> Vec<&str> { + report + .directories + .iter() + .filter(|check| !check.required && !check.present) + .map(|check| check.path) + .collect() +} + +fn write_non_standard_paths( + markdown: &mut String, + non_standard_path_report: &NonStandardPathReport, +) { + markdown.push_str("## Non-standard Paths\n\n"); + + markdown.push_str(&format!( + "- Status: **{}**\n", + non_standard_path_report.status() + )); + markdown.push_str(&format!( + "- Present: `{}/{}`\n\n", + non_standard_path_report.present_count(), + non_standard_path_report.total_count() + )); + + let present_checks = non_standard_path_report.present_checks(); + + if present_checks.is_empty() { + markdown.push_str("- No non-standard paths detected.\n\n"); + return; + } + + markdown.push_str("| Path | Recommendation |\n"); + markdown.push_str("|---|---|\n"); + + for check in present_checks { + markdown.push_str(&format!( + "| `{}` | {} |\n", + check.path, check.recommendation + )); + } + + markdown.push('\n'); +} + +fn write_crate_documentation_consistency(markdown: &mut String, facade: &FacadeInfo) { + markdown.push_str("## Crate Documentation Consistency\n\n"); + + if facade.crate_manifest_paths.is_empty() { + markdown.push_str("- No child crate manifests found.\n\n"); + return; + } + + markdown.push_str("| Status | Kind | Crate | README | lib.rs | prelude.rs | Notes |\n"); + markdown.push_str("|---|---|---|---:|---:|---:|---|\n"); + + for manifest_path in &facade.crate_manifest_paths { + let crate_dir = manifest_path.parent().unwrap_or(&facade.root); + + let crate_name = crate_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let is_facade_package = crate_name == facade.name; + + let readme_present = crate_dir.join("README.md").is_file(); + let lib_present = crate_dir.join("src/lib.rs").is_file(); + let prelude_present = crate_dir.join("src/prelude.rs").is_file(); + + let status = if readme_present && lib_present && (!is_facade_package || prelude_present) { + "ok" + } else { + "warning" + }; + + let kind = if is_facade_package { "facade" } else { "child" }; + + let notes = crate_documentation_notes( + is_facade_package, + readme_present, + lib_present, + prelude_present, + ); + + markdown.push_str(&format!( + "| {status} | `{kind}` | `{crate_name}` | {} | {} | {} | {} |\n", + yes_no(readme_present), + yes_no(lib_present), + yes_no(prelude_present), + notes + )); + } + + markdown.push('\n'); +} + +fn crate_documentation_notes( + is_facade_package: bool, + readme_present: bool, + lib_present: bool, + prelude_present: bool, +) -> String { + let mut notes = Vec::new(); + + if !readme_present { + notes.push("missing README.md"); + } + + if !lib_present { + notes.push("missing src/lib.rs"); + } + + if is_facade_package && !prelude_present { + notes.push("missing facade src/prelude.rs"); + } + + if notes.is_empty() { + "ok".to_string() + } else { + notes.join("; ") + } +} + +fn crate_documentation_warning_count(facade: &FacadeInfo) -> usize { + facade + .crate_manifest_paths + .iter() + .filter(|manifest_path| { + let crate_dir = manifest_path.parent().unwrap_or(&facade.root); + + let crate_name = crate_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let is_facade_package = crate_name == facade.name; + + let readme_present = crate_dir.join("README.md").is_file(); + let lib_present = crate_dir.join("src/lib.rs").is_file(); + let prelude_present = crate_dir.join("src/prelude.rs").is_file(); + + !readme_present || !lib_present || (is_facade_package && !prelude_present) + }) + .count() +} diff --git a/src/commands/dev/facade/run.rs b/src/commands/dev/facade/run.rs new file mode 100644 index 0000000..bf86ad1 --- /dev/null +++ b/src/commands/dev/facade/run.rs @@ -0,0 +1,41 @@ +//! Facade-level development check runner. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::Args; + +use super::discover::{FacadeInfo, discover_facade}; +use crate::output::Output; + +#[derive(Debug, Args)] +pub struct DevFacadeRunArgs { + /// Facade repository root. + #[arg(default_value = ".", value_name = "ROOT")] + pub root: PathBuf, +} + +pub(crate) fn run(args: DevFacadeRunArgs, output: Output) -> Result<()> { + let facade = discover_facade(&args.root)?; + + print_facade_summary(&facade, output); + + Ok(()) +} + +fn print_facade_summary(facade: &FacadeInfo, output: Output) { + output.line(format!( + "RustUse dev facade run - root: {}", + facade.root.display() + )); + output.line(format!("facade: {}", facade.name)); + output.line(format!("git: {}", yes_no(facade.has_git()))); + output.line(format!("Cargo.toml: {}", yes_no(facade.has_manifest()))); + output.line(format!("crates/: {}", yes_no(facade.has_crates_dir()))); + output.line(format!("crate manifests: {}", facade.crate_count())); + output.line(format!("status: {}", facade.status())); +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/src/commands/dev/info.rs b/src/commands/dev/info.rs new file mode 100644 index 0000000..e7c7d9e --- /dev/null +++ b/src/commands/dev/info.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use clap::Args; +use std::path::PathBuf; + +// use crate::cli::DevInfoArgs; +use crate::output::Output; + +#[derive(Debug, Args)] +pub struct DevInfoArgs { + /// Workspace root to inspect. + #[arg(default_value = ".")] + pub workspace: PathBuf, +} + +pub fn run(args: DevInfoArgs, output: Output) -> Result<()> { + let workspace = std::fs::canonicalize(&args.workspace).unwrap_or(args.workspace); + + output.line(format!( + "RustUse dev info - workspace: {}", + workspace.display() + )); + + Ok(()) +} diff --git a/src/commands/dev/root.rs b/src/commands/dev/root.rs new file mode 100644 index 0000000..bf72c25 --- /dev/null +++ b/src/commands/dev/root.rs @@ -0,0 +1,104 @@ +//! Root-level development commands for a local RustUse repository collection. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::output::Output; + +pub(crate) mod discover; +pub(crate) mod inspect; +pub(crate) mod manifest_fix; +pub(crate) mod manifest_flags; +pub(crate) mod manifest_model; +pub(crate) mod manifest_policy; +pub(crate) mod manifests; +pub(crate) mod publish; +pub(crate) mod report; +pub(crate) mod scan; +pub(crate) mod standards; + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct DevRootArgs { + #[command(subcommand)] + pub command: DevRootCommand, +} + +#[derive(Debug, Subcommand)] +pub enum DevRootCommand { + /// Inspect the local RustUse development root. + Inspect(DevRootPathArgs), + + /// Inspect Cargo manifests in the local RustUse development root. + Manifests(DevRootManifestArgs), + + /// Generate a report for the local RustUse development root. + Report(DevRootReportArgs), + + /// Scan local use-* facade directories. + Scan(DevRootPathArgs), +} + +#[derive(Debug, Args)] +pub struct DevRootPathArgs { + /// Root directory containing use-* facade repositories. + #[arg(default_value = ".", value_name = "ROOT")] + pub root: PathBuf, +} + +#[derive(Debug, Args)] +pub struct DevRootManifestArgs { + /// Root directory containing use-* facade repositories. + #[arg(default_value = ".", value_name = "ROOT")] + pub root: PathBuf, + + /// Only show manifest output for one facade, such as use-js. + #[arg(long, value_name = "FACADE")] + pub facade: Option, + + /// Only show issues with this issue code. + #[arg(long, value_name = "CODE")] + pub code: Option, + + /// Only show manifests of this kind: workspace-root, facade-package, or child-package. + #[arg(long, value_name = "KIND")] + pub kind: Option, + + /// Only show issues with this severity: error or warning. + #[arg(long, value_name = "SEVERITY")] + pub severity: Option, + + /// Apply supported manifest fixes in memory. + #[arg(long)] + pub fix: bool, + + /// Write supported manifest fixes to disk. + #[arg(long)] + pub write: bool, +} + +#[derive(Debug, Args)] +pub struct DevRootReportArgs { + /// Local RustUse development root containing cli/, docs/, and use-* repos. + #[arg(default_value = ".", value_name = "ROOT")] + pub root: PathBuf, + + /// Write the report to this path instead of rustuse-report.md in the root. + #[arg(long, value_name = "PATH")] + pub output: Option, + + /// Print the Markdown report to stdout instead of writing a file. + #[arg(long)] + pub stdout: bool, +} + +pub(crate) fn run(args: DevRootArgs, output: Output) -> Result<()> { + match args.command { + DevRootCommand::Inspect(args) => inspect::run(args, output), + DevRootCommand::Manifests(args) => manifests::run(args, output), + DevRootCommand::Report(args) => report::run(args, output), + DevRootCommand::Scan(args) => scan::run(args, output), + } +} diff --git a/src/commands/dev/root/discover.rs b/src/commands/dev/root/discover.rs new file mode 100644 index 0000000..6de2d0b --- /dev/null +++ b/src/commands/dev/root/discover.rs @@ -0,0 +1,220 @@ +//! Discovery helpers for a local RustUse development root. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; + +const ROOT_REPOS: &[&str] = &[ + ".github", + ".github-private", + "cli", + "docs", + "infra", + "mcp", + "rustuse", +]; + +const GIT_DIR_NAME: &str = ".git"; +const CARGO_MANIFEST_FILE_NAME: &str = "Cargo.toml"; +const CRATES_DIR_NAME: &str = "crates"; + +#[derive(Debug, Clone)] +pub(crate) struct RootRepoEntry { + pub(crate) name: &'static str, + pub(crate) present: bool, + pub(crate) has_git: bool, +} + +/* impl RootRepoEntry { + pub(crate) fn present(&self) -> bool { + self.present + } + + pub(crate) fn has_git(&self) -> bool { + self.has_git + } + + pub(crate) fn status(&self) -> &'static str { + if self.present() && self.has_git() { + "ok" + } else { + "warning" + } + } +} */ + +#[derive(Debug, Clone)] +pub(crate) struct FacadeEntry { + pub(crate) name: String, + pub(crate) version: Option, + pub(crate) has_git: bool, + pub(crate) has_cargo_toml: bool, + pub(crate) has_crates_dir: bool, + pub(crate) child_crate_count: usize, +} + +impl FacadeEntry { + pub(crate) fn has_git(&self) -> bool { + self.has_git + } + + pub(crate) fn has_cargo_toml(&self) -> bool { + self.has_cargo_toml + } + + pub(crate) fn has_crates_dir(&self) -> bool { + self.has_crates_dir + } + + pub(crate) fn has_version(&self) -> bool { + self.version.is_some() + } + + pub(crate) fn child_crate_count(&self) -> usize { + self.child_crate_count + } + + pub(crate) fn status(&self) -> &'static str { + if self.has_git() && self.has_cargo_toml() && self.has_crates_dir() && self.has_version() { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn discover_root_repos(root: &Path) -> Vec { + ROOT_REPOS + .iter() + .map(|name| discover_root_repo(root, name)) + .collect() +} + +fn discover_root_repo(root: &Path, name: &'static str) -> RootRepoEntry { + let path = root.join(name); + + RootRepoEntry { + name, + present: path.is_dir(), + has_git: path.join(GIT_DIR_NAME).is_dir(), + } +} + +pub(crate) fn discover_facades(root: &Path) -> Result> { + let mut facades = Vec::new(); + + for entry in fs::read_dir(root) + .with_context(|| format!("failed to read root directory `{}`", root.display()))? + { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if !name.starts_with("use-") { + continue; + } + + facades.push(discover_facade_entry(&path, name)?); + } + + facades.sort_by(|left, right| left.name.cmp(&right.name)); + + Ok(facades) +} + +fn discover_facade_entry(path: &Path, name: &str) -> Result { + let crates_dir = path.join(CRATES_DIR_NAME); + + Ok(FacadeEntry { + name: name.to_owned(), + version: read_facade_crate_version(path, name)?, + has_git: path.join(GIT_DIR_NAME).is_dir(), + has_cargo_toml: path.join(CARGO_MANIFEST_FILE_NAME).is_file(), + has_crates_dir: crates_dir.is_dir(), + child_crate_count: count_child_crates(&crates_dir)?, + }) +} + +pub(crate) fn display_version(version: &Option) -> &str { + version.as_deref().unwrap_or("") +} + +fn read_facade_crate_version(facade_root: &Path, facade_name: &str) -> Result> { + let root_manifest_path = facade_root.join(CARGO_MANIFEST_FILE_NAME); + let facade_manifest_path = facade_root + .join(CRATES_DIR_NAME) + .join(facade_name) + .join(CARGO_MANIFEST_FILE_NAME); + + if !facade_manifest_path.is_file() { + return Ok(None); + } + + let facade_manifest = read_toml_value(&facade_manifest_path)?; + + if let Some(version) = facade_manifest + .get("package") + .and_then(|package| package.get("version")) + .and_then(|version| version.as_str()) + { + return Ok(Some(version.to_owned())); + } + + let uses_workspace_version = facade_manifest + .get("package") + .and_then(|package| package.get("version")) + .and_then(|version| version.get("workspace")) + .and_then(|workspace| workspace.as_bool()) + .unwrap_or(false); + + if uses_workspace_version && root_manifest_path.is_file() { + let root_manifest = read_toml_value(&root_manifest_path)?; + + if let Some(version) = root_manifest + .get("workspace") + .and_then(|workspace| workspace.get("package")) + .and_then(|package| package.get("version")) + .and_then(|version| version.as_str()) + { + return Ok(Some(version.to_owned())); + } + } + + Ok(None) +} + +fn read_toml_value(path: &Path) -> Result { + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read `{}`", path.display()))?; + + toml::from_str(&raw).with_context(|| format!("failed to parse `{}`", path.display())) +} + +fn count_child_crates(crates_dir: &Path) -> Result { + if !crates_dir.is_dir() { + return Ok(0); + } + + let mut count = 0usize; + + for entry in fs::read_dir(crates_dir) + .with_context(|| format!("failed to read `{}`", crates_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() && path.join(CARGO_MANIFEST_FILE_NAME).is_file() { + count += 1; + } + } + + Ok(count) +} diff --git a/src/commands/dev/root/inspect.rs b/src/commands/dev/root/inspect.rs new file mode 100644 index 0000000..36092e9 --- /dev/null +++ b/src/commands/dev/root/inspect.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::DevRootPathArgs; +use crate::output::Output; + +pub(crate) fn run(args: DevRootPathArgs, output: Output) -> Result<()> { + let root = fs::canonicalize(&args.root).unwrap_or(args.root); + let root_name = root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let summary = inspect_root(&root)?; + + output.line(format!( + "RustUse dev root inspect - root: {}", + root.display() + )); + output.line(format!("root.name: {root_name}")); + output.line(format!( + "standard root name: {}", + if root_name == "rustuse" { + "yes" + } else { + "no, expected rustuse" + } + )); + output.line(format!("cli: {}", yes_no(summary.has_cli))); + output.line(format!("docs: {}", yes_no(summary.has_docs))); + output.line(format!("use-* directories: {}", summary.use_dir_count)); + output.line(format!("facades using .git: {}", summary.facade_git_count)); + output.line(format!( + "facades missing .git: {}", + summary.missing_git.len() + )); + + if !summary.missing_git.is_empty() { + output.line("missing .git:"); + + for path in &summary.missing_git { + output.line(format!("- {}", display_name(path))); + } + } + + if summary.has_cli && summary.use_dir_count > 0 && summary.missing_git.is_empty() { + output.line("status: ok"); + } else { + output.line("status: warning"); + } + + Ok(()) +} + +#[derive(Debug, Default)] +struct RootSummary { + has_cli: bool, + has_docs: bool, + use_dir_count: usize, + facade_git_count: usize, + missing_git: Vec, +} + +fn inspect_root(root: &Path) -> Result { + let mut summary = RootSummary { + has_cli: root.join("cli").is_dir(), + has_docs: root.join("docs").is_dir(), + ..RootSummary::default() + }; + + for entry in fs::read_dir(root) + .with_context(|| format!("failed to read root directory `{}`", root.display()))? + { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if !name.starts_with("use-") { + continue; + } + + summary.use_dir_count += 1; + + if path.join(".git").is_dir() { + summary.facade_git_count += 1; + } else { + summary.missing_git.push(path); + } + } + + summary.missing_git.sort(); + + Ok(summary) +} + +fn display_name(path: &Path) -> &str { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/src/commands/dev/root/manifest_fix.rs b/src/commands/dev/root/manifest_fix.rs new file mode 100644 index 0000000..8368cac --- /dev/null +++ b/src/commands/dev/root/manifest_fix.rs @@ -0,0 +1,1311 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use super::discover::{FacadeEntry, discover_facades}; +use crate::output::Output; + +const EXPECTED_WORKSPACE_MEMBERS: &[&str] = &["crates/*"]; +const RUSTUSE_GITHUB_ORG: &str = "https://github.com/RustUse"; +const RUSTUSE_DOCS_ORIGIN: &str = "https://rustuse.org"; + +const DEFAULT_WORKSPACE_AUTHORS: &[&str] = &["RustUse Contributors"]; +const DEFAULT_EDITION: &str = "2024"; +const DEFAULT_LICENSE: &str = "MIT OR Apache-2.0"; +const DEFAULT_RUST_VERSION: &str = "1.95.0"; +const DEFAULT_WORKSPACE_CATEGORIES: &[&str] = &["data-structures", "development-tools"]; + +const INHERITED_PACKAGE_FIELDS: &[&str] = &[ + "authors", + "edition", + "rust-version", + "license", + "repository", +]; + +#[derive(Debug, Clone)] +pub(crate) struct ManifestFixOptions { + pub(crate) root: PathBuf, + pub(crate) facade: Option, + pub(crate) code: Option, + pub(crate) write: bool, +} + +impl ManifestFixOptions { + pub(crate) fn new(root: impl Into) -> Self { + Self { + root: root.into(), + facade: None, + code: None, + write: false, + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum ManifestFixGroup { + All, + FacadeWiring, + WorkspaceShape, + WorkspaceDependencies, + PackageMetadata, +} + +impl ManifestFixGroup { + fn from_code(code: Option<&str>) -> Result { + let Some(code) = code else { + return Ok(Self::All); + }; + + match code { + "all" => Ok(Self::All), + + "facade-wiring" + | "missing-facade-dependencies" + | "missing-facade-child-dependency" + | "invalid-facade-child-dependency" + | "missing-facade-child-dependency-optional" + | "missing-facade-features" + | "invalid-facade-default-features" + | "missing-facade-default-features" + | "missing-facade-full-feature" + | "missing-full-feature-member" + | "missing-facade-child-feature" + | "invalid-facade-child-feature" => Ok(Self::FacadeWiring), + + "workspace-shape" + | "missing-standard-workspace-member" + | "non-standard-workspace-members" + | "missing-workspace" + | "missing-workspace-members" + | "invalid-workspace-members" + | "missing-workspace-resolver" + | "workspace-resolver" + | "missing-workspace-package" + | "missing-workspace-package-field" + | "invalid-workspace-repository" + | "missing-workspace-categories" + | "missing-workspace-unsafe-code-policy" + | "invalid-workspace-unsafe-code-policy" + | "missing-workspace-clippy-lints" => Ok(Self::WorkspaceShape), + + "workspace-dependencies" + | "missing-workspace-dependencies" + | "missing-workspace-dependency" + | "invalid-workspace-dependency" + | "invalid-workspace-dependency-path" + | "missing-workspace-dependency-path" + | "missing-workspace-dependency-version" => Ok(Self::WorkspaceDependencies), + + "package-metadata" + | "missing-package-field" + | "missing-package-publish" + | "package-publish" + | "missing-package-categories" + | "missing-inherited-categories" + | "missing-package-inherited-field" + | "package-field-not-inherited" + | "invalid-package-homepage" + | "invalid-package-documentation" + | "missing-package-readme-file" + | "missing-docs-rs-all-features" + | "invalid-docs-rs-all-features" + | "missing-lints-workspace" => Ok(Self::PackageMetadata), + + other => bail!("unknown manifest fix code or group `{other}`"), + } + } + + fn fixes_facade_wiring(self) -> bool { + matches!(self, Self::All | Self::FacadeWiring) + } + + fn fixes_workspace_shape(self) -> bool { + matches!(self, Self::All | Self::WorkspaceShape) + } + + fn fixes_workspace_dependencies(self) -> bool { + matches!(self, Self::All | Self::WorkspaceDependencies) + } + + fn fixes_package_metadata(self) -> bool { + matches!(self, Self::All | Self::PackageMetadata) + } +} + +#[derive(Debug, Default)] +pub(crate) struct ManifestFixSummary { + pub(crate) facades_inspected: usize, + pub(crate) files_changed: usize, + pub(crate) files_unchanged: usize, + pub(crate) files_created: usize, + pub(crate) skipped_facades: usize, + pub(crate) changes: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct ManifestFileChange { + pub(crate) path: PathBuf, + pub(crate) created: bool, + pub(crate) wrote: bool, +} + +pub(crate) fn run(options: ManifestFixOptions, output: Output) -> Result { + let summary = fix_manifests(&options)?; + + output.line(format!( + "RustUse dev root manifests fix - root: {}", + options.root.display() + )); + + output.line(format!( + "mode: {}", + if options.write { "write" } else { "dry-run" } + )); + output.line(format!("facades inspected: {}", summary.facades_inspected)); + output.line(format!("skipped facades: {}", summary.skipped_facades)); + output.line(format!("files changed: {}", summary.files_changed)); + output.line(format!("files created: {}", summary.files_created)); + output.line(format!("files unchanged: {}", summary.files_unchanged)); + + if !summary.changes.is_empty() { + output.line(""); + output.line("changed files:"); + + for change in &summary.changes { + let action = if change.wrote { "wrote" } else { "would write" }; + let created = if change.created { " created" } else { "" }; + + output.line(format!("- {action}{created}: {}", change.path.display())); + } + } + + Ok(summary) +} + +pub(crate) fn fix_manifests(options: &ManifestFixOptions) -> Result { + let root = fs::canonicalize(&options.root).unwrap_or_else(|_| options.root.clone()); + let facades = discover_facades(&root)?; + let group = ManifestFixGroup::from_code(options.code.as_deref())?; + + let mut summary = ManifestFixSummary::default(); + + for facade in facades { + if !matches_facade_filter(&facade, options.facade.as_deref()) { + summary.skipped_facades += 1; + continue; + } + + summary.facades_inspected += 1; + fix_facade_manifests(&root, &facade, group, options.write, &mut summary)?; + } + + Ok(summary) +} + +fn fix_facade_manifests( + root: &Path, + facade: &FacadeEntry, + group: ManifestFixGroup, + write: bool, + summary: &mut ManifestFixSummary, +) -> Result<()> { + let facade_root = root.join(&facade.name); + let crates_dir = facade_root.join("crates"); + let workspace_manifest_path = facade_root.join("Cargo.toml"); + + let crate_infos = discover_crate_infos(&crates_dir)?; + + let Some(facade_crate) = find_facade_crate(&crate_infos, &facade.name).cloned() else { + /* if group.fixes_workspace_shape() || group.fixes_workspace_dependencies() { + let child_crates = child_crates(&crate_infos, &facade.name); + fix_workspace_manifest( + root, + &workspace_manifest_path, + &facade.name, + &child_crates, + group, + write, + summary, + )?; + } */ + + if group.fixes_workspace_shape() + || group.fixes_workspace_dependencies() + || group.fixes_facade_wiring() + { + let child_crates = child_crates(&crate_infos, &facade.name); + fix_workspace_manifest( + root, + &workspace_manifest_path, + &facade.name, + &child_crates, + group, + write, + summary, + )?; + } + + return Ok(()); + }; + + let child_crates = child_crates(&crate_infos, &facade.name); + + /* if group.fixes_workspace_shape() || group.fixes_workspace_dependencies() { + fix_workspace_manifest( + root, + &workspace_manifest_path, + &facade.name, + &child_crates, + group, + write, + summary, + )?; + } */ + + if group.fixes_workspace_shape() + || group.fixes_workspace_dependencies() + || group.fixes_facade_wiring() + { + fix_workspace_manifest( + root, + &workspace_manifest_path, + &facade.name, + &child_crates, + group, + write, + summary, + )?; + } + + if group.fixes_facade_wiring() || group.fixes_package_metadata() { + fix_package_manifest( + root, + &facade_crate, + &facade.name, + &child_crates, + group, + true, + write, + summary, + )?; + } + + if group.fixes_package_metadata() { + for child in &child_crates { + fix_package_manifest(root, child, &facade.name, &[], group, false, write, summary)?; + } + } + + Ok(()) +} + +fn fix_workspace_manifest( + root: &Path, + manifest_path: &Path, + facade_name: &str, + child_crates: &[CrateInfo], + group: ManifestFixGroup, + write: bool, + summary: &mut ManifestFixSummary, +) -> Result<()> { + let created = !manifest_path.exists(); + let original = fs::read_to_string(manifest_path).unwrap_or_default(); + + let mut manifest = if original.trim().is_empty() { + toml::Value::Table(toml::Table::new()) + } else { + toml::from_str::(&original) + .with_context(|| format!("failed to parse `{}`", manifest_path.display()))? + }; + + { + let root_table = manifest + .as_table_mut() + .context("expected Cargo.toml document to be a TOML table")?; + + let workspace = ensure_table(root_table, "workspace"); + + if group.fixes_workspace_shape() { + workspace.insert( + "members".to_string(), + toml::Value::Array( + EXPECTED_WORKSPACE_MEMBERS + .iter() + .map(|member| toml::Value::String((*member).to_string())) + .collect(), + ), + ); + + workspace.insert("resolver".to_string(), toml::Value::String("3".to_string())); + + let package = ensure_table(workspace, "package"); + + set_string_array_if_missing(package, "authors", DEFAULT_WORKSPACE_AUTHORS); + set_string_if_missing(package, "edition", DEFAULT_EDITION); + set_string_if_missing(package, "license", DEFAULT_LICENSE); + set_string_if_missing( + package, + "repository", + &format!("{RUSTUSE_GITHUB_ORG}/{facade_name}"), + ); + set_string_if_missing(package, "rust-version", DEFAULT_RUST_VERSION); + + if !package.contains_key("categories") { + package.insert( + "categories".to_string(), + toml::Value::Array( + DEFAULT_WORKSPACE_CATEGORIES + .iter() + .map(|category| toml::Value::String((*category).to_string())) + .collect(), + ), + ); + } + + let lints = ensure_table(workspace, "lints"); + let rust_lints = ensure_table(lints, "rust"); + rust_lints.insert( + "unsafe_code".to_string(), + toml::Value::String("forbid".to_string()), + ); + + let clippy_lints = ensure_table(lints, "clippy"); + set_string_if_missing(clippy_lints, "pedantic", "warn"); + set_string_if_missing(clippy_lints, "nursery", "warn"); + } + + /* if group.fixes_workspace_dependencies() { + let workspace_dependencies = ensure_table(workspace, "dependencies"); + + for child in child_crates { + let mut dependency = toml::Table::new(); + + if let Some(version) = &child.version { + dependency.insert("version".to_string(), toml::Value::String(version.clone())); + } + + dependency.insert( + "path".to_string(), + toml::Value::String(format!("crates/{}", child.dir_name)), + ); + + workspace_dependencies.insert( + child.dependency_name().to_string(), + toml::Value::Table(dependency), + ); + } + } */ + + if group.fixes_workspace_dependencies() || group.fixes_facade_wiring() { + let workspace_dependencies = ensure_table(workspace, "dependencies"); + + for child in child_crates { + let mut dependency = toml::Table::new(); + + if let Some(version) = &child.version { + dependency.insert("version".to_string(), toml::Value::String(version.clone())); + } + + dependency.insert( + "path".to_string(), + toml::Value::String(format!("crates/{}", child.dir_name)), + ); + + workspace_dependencies.insert( + child.dependency_name().to_string(), + toml::Value::Table(dependency), + ); + } + } + } + + /* write_if_changed( + root, + manifest_path, + created, + &original, + &manifest, + write, + summary, + ) */ + + let rendered = render_workspace_manifest(&manifest, facade_name, child_crates); + + write_rendered_if_changed( + root, + manifest_path, + created, + &original, + rendered, + write, + summary, + ) +} + +fn fix_package_manifest( + root: &Path, + crate_info: &CrateInfo, + facade_name: &str, + child_crates: &[CrateInfo], + group: ManifestFixGroup, + is_facade_package: bool, + write: bool, + summary: &mut ManifestFixSummary, +) -> Result<()> { + let manifest_path = &crate_info.manifest_path; + let created = !manifest_path.exists(); + let original = fs::read_to_string(manifest_path).unwrap_or_default(); + + let mut manifest = if original.trim().is_empty() { + toml::Value::Table(toml::Table::new()) + } else { + toml::from_str::(&original) + .with_context(|| format!("failed to parse `{}`", manifest_path.display()))? + }; + + { + let root_table = manifest + .as_table_mut() + .context("expected Cargo.toml document to be a TOML table")?; + + if group.fixes_package_metadata() { + let package = ensure_table(root_table, "package"); + + set_string_if_missing(package, "name", &crate_info.package_name); + + if crate_info.version.is_none() { + set_string_if_missing(package, "version", "0.1.0"); + } + + if !package.contains_key("publish") { + package.insert("publish".to_string(), toml::Value::Boolean(true)); + } + + set_string_if_missing(package, "readme", "README.md"); + set_string_if_missing( + package, + "documentation", + &format!("https://docs.rs/{}", crate_info.package_name), + ); + + let homepage = if is_facade_package { + format!("{RUSTUSE_DOCS_ORIGIN}/{facade_name}") + } else { + format!( + "{RUSTUSE_DOCS_ORIGIN}/{facade_name}/{}", + crate_info.package_name + ) + }; + + set_string_if_missing(package, "homepage", &homepage); + + for field in INHERITED_PACKAGE_FIELDS { + set_workspace_true(package, field); + } + + if !package.contains_key("categories") { + set_workspace_true(package, "categories"); + } + + let docs_rs = ensure_dotted_table(root_table, &["package", "metadata", "docs", "rs"]); + docs_rs.insert("all-features".to_string(), toml::Value::Boolean(true)); + + let lints = ensure_table(root_table, "lints"); + set_workspace_true(lints, "workspace"); + } + + if is_facade_package && group.fixes_facade_wiring() { + let dependencies = ensure_table(root_table, "dependencies"); + + for child in child_crates { + let mut dependency = toml::Table::new(); + dependency.insert("workspace".to_string(), toml::Value::Boolean(true)); + dependency.insert("optional".to_string(), toml::Value::Boolean(true)); + + dependencies.insert( + child.dependency_name().to_string(), + toml::Value::Table(dependency), + ); + } + + let features = ensure_table(root_table, "features"); + + features.insert("default".to_string(), toml::Value::Array(Vec::new())); + + let full_features = child_crates + .iter() + .map(|child| toml::Value::String(child.feature_name())) + .collect::>(); + + features.insert("full".to_string(), toml::Value::Array(full_features)); + + for child in child_crates { + features.insert( + child.feature_name(), + toml::Value::Array(vec![toml::Value::String(format!( + "dep:{}", + child.dependency_name() + ))]), + ); + } + } + } + + if is_facade_package && (group.fixes_facade_wiring() || group.fixes_package_metadata()) { + let rendered = render_facade_package_manifest(&manifest, facade_name, child_crates); + + return write_rendered_if_changed( + root, + manifest_path, + created, + &original, + rendered, + write, + summary, + ); + } + + write_if_changed( + root, + manifest_path, + created, + &original, + &manifest, + write, + summary, + ) +} + +fn render_facade_package_manifest( + manifest: &toml::Value, + facade_name: &str, + child_crates: &[CrateInfo], +) -> String { + let package = manifest.get("package").and_then(toml::Value::as_table); + + let name = string_field(package, "name").unwrap_or(facade_name); + let version = string_field(package, "version").unwrap_or("0.1.0"); + let publish = bool_field(package, "publish").unwrap_or(true); + let keywords = + array_string_field(package, "keywords").unwrap_or_else(|| vec!["rustuse".to_string()]); + let description = + string_field(package, "description").unwrap_or("Facade crate for RustUse primitives"); + let homepage = format!("{RUSTUSE_DOCS_ORIGIN}/{facade_name}"); + let documentation = format!("https://docs.rs/{name}"); + let readme = string_field(package, "readme").unwrap_or("README.md"); + + let mut out = String::new(); + + out.push_str("[package]\n"); + out.push_str(&format!("name = {}\n", render_toml_string(name))); + out.push_str(&format!("version = {}\n", render_toml_string(version))); + out.push_str(&format!("publish = {publish}\n")); + out.push_str(&format!("keywords = {}\n", render_inline_array(&keywords))); + out.push_str(&format!( + "description = {}\n", + render_toml_string(description) + )); + out.push_str(&format!("homepage = {}\n", render_toml_string(&homepage))); + out.push_str(&format!( + "documentation = {}\n", + render_toml_string(&documentation) + )); + out.push_str(&format!("readme = {}\n", render_toml_string(readme))); + out.push_str("authors.workspace = true\n"); + out.push_str("edition.workspace = true\n"); + out.push_str("rust-version.workspace = true\n"); + out.push_str("license.workspace = true\n"); + out.push_str("repository.workspace = true\n"); + out.push_str("categories.workspace = true\n\n"); + + out.push_str("[package.metadata.docs.rs]\n"); + out.push_str("all-features = true\n\n"); + + out.push_str("[features]\n"); + out.push_str("default = []\n\n"); + + out.push_str("full = [\n"); + for child in child_crates { + out.push_str(&format!( + " {},\n", + render_toml_string(&child.feature_name()) + )); + } + out.push_str("]\n\n"); + + for child in child_crates { + out.push_str(&format!( + "{} = [{}]\n", + child.feature_name(), + render_toml_string(&format!("dep:{}", child.dependency_name())) + )); + } + + out.push('\n'); + out.push_str("[dependencies]\n"); + + for child in child_crates { + out.push_str(&format!( + "{} = {{ workspace = true, optional = true }}\n", + child.dependency_name() + )); + } + + append_existing_dependency_table(manifest, "dev-dependencies", &mut out); + append_existing_dependency_table(manifest, "build-dependencies", &mut out); + append_existing_array_of_tables(manifest, "example", "example", &mut out); + append_existing_array_of_tables(manifest, "bin", "bin", &mut out); + append_existing_array_of_tables(manifest, "test", "test", &mut out); + append_existing_array_of_tables(manifest, "bench", "bench", &mut out); + append_unhandled_top_level_sections(manifest, &mut out); + + out.push('\n'); + out.push_str("[lints]\n"); + out.push_str("workspace = true\n"); + + if !out.ends_with('\n') { + out.push('\n'); + } + + out +} + +fn append_existing_dependency_table(manifest: &toml::Value, key: &str, out: &mut String) { + let Some(table) = manifest.get(key).and_then(toml::Value::as_table) else { + return; + }; + + if table.is_empty() { + return; + } + + out.push('\n'); + out.push_str(&format!("[{key}]\n")); + + for (name, value) in table { + out.push_str(&format!("{name} = {}\n", render_dependency_value(value))); + } +} + +fn append_existing_array_of_tables( + manifest: &toml::Value, + source_key: &str, + rendered_key: &str, + out: &mut String, +) { + let Some(items) = manifest.get(source_key).and_then(toml::Value::as_array) else { + return; + }; + + for item in items { + let Some(table) = item.as_table() else { + continue; + }; + + out.push('\n'); + out.push_str(&format!("[[{rendered_key}]]\n")); + + if let Some(name) = table.get("name").and_then(toml::Value::as_str) { + out.push_str(&format!("name = {}\n", render_toml_string(name))); + } + + if let Some(path) = table.get("path").and_then(toml::Value::as_str) { + out.push_str(&format!("path = {}\n", render_toml_string(path))); + } + + if let Some(required_features) = table + .get("required-features") + .and_then(toml::Value::as_array) + { + let required_features = required_features + .iter() + .filter_map(toml::Value::as_str) + .map(ToOwned::to_owned) + .collect::>(); + + out.push_str(&format!( + "required-features = {}\n", + render_inline_array(&required_features) + )); + } + + for (key, value) in table { + if matches!(key.as_str(), "name" | "path" | "required-features") { + continue; + } + + out.push_str(&format!("{key} = {}\n", render_toml_value(value))); + } + } +} + +fn append_unhandled_top_level_sections(manifest: &toml::Value, out: &mut String) { + let Some(table) = manifest.as_table() else { + return; + }; + + for (key, value) in table { + if is_canonical_facade_top_level_key(key) { + continue; + } + + let mut single = toml::Table::new(); + single.insert(key.clone(), value.clone()); + + let Ok(rendered) = toml::to_string_pretty(&toml::Value::Table(single)) else { + continue; + }; + + let rendered = rendered.trim(); + + if rendered.is_empty() { + continue; + } + + out.push('\n'); + out.push_str(rendered); + out.push('\n'); + } +} + +fn is_canonical_facade_top_level_key(key: &str) -> bool { + matches!( + key, + "package" + | "features" + | "dependencies" + | "dev-dependencies" + | "build-dependencies" + | "example" + | "bin" + | "test" + | "bench" + | "lints" + ) +} + +fn render_dependency_value(value: &toml::Value) -> String { + let Some(table) = value.as_table() else { + return render_toml_value(value); + }; + + let preferred_order = [ + "version", + "path", + "package", + "workspace", + "optional", + "default-features", + "features", + ]; + + let mut parts = Vec::new(); + + for key in preferred_order { + if let Some(value) = table.get(key) { + parts.push(format!("{key} = {}", render_toml_value(value))); + } + } + + for (key, value) in table { + if preferred_order.contains(&key.as_str()) { + continue; + } + + parts.push(format!("{key} = {}", render_toml_value(value))); + } + + format!("{{ {} }}", parts.join(", ")) +} + +fn render_toml_value(value: &toml::Value) -> String { + if let Some(value) = value.as_str() { + return render_toml_string(value); + } + + if let Some(values) = value.as_array() { + let strings = values + .iter() + .filter_map(toml::Value::as_str) + .map(ToOwned::to_owned) + .collect::>(); + + if strings.len() == values.len() { + return render_inline_array(&strings); + } + } + + value.to_string() +} + +fn render_inline_array(values: &[String]) -> String { + let rendered = values + .iter() + .map(|value| render_toml_string(value)) + .collect::>() + .join(", "); + + format!("[{rendered}]") +} + +fn render_toml_string(value: &str) -> String { + format!("{value:?}") +} + +fn string_field<'a>(table: Option<&'a toml::Table>, key: &str) -> Option<&'a str> { + table + .and_then(|table| table.get(key)) + .and_then(toml::Value::as_str) +} + +fn bool_field(table: Option<&toml::Table>, key: &str) -> Option { + table + .and_then(|table| table.get(key)) + .and_then(toml::Value::as_bool) +} + +fn array_string_field(table: Option<&toml::Table>, key: &str) -> Option> { + table + .and_then(|table| table.get(key)) + .and_then(toml::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(toml::Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + .filter(|values| !values.is_empty()) +} + +fn write_if_changed( + root: &Path, + manifest_path: &Path, + created: bool, + original: &str, + manifest: &toml::Value, + write: bool, + summary: &mut ManifestFixSummary, +) -> Result<()> { + let rendered = toml::to_string_pretty(manifest) + .with_context(|| format!("failed to render `{}`", manifest_path.display()))?; + + write_rendered_if_changed( + root, + manifest_path, + created, + original, + rendered, + write, + summary, + ) +} + +fn write_rendered_if_changed( + root: &Path, + manifest_path: &Path, + created: bool, + original: &str, + rendered: String, + write: bool, + summary: &mut ManifestFixSummary, +) -> Result<()> { + let rendered = if rendered.ends_with('\n') { + rendered + } else { + format!("{rendered}\n") + }; + + if rendered == original { + summary.files_unchanged += 1; + return Ok(()); + } + + summary.files_changed += 1; + + if created { + summary.files_created += 1; + } + + if write { + if let Some(parent) = manifest_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + + fs::write(manifest_path, rendered) + .with_context(|| format!("failed to write `{}`", manifest_path.display()))?; + } + + summary.changes.push(ManifestFileChange { + path: relative_path(root, manifest_path), + created, + wrote: write, + }); + + Ok(()) +} + +#[derive(Debug, Clone)] +struct CrateInfo { + dir_name: String, + package_name: String, + version: Option, + manifest_path: PathBuf, +} + +impl CrateInfo { + fn dependency_name(&self) -> &str { + &self.package_name + } + + fn feature_name(&self) -> String { + self.dependency_name() + .strip_prefix("use-") + .unwrap_or(self.dependency_name()) + .to_string() + } +} + +fn discover_crate_infos(crates_dir: &Path) -> Result> { + if !crates_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut crates = Vec::new(); + + for entry in fs::read_dir(crates_dir) + .with_context(|| format!("failed to read `{}`", crates_dir.display()))? + { + let entry = entry?; + let crate_dir = entry.path(); + + if !crate_dir.is_dir() { + continue; + } + + let manifest_path = crate_dir.join("Cargo.toml"); + + if !manifest_path.is_file() { + continue; + } + + let dir_name = crate_dir + .file_name() + .and_then(|name| name.to_str()) + .context("crate directory name is not valid UTF-8")? + .to_string(); + + let raw = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read `{}`", manifest_path.display()))?; + + let manifest = toml::from_str::(&raw) + .with_context(|| format!("failed to parse `{}`", manifest_path.display()))?; + + let package = manifest.get("package").and_then(toml::Value::as_table); + + let package_name = package + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .unwrap_or(&dir_name) + .to_string(); + + let version = package + .and_then(|package| package.get("version")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned); + + crates.push(CrateInfo { + dir_name, + package_name, + version, + manifest_path, + }); + } + + crates.sort_by(|left, right| left.package_name.cmp(&right.package_name)); + + Ok(crates) +} + +fn find_facade_crate<'a>(crates: &'a [CrateInfo], facade_name: &str) -> Option<&'a CrateInfo> { + crates + .iter() + .find(|crate_info| crate_info.package_name == facade_name) + .or_else(|| { + crates + .iter() + .find(|crate_info| crate_info.dir_name == facade_name) + }) +} + +fn child_crates(crates: &[CrateInfo], facade_name: &str) -> Vec { + crates + .iter() + .filter(|crate_info| { + crate_info.dir_name != facade_name && crate_info.package_name != facade_name + }) + .cloned() + .collect() +} + +fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> &'a mut toml::Table { + let needs_insert = table.get(key).and_then(toml::Value::as_table).is_none(); + + if needs_insert { + table.insert(key.to_string(), toml::Value::Table(toml::Table::new())); + } + + table + .get_mut(key) + .and_then(toml::Value::as_table_mut) + .expect("table was just inserted") +} + +fn ensure_dotted_table<'a>(table: &'a mut toml::Table, path: &[&str]) -> &'a mut toml::Table { + let mut current = table; + + for key in path { + current = ensure_table(current, key); + } + + current +} + +fn set_string_if_missing(table: &mut toml::Table, key: &str, value: &str) { + if !table.contains_key(key) { + table.insert(key.to_string(), toml::Value::String(value.to_string())); + } +} + +fn set_string_array_if_missing(table: &mut toml::Table, key: &str, values: &[&str]) { + if !table.contains_key(key) { + table.insert( + key.to_string(), + toml::Value::Array( + values + .iter() + .map(|value| toml::Value::String((*value).to_string())) + .collect(), + ), + ); + } +} + +fn set_workspace_true(table: &mut toml::Table, key: &str) { + let mut workspace = toml::Table::new(); + workspace.insert("workspace".to_string(), toml::Value::Boolean(true)); + + table.insert(key.to_string(), toml::Value::Table(workspace)); +} + +fn matches_facade_filter(facade: &FacadeEntry, facade_filter: Option<&str>) -> bool { + match facade_filter { + Some(filter) => facade.name == filter, + None => true, + } +} + +fn relative_path(root: &Path, path: &Path) -> PathBuf { + path.strip_prefix(root).unwrap_or(path).to_path_buf() +} + +fn render_workspace_manifest( + manifest: &toml::Value, + facade_name: &str, + child_crates: &[CrateInfo], +) -> String { + let workspace = manifest.get("workspace").and_then(toml::Value::as_table); + + let package = workspace + .and_then(|workspace| workspace.get("package")) + .and_then(toml::Value::as_table); + + let categories = array_string_field(package, "categories").unwrap_or_else(|| { + DEFAULT_WORKSPACE_CATEGORIES + .iter() + .map(|value| (*value).to_string()) + .collect() + }); + + let mut out = String::new(); + + out.push_str("[workspace]\n"); + out.push_str("members = [\"crates/*\"]\n"); + out.push_str("resolver = \"3\"\n\n"); + + out.push_str("[workspace.package]\n"); + out.push_str("authors = [\"RustUse Contributors\"]\n"); + out.push_str(&format!( + "categories = {}\n", + render_inline_array(&categories) + )); + out.push_str("edition = \"2024\"\n"); + out.push_str("license = \"MIT OR Apache-2.0\"\n"); + out.push_str(&format!( + "repository = \"{RUSTUSE_GITHUB_ORG}/{facade_name}\"\n" + )); + out.push_str("rust-version = \"1.95.0\"\n\n"); + + out.push_str("[workspace.dependencies]\n"); + + let dependencies = workspace + .and_then(|workspace| workspace.get("dependencies")) + .and_then(toml::Value::as_table); + + append_workspace_dependency_entries(dependencies, child_crates, &mut out); + + out.push('\n'); + out.push_str("[workspace.lints.rust]\n"); + out.push_str("unsafe_code = \"forbid\"\n\n"); + + out.push_str("[workspace.lints.clippy]\n"); + append_workspace_clippy_lints(workspace, &mut out); + + if !out.ends_with('\n') { + out.push('\n'); + } + + out +} + +fn append_workspace_dependency_entries( + existing_dependencies: Option<&toml::Table>, + child_crates: &[CrateInfo], + out: &mut String, +) { + let mut entries = Vec::new(); + + if let Some(existing_dependencies) = existing_dependencies { + for (name, value) in existing_dependencies { + if child_crates + .iter() + .any(|child| child.dependency_name() == name) + { + continue; + } + + entries.push((name.clone(), render_dependency_value(value))); + } + } + + for child in child_crates { + let path = format!("crates/{}", child.dir_name); + + let value = match &child.version { + Some(version) => format!( + "{{ version = {}, path = {} }}", + render_toml_string(version), + render_toml_string(&path) + ), + None => format!("{{ path = {} }}", render_toml_string(&path)), + }; + + entries.push((child.dependency_name().to_string(), value)); + } + + entries.sort_by_key(|entry| dependency_sort_key(&entry.0)); + + for (name, value) in entries { + out.push_str(&format!("{name} = {value}\n")); + } +} + +fn dependency_sort_key(name: &str) -> (u8, String) { + if name.starts_with("use-") { + (1, name.to_string()) + } else { + (0, name.to_string()) + } +} + +fn append_workspace_clippy_lints(workspace: Option<&toml::Table>, out: &mut String) { + let existing = workspace + .and_then(|workspace| workspace.get("lints")) + .and_then(|lints| lints.get("clippy")) + .and_then(toml::Value::as_table); + + let canonical_keys = [ + "all", + "cargo", + "derivable_impls", + "doc_markdown", + "expect_used", + "missing_const_for_fn", + "missing_errors_doc", + "module_name_repetitions", + "multiple_crate_versions", + "must_use_candidate", + "nursery", + "pedantic", + "return_self_not_must_use", + "todo", + "unimplemented", + "unwrap_used", + ]; + + let defaults = [ + ("all", "{ level = \"warn\", priority = -1 }"), + ("cargo", "{ level = \"warn\", priority = -1 }"), + ("derivable_impls", "\"allow\""), + ("doc_markdown", "\"allow\""), + ("expect_used", "\"warn\""), + ("missing_const_for_fn", "\"allow\""), + ("missing_errors_doc", "\"allow\""), + ("module_name_repetitions", "\"allow\""), + ("multiple_crate_versions", "\"allow\""), + ("must_use_candidate", "\"allow\""), + ("nursery", "{ level = \"warn\", priority = -1 }"), + ("pedantic", "{ level = \"warn\", priority = -1 }"), + ("return_self_not_must_use", "\"allow\""), + ("todo", "\"deny\""), + ("unimplemented", "\"deny\""), + ("unwrap_used", "\"warn\""), + ]; + + for (key, default_value) in defaults { + let value = existing + .and_then(|existing| existing.get(key)) + .map(render_workspace_lint_value) + .unwrap_or_else(|| default_value.to_string()); + + out.push_str(&format!("{key} = {value}\n")); + } + + if let Some(existing) = existing { + let mut extras = existing + .iter() + .filter(|(key, _)| !canonical_keys.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), render_workspace_lint_value(value))) + .collect::>(); + + extras.sort_by(|left, right| left.0.cmp(&right.0)); + + for (key, value) in extras { + out.push_str(&format!("{key} = {value}\n")); + } + } +} + +fn render_workspace_lint_value(value: &toml::Value) -> String { + if let Some(table) = value.as_table() { + let level = table + .get("level") + .and_then(toml::Value::as_str) + .unwrap_or("warn"); + + let priority = table + .get("priority") + .and_then(toml::Value::as_integer) + .unwrap_or(-1); + + return format!( + "{{ level = {}, priority = {priority} }}", + render_toml_string(level) + ); + } + + render_toml_value(value) +} diff --git a/src/commands/dev/root/manifest_flags.rs b/src/commands/dev/root/manifest_flags.rs new file mode 100644 index 0000000..41d5841 --- /dev/null +++ b/src/commands/dev/root/manifest_flags.rs @@ -0,0 +1,153 @@ +pub(crate) const SHAPE_WORKSPACE: &str = "Workspace shape"; +pub(crate) const SHAPE_FACADE_WIRING: &str = "Facade wiring"; +pub(crate) const SHAPE_PACKAGE: &str = "Package shape"; +pub(crate) const SHAPE_CATEGORY_METADATA: &str = "Category metadata"; +pub(crate) const SHAPE_GENERAL_METADATA: &str = "General metadata"; + +pub(crate) const MISSING_STANDARD_WORKSPACE_MEMBER: &str = "missing-standard-workspace-member"; +pub(crate) const NON_STANDARD_WORKSPACE_MEMBERS: &str = "non-standard-workspace-members"; +pub(crate) const MISSING_WORKSPACE: &str = "missing-workspace"; +pub(crate) const MISSING_WORKSPACE_MEMBERS: &str = "missing-workspace-members"; +pub(crate) const INVALID_WORKSPACE_MEMBERS: &str = "invalid-workspace-members"; +pub(crate) const MISSING_WORKSPACE_RESOLVER: &str = "missing-workspace-resolver"; +pub(crate) const INVALID_WORKSPACE_RESOLVER: &str = "workspace-resolver"; +pub(crate) const MISSING_WORKSPACE_PACKAGE: &str = "missing-workspace-package"; +pub(crate) const MISSING_WORKSPACE_PACKAGE_FIELD: &str = "missing-workspace-package-field"; +pub(crate) const INVALID_WORKSPACE_REPOSITORY: &str = "invalid-workspace-repository"; +pub(crate) const MISSING_WORKSPACE_DEPENDENCIES: &str = "missing-workspace-dependencies"; +pub(crate) const MISSING_WORKSPACE_DEPENDENCY: &str = "missing-workspace-dependency"; +pub(crate) const INVALID_WORKSPACE_DEPENDENCY: &str = "invalid-workspace-dependency"; +pub(crate) const INVALID_WORKSPACE_DEPENDENCY_SHAPE: &str = "invalid-workspace-dependency-shape"; +pub(crate) const INVALID_WORKSPACE_DEPENDENCY_PATH: &str = "invalid-workspace-dependency-path"; +pub(crate) const MISSING_WORKSPACE_DEPENDENCY_PATH: &str = "missing-workspace-dependency-path"; +pub(crate) const MISSING_WORKSPACE_DEPENDENCY_VERSION: &str = + "missing-workspace-dependency-version"; +pub(crate) const INVALID_WORKSPACE_DEPENDENCY_VERSION: &str = + "invalid-workspace-dependency-version"; +pub(crate) const MISMATCHED_WORKSPACE_DEPENDENCY_VERSION: &str = + "mismatched-workspace-dependency-version"; +pub(crate) const ORPHAN_WORKSPACE_DEPENDENCY_PATH: &str = "orphan-workspace-dependency-path"; +pub(crate) const MISSING_WORKSPACE_UNSAFE_CODE_POLICY: &str = + "missing-workspace-unsafe-code-policy"; +pub(crate) const INVALID_WORKSPACE_UNSAFE_CODE_POLICY: &str = + "invalid-workspace-unsafe-code-policy"; +pub(crate) const MISSING_WORKSPACE_CLIPPY_LINTS: &str = "missing-workspace-clippy-lints"; + +pub(crate) const MISSING_FACADE_DEPENDENCIES: &str = "missing-facade-dependencies"; +pub(crate) const MISSING_FACADE_CHILD_DEPENDENCY: &str = "missing-facade-child-dependency"; +pub(crate) const INVALID_FACADE_CHILD_DEPENDENCY: &str = "invalid-facade-child-dependency"; +pub(crate) const MISSING_FACADE_CHILD_DEPENDENCY_OPTIONAL: &str = + "missing-facade-child-dependency-optional"; +pub(crate) const MISSING_FACADE_FEATURES: &str = "missing-facade-features"; +pub(crate) const INVALID_FACADE_DEFAULT_FEATURES: &str = "invalid-facade-default-features"; +pub(crate) const MISSING_FACADE_DEFAULT_FEATURES: &str = "missing-facade-default-features"; +pub(crate) const MISSING_FACADE_FULL_FEATURE: &str = "missing-facade-full-feature"; +pub(crate) const MISSING_FULL_FEATURE_MEMBER: &str = "missing-full-feature-member"; +pub(crate) const MISSING_FACADE_CHILD_FEATURE: &str = "missing-facade-child-feature"; +pub(crate) const INVALID_FACADE_CHILD_FEATURE: &str = "invalid-facade-child-feature"; + +pub(crate) const INVALID_PACKAGE_HOMEPAGE: &str = "invalid-package-homepage"; +pub(crate) const INVALID_PACKAGE_DOCUMENTATION: &str = "invalid-package-documentation"; +pub(crate) const MISSING_PACKAGE_README_FILE: &str = "missing-package-readme-file"; +pub(crate) const MISSING_DOCS_RS_ALL_FEATURES: &str = "missing-docs-rs-all-features"; +pub(crate) const INVALID_DOCS_RS_ALL_FEATURES: &str = "invalid-docs-rs-all-features"; +pub(crate) const MISSING_LINTS_WORKSPACE: &str = "missing-lints-workspace"; +pub(crate) const PACKAGE_NAME_DIRECTORY_MISMATCH: &str = "package-name-directory-mismatch"; +pub(crate) const INVALID_FACADE_PACKAGE_NAME: &str = "invalid-facade-package-name"; +pub(crate) const INVALID_CHILD_PACKAGE_NAME: &str = "invalid-child-package-name"; + +pub(crate) const INVALID_CATEGORY_SLUG: &str = "invalid-category-slug"; +pub(crate) const TOO_MANY_CATEGORIES: &str = "too-many-categories"; +pub(crate) const DUPLICATE_CATEGORY: &str = "duplicate-category"; +pub(crate) const INVALID_CATEGORIES_SHAPE: &str = "invalid-categories-shape"; +pub(crate) const INVALID_CATEGORY_VALUE: &str = "invalid-category-value"; +pub(crate) const MISSING_WORKSPACE_CATEGORIES: &str = "missing-workspace-categories"; +pub(crate) const MISSING_PACKAGE_CATEGORIES: &str = "missing-package-categories"; +pub(crate) const MISSING_INHERITED_CATEGORIES: &str = "missing-inherited-categories"; + +pub(crate) const WORKSPACE_SHAPE_CODES: &[&str] = &[ + MISSING_STANDARD_WORKSPACE_MEMBER, + NON_STANDARD_WORKSPACE_MEMBERS, + MISSING_WORKSPACE, + MISSING_WORKSPACE_MEMBERS, + INVALID_WORKSPACE_MEMBERS, + MISSING_WORKSPACE_RESOLVER, + INVALID_WORKSPACE_RESOLVER, + MISSING_WORKSPACE_PACKAGE, + MISSING_WORKSPACE_PACKAGE_FIELD, + INVALID_WORKSPACE_REPOSITORY, + MISSING_WORKSPACE_DEPENDENCIES, + MISSING_WORKSPACE_DEPENDENCY, + INVALID_WORKSPACE_DEPENDENCY, + INVALID_WORKSPACE_DEPENDENCY_SHAPE, + INVALID_WORKSPACE_DEPENDENCY_PATH, + MISSING_WORKSPACE_DEPENDENCY_PATH, + MISSING_WORKSPACE_DEPENDENCY_VERSION, + INVALID_WORKSPACE_DEPENDENCY_VERSION, + MISMATCHED_WORKSPACE_DEPENDENCY_VERSION, + ORPHAN_WORKSPACE_DEPENDENCY_PATH, + MISSING_WORKSPACE_UNSAFE_CODE_POLICY, + INVALID_WORKSPACE_UNSAFE_CODE_POLICY, + MISSING_WORKSPACE_CLIPPY_LINTS, +]; + +pub(crate) const FACADE_WIRING_CODES: &[&str] = &[ + MISSING_FACADE_DEPENDENCIES, + MISSING_FACADE_CHILD_DEPENDENCY, + INVALID_FACADE_CHILD_DEPENDENCY, + MISSING_FACADE_CHILD_DEPENDENCY_OPTIONAL, + MISSING_FACADE_FEATURES, + INVALID_FACADE_DEFAULT_FEATURES, + MISSING_FACADE_DEFAULT_FEATURES, + MISSING_FACADE_FULL_FEATURE, + MISSING_FULL_FEATURE_MEMBER, + MISSING_FACADE_CHILD_FEATURE, + INVALID_FACADE_CHILD_FEATURE, +]; + +pub(crate) const PACKAGE_SHAPE_CODES: &[&str] = &[ + INVALID_PACKAGE_HOMEPAGE, + INVALID_PACKAGE_DOCUMENTATION, + MISSING_PACKAGE_README_FILE, + MISSING_DOCS_RS_ALL_FEATURES, + INVALID_DOCS_RS_ALL_FEATURES, + MISSING_LINTS_WORKSPACE, + PACKAGE_NAME_DIRECTORY_MISMATCH, + INVALID_FACADE_PACKAGE_NAME, + INVALID_CHILD_PACKAGE_NAME, +]; + +pub(crate) const CATEGORY_METADATA_CODES: &[&str] = &[ + INVALID_CATEGORY_SLUG, + TOO_MANY_CATEGORIES, + DUPLICATE_CATEGORY, + INVALID_CATEGORIES_SHAPE, + INVALID_CATEGORY_VALUE, + MISSING_WORKSPACE_CATEGORIES, + MISSING_PACKAGE_CATEGORIES, + MISSING_INHERITED_CATEGORIES, +]; + +/* pub(crate) const WORKSPACE_DEPENDENCY_VERSION_CODES: &[&str] = &[ + MISSING_WORKSPACE_DEPENDENCY_VERSION, + INVALID_WORKSPACE_DEPENDENCY_VERSION, + MISMATCHED_WORKSPACE_DEPENDENCY_VERSION, +]; */ + +pub(crate) fn manifest_shape_bucket(code: &str) -> &'static str { + if WORKSPACE_SHAPE_CODES.contains(&code) { + SHAPE_WORKSPACE + } else if FACADE_WIRING_CODES.contains(&code) { + SHAPE_FACADE_WIRING + } else if PACKAGE_SHAPE_CODES.contains(&code) { + SHAPE_PACKAGE + } else if CATEGORY_METADATA_CODES.contains(&code) { + SHAPE_CATEGORY_METADATA + } else { + SHAPE_GENERAL_METADATA + } +} + +/* pub(crate) fn is_workspace_dependency_version_code(code: &str) -> bool { + WORKSPACE_DEPENDENCY_VERSION_CODES.contains(&code) +} */ diff --git a/src/commands/dev/root/manifest_model.rs b/src/commands/dev/root/manifest_model.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/root/manifest_model.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/root/manifest_policy.rs b/src/commands/dev/root/manifest_policy.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/root/manifest_policy.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/root/manifests.rs b/src/commands/dev/root/manifests.rs new file mode 100644 index 0000000..1599813 --- /dev/null +++ b/src/commands/dev/root/manifests.rs @@ -0,0 +1,1554 @@ +use std::collections::BTreeMap; +use std::fs; + +use anyhow::Result; + +use super::DevRootManifestArgs; +use super::discover::discover_facades; +use super::manifest_fix::{self, ManifestFixOptions}; +use super::manifest_flags::manifest_shape_bucket; +use crate::commands::dev::utils::manifest::{ + FacadeManifestReport, ManifestFileReport, ManifestIssue, analyze_manifests, +}; +use crate::output::Output; + +const TOP_OFFENDER_LIMIT: usize = 15; + +pub(crate) fn run(args: DevRootManifestArgs, output: Output) -> Result<()> { + if args.fix || args.write { + let mut options = ManifestFixOptions::new(&args.root); + options.facade = args.facade.clone(); + options.code = args.code.clone(); + options.write = args.write; + + manifest_fix::run(options, output)?; + return Ok(()); + } + let root = fs::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone()); + let facades = discover_facades(&root)?; + let reports = analyze_manifests(&root, &facades)?; + + let manifest_count: usize = reports + .iter() + .map(FacadeManifestReport::manifest_count) + .sum(); + + let issue_count: usize = reports.iter().map(FacadeManifestReport::issue_count).sum(); + + let error_count: usize = reports.iter().map(FacadeManifestReport::error_count).sum(); + + let warning_count: usize = reports + .iter() + .map(FacadeManifestReport::warning_count) + .sum(); + + let invalid_category_count: usize = reports + .iter() + .map(FacadeManifestReport::invalid_category_count) + .sum(); + + let has_filters = has_manifest_filters(&args); + let matching_issue_count = matching_issue_count(&reports, &args); + + output.line(format!( + "RustUse dev root manifests - root: {}", + root.display() + )); + output.line(format!("facades inspected: {}", reports.len())); + output.line(format!("manifests inspected: {manifest_count}")); + output.line(format!( + "issues: {issue_count} ({error_count} error(s), {warning_count} warning(s))" + )); + output.line(format!("invalid category slugs: {invalid_category_count}")); + + if has_filters { + output.line(format!("matching filtered issues: {matching_issue_count}")); + } + + output.line(""); + + output.line(format!( + "{:<8} {:<24} {:>9} {:>8} {:>8} {:>10}", + "Status", "Facade", "Manifests", "Errors", "Warnings", "Bad cats" + )); + + output.line(format!( + "{:<8} {:<24} {:>9} {:>8} {:>8} {:>10}", + "------", "------", "---------", "------", "--------", "--------" + )); + + for report in &reports { + output.line(format!( + "{:<8} {:<24} {:>9} {:>8} {:>8} {:>10}", + report.status(), + report.facade_name, + report.manifest_count(), + report.error_count(), + report.warning_count(), + report.invalid_category_count() + )); + } + + write_manifest_issue_summary(&output, &reports); + write_manifest_shape_summary(&output, &reports); + write_manifest_top_offenders(&output, &reports); + + if matching_issue_count > 0 { + output.line(""); + output.line("manifest issues:"); + + for facade_report in &reports { + if !facade_matches_filter(facade_report, &args) { + continue; + } + + for manifest in &facade_report.manifests { + if manifest.issues.is_empty() { + continue; + } + + if !manifest_matches_filters(manifest, &args) { + continue; + } + + output.line(format!( + "- {} [{}] {}", + facade_report.facade_name, + manifest.kind.as_str(), + manifest.path.display() + )); + + if let Some(package_name) = &manifest.package_name { + output.line(format!(" package: `{package_name}`")); + } + + output.line(format!(" status: {}", manifest.status())); + + for issue in &manifest.issues { + if !issue_matches_filters(issue, &args) { + continue; + } + + output.line(format!( + " - {} {}: {}", + issue.severity.as_str(), + issue.code, + issue.message + )); + } + } + } + } + + output.line(""); + + if has_filters && matching_issue_count == 0 { + output.line("status: ok, no matching filtered issues"); + } else if error_count > 0 { + output.line("status: error"); + } else if warning_count > 0 { + output.line("status: warning"); + } else { + output.line("status: ok"); + } + + Ok(()) +} + +fn write_manifest_issue_summary(output: &Output, reports: &[FacadeManifestReport]) { + let mut summary: BTreeMap<(&'static str, &'static str), usize> = BTreeMap::new(); + + for facade_report in reports { + for manifest in &facade_report.manifests { + for issue in &manifest.issues { + let key = (issue.severity.as_str(), issue.code); + *summary.entry(key).or_default() += 1; + } + } + } + + if summary.is_empty() { + return; + } + + let mut rows = summary + .into_iter() + .map(|((severity, code), count)| (severity, code, count)) + .collect::>(); + + rows.sort_by(|left, right| { + right + .2 + .cmp(&left.2) + .then_with(|| left.0.cmp(right.0)) + .then_with(|| left.1.cmp(right.1)) + }); + + output.line(""); + output.line("manifest issue summary:"); + output.line(format!("{:<8} {:<48} {:>8}", "Severity", "Code", "Count")); + output.line(format!("{:<8} {:<48} {:>8}", "--------", "----", "-----")); + + for (severity, code, count) in rows { + output.line(format!("{:<8} {:<48} {:>8}", severity, code, count)); + } +} + +fn write_manifest_shape_summary(output: &Output, reports: &[FacadeManifestReport]) { + let mut buckets: BTreeMap<&'static str, usize> = BTreeMap::new(); + + for facade_report in reports { + for manifest in &facade_report.manifests { + for issue in &manifest.issues { + let bucket = manifest_shape_bucket(issue.code); + *buckets.entry(bucket).or_default() += 1; + } + } + } + + if buckets.is_empty() { + return; + } + + let mut rows = buckets.into_iter().collect::>(); + + rows.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + + output.line(""); + output.line("manifest shape summary:"); + output.line(format!("{:<20} {:>8}", "Shape area", "Issues")); + output.line(format!("{:<20} {:>8}", "----------", "------")); + + for (bucket, count) in rows { + output.line(format!("{:<20} {:>8}", bucket, count)); + } +} + +fn write_manifest_top_offenders(output: &Output, reports: &[FacadeManifestReport]) { + let mut rows = Vec::new(); + + for report in reports { + let issue_count = report.issue_count(); + + if issue_count == 0 { + continue; + } + + rows.push(ManifestTopOffender { + facade_name: report.facade_name.clone(), + issue_count, + error_count: report.error_count(), + warning_count: report.warning_count(), + main_shape_area: main_shape_area(report), + }); + } + + if rows.is_empty() { + return; + } + + rows.sort_by(|left, right| { + right + .issue_count + .cmp(&left.issue_count) + .then_with(|| right.error_count.cmp(&left.error_count)) + .then_with(|| left.facade_name.cmp(&right.facade_name)) + }); + + output.line(""); + output.line(format!( + "top manifest offenders (top {TOP_OFFENDER_LIMIT}):" + )); + output.line(format!( + "{:<24} {:>8} {:>8} {:>8} {:<20}", + "Facade", "Issues", "Errors", "Warnings", "Main area" + )); + output.line(format!( + "{:<24} {:>8} {:>8} {:>8} {:<20}", + "------", "------", "------", "--------", "---------" + )); + + for row in rows.into_iter().take(TOP_OFFENDER_LIMIT) { + output.line(format!( + "{:<24} {:>8} {:>8} {:>8} {:<20}", + row.facade_name, + row.issue_count, + row.error_count, + row.warning_count, + row.main_shape_area + )); + } +} + +fn main_shape_area(report: &FacadeManifestReport) -> &'static str { + let mut buckets: BTreeMap<&'static str, usize> = BTreeMap::new(); + + for manifest in &report.manifests { + for issue in &manifest.issues { + let bucket = manifest_shape_bucket(issue.code); + *buckets.entry(bucket).or_default() += 1; + } + } + + buckets + .into_iter() + .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.0.cmp(left.0))) + .map(|(bucket, _)| bucket) + .unwrap_or("Unknown") +} + +#[derive(Debug)] +struct ManifestTopOffender { + facade_name: String, + issue_count: usize, + error_count: usize, + warning_count: usize, + main_shape_area: &'static str, +} + +/* fn manifest_shape_bucket(code: &str) -> &'static str { + match code { + "missing-standard-workspace-member" + | "non-standard-workspace-members" + | "missing-workspace" + | "missing-workspace-members" + | "invalid-workspace-members" + | "missing-workspace-resolver" + | "workspace-resolver" + | "missing-workspace-package" + | "missing-workspace-package-field" + | "invalid-workspace-repository" + | "missing-workspace-dependencies" + | "missing-workspace-dependency" + | "invalid-workspace-dependency" + | "invalid-workspace-dependency-path" + | "missing-workspace-dependency-path" + | "missing-workspace-dependency-version" + | "missing-workspace-unsafe-code-policy" + | "invalid-workspace-unsafe-code-policy" + | "missing-workspace-clippy-lints" => "Workspace shape", + + "missing-facade-dependencies" + | "missing-facade-child-dependency" + | "invalid-facade-child-dependency" + | "missing-facade-child-dependency-optional" + | "missing-facade-features" + | "invalid-facade-default-features" + | "missing-facade-default-features" + | "missing-facade-full-feature" + | "missing-full-feature-member" + | "missing-facade-child-feature" + | "invalid-facade-child-feature" => "Facade wiring", + + "invalid-package-homepage" + | "invalid-package-documentation" + | "missing-package-readme-file" + | "missing-docs-rs-all-features" + | "invalid-docs-rs-all-features" + | "missing-lints-workspace" + | "package-name-directory-mismatch" + | "invalid-facade-package-name" + | "invalid-child-package-name" => "Package shape", + + "invalid-category-slug" + | "too-many-categories" + | "duplicate-category" + | "invalid-categories-shape" + | "invalid-category-value" + | "missing-workspace-categories" + | "missing-package-categories" + | "missing-inherited-categories" => "Category metadata", + + _ => "General metadata", + } +} */ + +/* #[derive(Debug)] +pub(crate) struct FacadeManifestReport { + pub(crate) facade_name: String, + pub(crate) manifests: Vec, +} */ + +/* impl FacadeManifestReport { + pub(crate) fn status(&self) -> &'static str { + if self.error_count() > 0 { + "error" + } else if self.warning_count() > 0 { + "warning" + } else { + "ok" + } + } + + pub(crate) fn manifest_count(&self) -> usize { + self.manifests.len() + } + + pub(crate) fn issue_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::issue_count) + .sum() + } + + pub(crate) fn error_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::error_count) + .sum() + } + + pub(crate) fn warning_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::warning_count) + .sum() + } + + pub(crate) fn invalid_category_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::invalid_category_count) + .sum() + } +} */ + +/* #[derive(Debug)] +pub(crate) struct ManifestFileReport { + pub(crate) path: PathBuf, + pub(crate) kind: ManifestKind, + pub(crate) package_name: Option, + pub(crate) issues: Vec, +} */ + +/* impl ManifestFileReport { + pub(crate) fn status(&self) -> &'static str { + if self.error_count() > 0 { + "error" + } else if self.warning_count() > 0 { + "warning" + } else { + "ok" + } + } + + pub(crate) fn issue_count(&self) -> usize { + self.issues.len() + } + + pub(crate) fn error_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == ManifestIssueSeverity::Error) + .count() + } + + pub(crate) fn warning_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == ManifestIssueSeverity::Warning) + .count() + } + + pub(crate) fn invalid_category_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.code == "invalid-category-slug") + .count() + } +} */ + +/* #[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum ManifestKind { + WorkspaceRoot, + FacadePackage, + ChildPackage, +} */ + +/* impl ManifestKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::WorkspaceRoot => "workspace-root", + Self::FacadePackage => "facade-package", + Self::ChildPackage => "child-package", + } + } +} */ + +/* #[derive(Debug)] +pub(crate) struct ManifestIssue { + pub(crate) severity: ManifestIssueSeverity, + pub(crate) code: &'static str, + pub(crate) message: String, +} */ + +/* impl ManifestIssue { + fn error(code: &'static str, message: impl Into) -> Self { + Self { + severity: ManifestIssueSeverity::Error, + code, + message: message.into(), + } + } + + fn warning(code: &'static str, message: impl Into) -> Self { + Self { + severity: ManifestIssueSeverity::Warning, + code, + message: message.into(), + } + } +} */ + +/* #[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum ManifestIssueSeverity { + Error, + Warning, +} */ + +/* impl ManifestIssueSeverity { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + } + } +} */ + +/* pub(crate) fn analyze_manifests( + root: &Path, + facades: &[FacadeEntry], +) -> Result> { + facades + .iter() + .map(|facade| analyze_facade_manifests(root, facade)) + .collect() +} */ + +/* pub(crate) fn analyze_facade_manifests( + root: &Path, + facade: &FacadeEntry, +) -> Result { + let facade_root = root.join(&facade.name); + let workspace_manifest_path = facade_root.join("Cargo.toml"); + let crates_dir = facade_root.join("crates"); + + let mut manifests = Vec::new(); + + let crate_manifest_paths = discover_crate_manifest_paths(&crates_dir)?; + + let crate_dir_names = crate_manifest_paths + .iter() + .filter_map(|path| crate_dir_name(path)) + .collect::>(); + + let child_crate_names = crate_dir_names + .iter() + .filter(|name| name.as_str() != facade.name.as_str()) + .cloned() + .collect::>(); + + let child_crates = collect_child_crate_info(&crate_manifest_paths, &facade.name); + + let workspace_categories = analyze_workspace_root_manifest( + root, + &workspace_manifest_path, + facade, + &child_crates, + &mut manifests, + ); + + for manifest_path in crate_manifest_paths { + let crate_dir_name = manifest_path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let kind = if crate_dir_name == facade.name { + ManifestKind::FacadePackage + } else { + ManifestKind::ChildPackage + }; + + analyze_package_manifest( + root, + &manifest_path, + kind, + &facade.name, + &child_crate_names, + workspace_categories.as_deref(), + &mut manifests, + ); + } + + Ok(FacadeManifestReport { + facade_name: facade.name.clone(), + manifests, + }) +} */ + +/* fn analyze_workspace_root_manifest( + root: &Path, + manifest_path: &Path, + facade: &FacadeEntry, + child_crate_names: &BTreeSet, + manifests: &mut Vec, +) -> Option> { */ +/* fn analyze_workspace_root_manifest( + root: &Path, + manifest_path: &Path, + facade: &FacadeEntry, + child_crates: &BTreeMap, + manifests: &mut Vec, +) -> Option> { + let mut report = ManifestFileReport { + path: relative_path(root, manifest_path), + kind: ManifestKind::WorkspaceRoot, + package_name: None, + issues: Vec::new(), + }; + + if !manifest_path.is_file() { + report.issues.push(ManifestIssue::error( + "missing-workspace-manifest", + "missing workspace root Cargo.toml", + )); + manifests.push(report); + return None; + } + + let Some(manifest) = read_manifest(manifest_path, &mut report) else { + manifests.push(report); + return None; + }; + + let workspace_categories = + analyze_workspace_table(&manifest, facade, &mut report, child_crates); + + manifests.push(report); + + workspace_categories +} */ + +/* fn analyze_package_manifest( + root: &Path, + manifest_path: &Path, + kind: ManifestKind, + facade_name: &str, + child_crate_names: &BTreeSet, + workspace_categories: Option<&[String]>, + manifests: &mut Vec, +) { + let mut report = ManifestFileReport { + path: relative_path(root, manifest_path), + kind, + package_name: None, + issues: Vec::new(), + }; + + let Some(manifest) = read_manifest(manifest_path, &mut report) else { + manifests.push(report); + return; + }; + + analyze_package_table( + &manifest, + kind, + facade_name, + child_crate_names, + workspace_categories, + &mut report, + ); + + manifests.push(report); +} + */ +/* fn analyze_workspace_table( + manifest: &toml::Value, + facade: &FacadeEntry, + report: &mut ManifestFileReport, + child_crates: &BTreeMap, +) -> Option> { + let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::error( + "missing-workspace", + "workspace root manifest is missing [workspace]", + )); + return None; + }; + + match workspace.get("resolver").and_then(toml::Value::as_str) { + Some("3") => {}, + Some(resolver) => report.issues.push(ManifestIssue::warning( + "workspace-resolver", + format!("expected [workspace].resolver = \"3\", found \"{resolver}\""), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-resolver", + "missing [workspace].resolver", + )), + } + + match workspace.get("members") { + Some(value) if value.as_array().is_some() => {}, + Some(_) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-members", + "expected [workspace].members to be an array", + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-members", + "missing [workspace].members", + )), + } + + validate_workspace_members(workspace, report); + // validate_workspace_dependencies(workspace, child_crate_names, report); + validate_workspace_dependencies(workspace, child_crates, report); + validate_workspace_lints(workspace, report); + + let Some(workspace_package) = workspace.get("package").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-package", + "missing [workspace.package]", + )); + return None; + }; + + validate_workspace_repository(workspace_package, &facade.name, report); + + for field in REQUIRED_WORKSPACE_PACKAGE_FIELDS { + if !workspace_package.contains_key(*field) { + report.issues.push(ManifestIssue::warning( + "missing-workspace-package-field", + format!("missing [workspace.package].{field}"), + )); + } + } + + let Some(categories_value) = workspace_package.get("categories") else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-categories", + "missing [workspace.package].categories", + )); + return None; + }; + + let categories = + collect_category_strings(categories_value, "[workspace.package].categories", report); + + if let Some(categories) = categories.as_ref() { + validate_categories(categories, "[workspace.package].categories", report); + } + + categories +} */ + +/* fn analyze_package_table( + manifest: &toml::Value, + kind: ManifestKind, + facade_name: &str, + child_crate_names: &BTreeSet, + workspace_categories: Option<&[String]>, + report: &mut ManifestFileReport, +) { + let Some(package) = manifest.get("package").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::error( + "missing-package", + "crate manifest is missing [package]", + )); + return; + }; + + report.package_name = package + .get("name") + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned); + + for field in REQUIRED_PACKAGE_FIELDS { + if !package.contains_key(*field) { + report.issues.push(ManifestIssue::warning( + "missing-package-field", + format!("missing [package].{field}"), + )); + } + } + + if package.get("name").and_then(toml::Value::as_str).is_none() { + report.issues.push(ManifestIssue::error( + "invalid-package-name", + "expected [package].name to be a string", + )); + } + + if !has_string_or_workspace_true(package, "version") { + report.issues.push(ManifestIssue::warning( + "invalid-package-version", + "expected [package].version to be a string or use version.workspace = true", + )); + } + + match package.get("publish").and_then(toml::Value::as_bool) { + Some(true) => {}, + Some(false) => report.issues.push(ManifestIssue::warning( + "package-publish", + "expected [package].publish = true for publishable RustUse crates", + )), + None => report.issues.push(ManifestIssue::warning( + "missing-package-publish", + "missing [package].publish", + )), + } + + for field in EXPECTED_WORKSPACE_INHERITED_PACKAGE_FIELDS { + check_workspace_inherited_package_field(package, field, report); + } + + analyze_package_categories(package, workspace_categories, report); + + if kind == ManifestKind::FacadePackage { + validate_facade_dependency_and_feature_wiring( + manifest, + facade_name, + child_crate_names, + report, + ); + } +} */ +/* +fn analyze_package_categories( + package: &toml::Table, + workspace_categories: Option<&[String]>, + report: &mut ManifestFileReport, +) { + let Some(categories_value) = package.get("categories") else { + report.issues.push(ManifestIssue::warning( + "missing-package-categories", + "missing [package].categories or categories.workspace = true", + )); + return; + }; + + if is_workspace_true(categories_value) { + if workspace_categories.is_none() { + report.issues.push(ManifestIssue::error( + "missing-inherited-categories", + "package uses categories.workspace = true, but [workspace.package].categories is missing", + )); + } + + return; + } + + let Some(categories) = + collect_category_strings(categories_value, "[package].categories", report) + else { + return; + }; + + validate_categories(&categories, "[package].categories", report); +} */ + +/* fn check_workspace_inherited_package_field( + package: &toml::Table, + field: &'static str, + report: &mut ManifestFileReport, +) { + let Some(value) = package.get(field) else { + report.issues.push(ManifestIssue::warning( + "missing-package-inherited-field", + format!("missing [package].{field}.workspace = true"), + )); + return; + }; + + if is_workspace_true(value) { + return; + } + + report.issues.push(ManifestIssue::warning( + "package-field-not-inherited", + format!("expected [package].{field}.workspace = true"), + )); +} */ + +/* fn collect_category_strings( + value: &toml::Value, + field_name: &'static str, + report: &mut ManifestFileReport, +) -> Option> { + let Some(array) = value.as_array() else { + report.issues.push(ManifestIssue::error( + "invalid-categories-shape", + format!("expected {field_name} to be an array of strings"), + )); + return None; + }; + + let mut categories = Vec::new(); + + for (index, item) in array.iter().enumerate() { + let Some(category) = item.as_str() else { + report.issues.push(ManifestIssue::error( + "invalid-category-value", + format!("expected {field_name}[{index}] to be a string"), + )); + continue; + }; + + categories.push(category.to_string()); + } + + Some(categories) +} */ + +/* fn validate_categories( + categories: &[String], + field_name: &'static str, + report: &mut ManifestFileReport, +) { + if categories.len() > MAX_CRATES_IO_CATEGORIES { + report.issues.push(ManifestIssue::error( + "too-many-categories", + format!( + "{field_name} has {} categories; crates.io allows at most {}", + categories.len(), + MAX_CRATES_IO_CATEGORIES + ), + )); + } + + let mut seen = BTreeSet::new(); + + for category in categories { + if !seen.insert(category.as_str()) { + report.issues.push(ManifestIssue::warning( + "duplicate-category", + format!("{field_name} contains duplicate category `{category}`"), + )); + } + + if !is_valid_category_slug(category) { + report.issues.push(ManifestIssue::error( + "invalid-category-slug", + format!("`{category}` is not a valid crates.io category slug"), + )); + } + } +} */ + +/* fn has_string_or_workspace_true(package: &toml::Table, field: &'static str) -> bool { + let Some(value) = package.get(field) else { + return false; + }; + + value.as_str().is_some() || is_workspace_true(value) +} */ + +/* fn is_workspace_true(value: &toml::Value) -> bool { + value + .as_table() + .and_then(|table| table.get("workspace")) + .and_then(toml::Value::as_bool) + .unwrap_or(false) +} */ + +/* fn read_manifest(path: &Path, report: &mut ManifestFileReport) -> Option { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) => { + report.issues.push(ManifestIssue::error( + "read-manifest", + format!("failed to read `{}`: {error}", path.display()), + )); + return None; + }, + }; + + match toml::from_str(&raw) { + Ok(value) => Some(value), + Err(error) => { + report.issues.push(ManifestIssue::error( + "parse-manifest", + format!("failed to parse `{}`: {error}", path.display()), + )); + None + }, + } +} */ + +/* fn discover_crate_manifest_paths(crates_dir: &Path) -> Result> { + if !crates_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut manifests = Vec::new(); + + for entry in fs::read_dir(crates_dir) + .with_context(|| format!("failed to read `{}`", crates_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let manifest = path.join("Cargo.toml"); + + if manifest.is_file() { + manifests.push(manifest); + } + } + + manifests.sort(); + + Ok(manifests) +} */ + +/* fn relative_path(root: &Path, path: &Path) -> PathBuf { + path.strip_prefix(root).unwrap_or(path).to_path_buf() +} */ + +/* fn crate_dir_name(manifest_path: &Path) -> Option { + manifest_path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) +} */ + +/* #[derive(Debug, Clone)] +pub struct ChildCrateInfo { + // dir_name: String, + // package_name: Option, + version: Option, +} */ + +/* fn collect_child_crate_info( + crate_manifest_paths: &[PathBuf], + facade_name: &str, +) -> BTreeMap { + let mut child_crates = BTreeMap::new(); + + for manifest_path in crate_manifest_paths { + let Some(dir_name) = crate_dir_name(manifest_path) else { + continue; + }; + + if dir_name == facade_name { + continue; + } + + let raw = fs::read_to_string(manifest_path).ok(); + let manifest = raw + .as_deref() + .and_then(|raw| toml::from_str::(raw).ok()); + + let package = manifest + .as_ref() + .and_then(|manifest| manifest.get("package")) + .and_then(toml::Value::as_table); + + let version = package + .and_then(|package| package.get("version")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned); + + child_crates.insert( + dir_name.clone(), + ChildCrateInfo { + // dir_name, + // package_name, + version, + }, + ); + } + + child_crates +} */ + +/* +fn validate_workspace_members(workspace: &toml::Table, report: &mut ManifestFileReport) { + let Some(members) = workspace.get("members").and_then(toml::Value::as_array) else { + return; + }; + + let members = members + .iter() + .filter_map(toml::Value::as_str) + .collect::>(); + + for expected in EXPECTED_WORKSPACE_MEMBERS { + if !members.contains(expected) { + report.issues.push(ManifestIssue::warning( + "missing-standard-workspace-member", + format!("expected [workspace].members to include `{expected}`"), + )); + } + } + + if members.len() != EXPECTED_WORKSPACE_MEMBERS.len() { + report.issues.push(ManifestIssue::warning( + "non-standard-workspace-members", + "expected [workspace].members to be exactly [\"crates/\"]", + )); + } +} + +fn validate_workspace_repository( + workspace_package: &toml::Table, + facade_name: &str, + report: &mut ManifestFileReport, +) { + let expected = format!("{RUSTUSE_GITHUB_ORG}/{facade_name}"); + + match workspace_package + .get("repository") + .and_then(toml::Value::as_str) + { + Some(actual) if actual == expected => {}, + Some(actual) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-repository", + format!("expected [workspace.package].repository = `{expected}`, found `{actual}`"), + )), + None => {}, + } +} */ + +/* fn validate_workspace_dependencies( + workspace: &toml::Table, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + let Some(dependencies) = workspace + .get("dependencies") + .and_then(toml::Value::as_table) + else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependencies", + "missing [workspace.dependencies]", + )); + return; + }; + + for crate_name in child_crate_names { + let Some(dependency) = dependencies.get(crate_name) else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency", + format!("missing [workspace.dependencies].{crate_name}"), + )); + continue; + }; + + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency", + format!("expected [workspace.dependencies].{crate_name} to be an inline table"), + )); + continue; + }; + + let expected_path = format!("crates/{crate_name}"); + + match dependency_table.get("path").and_then(toml::Value::as_str) { + Some(actual_path) if actual_path == expected_path => {} + Some(actual_path) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-path", + format!( + "expected [workspace.dependencies].{crate_name}.path = `{expected_path}`, found `{actual_path}`" + ), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency-path", + format!("missing [workspace.dependencies].{crate_name}.path"), + )), + } + + if dependency_table + .get("version") + .and_then(toml::Value::as_str) + .is_none() + { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency-version", + format!("missing [workspace.dependencies].{crate_name}.version"), + )); + } + } +} */ + +/* fn validate_workspace_dependencies( + workspace: &toml::Table, + child_crates: &BTreeMap, + report: &mut ManifestFileReport, +) { + let Some(dependencies) = workspace + .get("dependencies") + .and_then(toml::Value::as_table) + else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependencies", + "missing [workspace.dependencies]", + )); + return; + }; + + validate_all_workspace_dependency_versions(dependencies, report); + validate_child_workspace_dependencies(dependencies, child_crates, report); + validate_orphan_workspace_dependency_paths(dependencies, child_crates, report); +} + +fn validate_all_workspace_dependency_versions( + dependencies: &toml::Table, + report: &mut ManifestFileReport, +) { + for (dependency_name, dependency) in dependencies { + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-shape", + format!( + "expected [workspace.dependencies].{dependency_name} to be an inline table with version" + ), + )); + continue; + }; + + match dependency_table.get("version") { + Some(version) if version.as_str().is_some_and(|value| !value.trim().is_empty()) => {}, + Some(_) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-version", + format!( + "expected [workspace.dependencies].{dependency_name}.version to be a non-empty string" + ), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency-version", + format!("missing [workspace.dependencies].{dependency_name}.version"), + )), + } + } +} + +fn validate_child_workspace_dependencies( + dependencies: &toml::Table, + child_crates: &BTreeMap, + report: &mut ManifestFileReport, +) { + for (crate_name, child_crate) in child_crates { + let Some(dependency) = dependencies.get(crate_name) else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency", + format!("missing [workspace.dependencies].{crate_name}"), + )); + continue; + }; + + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-shape", + format!("expected [workspace.dependencies].{crate_name} to be an inline table"), + )); + continue; + }; + + let expected_path = format!("crates/{crate_name}"); + + match dependency_table.get("path").and_then(toml::Value::as_str) { + Some(actual_path) if actual_path == expected_path => {}, + Some(actual_path) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-path", + format!( + "expected [workspace.dependencies].{crate_name}.path = `{expected_path}`, found `{actual_path}`" + ), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency-path", + format!("missing [workspace.dependencies].{crate_name}.path"), + )), + } + + let actual_version = dependency_table + .get("version") + .and_then(toml::Value::as_str); + + if let (Some(expected_version), Some(actual_version)) = + (child_crate.version.as_deref(), actual_version) + { + if actual_version != expected_version { + report.issues.push(ManifestIssue::warning( + "mismatched-workspace-dependency-version", + format!( + "expected [workspace.dependencies].{crate_name}.version = `{expected_version}`, found `{actual_version}`" + ), + )); + } + } + } +} + +fn validate_orphan_workspace_dependency_paths( + dependencies: &toml::Table, + child_crates: &BTreeMap, + report: &mut ManifestFileReport, +) { + for (dependency_name, dependency) in dependencies { + let Some(dependency_table) = dependency.as_table() else { + continue; + }; + + let Some(path) = dependency_table.get("path").and_then(toml::Value::as_str) else { + continue; + }; + + let Some(crate_name) = path.strip_prefix("crates/") else { + continue; + }; + + if !child_crates.contains_key(crate_name) { + report.issues.push(ManifestIssue::warning( + "orphan-workspace-dependency-path", + format!( + "[workspace.dependencies].{dependency_name}.path points to `{path}`, but no matching child crate manifest was found" + ), + )); + } + } +} + +fn validate_workspace_lints(workspace: &toml::Table, report: &mut ManifestFileReport) { + let unsafe_code = workspace + .get("lints") + .and_then(|lints| lints.get("rust")) + .and_then(|rust| rust.get("unsafe_code")) + .and_then(toml::Value::as_str); + + match unsafe_code { + Some("forbid") => {}, + Some(actual) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-unsafe-code-policy", + format!("expected [workspace.lints.rust].unsafe_code = \"forbid\", found `{actual}`"), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-unsafe-code-policy", + "missing [workspace.lints.rust].unsafe_code = \"forbid\"", + )), + } + + let clippy = workspace + .get("lints") + .and_then(|lints| lints.get("clippy")) + .and_then(toml::Value::as_table); + + if clippy.is_none() { + report.issues.push(ManifestIssue::warning( + "missing-workspace-clippy-lints", + "missing [workspace.lints.clippy]", + )); + } +} + +fn validate_facade_dependency_and_feature_wiring( + manifest: &toml::Value, + _facade_name: &str, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + validate_facade_child_dependencies(manifest, child_crate_names, report); + validate_facade_features(manifest, child_crate_names, report); +} + +fn validate_facade_child_dependencies( + manifest: &toml::Value, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + if child_crate_names.is_empty() { + return; + } + + let Some(dependencies) = manifest.get("dependencies").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::warning( + "missing-facade-dependencies", + "facade crate has child crates but no [dependencies]", + )); + return; + }; + + for child_crate in child_crate_names { + let Some(dependency) = dependencies.get(child_crate) else { + report.issues.push(ManifestIssue::warning( + "missing-facade-child-dependency", + format!("missing [dependencies].{child_crate}"), + )); + continue; + }; + + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-facade-child-dependency", + format!("expected [dependencies].{child_crate} to be an inline table"), + )); + continue; + }; + + if !is_workspace_true(dependency) { + report.issues.push(ManifestIssue::warning( + "invalid-facade-child-dependency", + format!("expected [dependencies].{child_crate}.workspace = true"), + )); + } + + match dependency_table + .get("optional") + .and_then(toml::Value::as_bool) + { + Some(true) => {}, + Some(false) => report.issues.push(ManifestIssue::warning( + "invalid-facade-child-dependency", + format!("expected [dependencies].{child_crate}.optional = true"), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-facade-child-dependency-optional", + format!("missing [dependencies].{child_crate}.optional = true"), + )), + } + } +} + +fn validate_facade_features( + manifest: &toml::Value, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + if child_crate_names.is_empty() { + return; + } + + let Some(features) = manifest.get("features").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::warning( + "missing-facade-features", + "facade crate has child crates but no [features]", + )); + return; + }; + + match features.get("default").and_then(toml::Value::as_array) { + Some(default_features) if default_features.is_empty() => {}, + Some(_) => report.issues.push(ManifestIssue::warning( + "invalid-facade-default-features", + "expected [features].default = []", + )), + None => report.issues.push(ManifestIssue::warning( + "missing-facade-default-features", + "missing [features].default = []", + )), + } + + let full_features = features + .get("full") + .and_then(toml::Value::as_array) + .map(|values| array_string_set(values)); + + if full_features.is_none() { + report.issues.push(ManifestIssue::warning( + "missing-facade-full-feature", + "missing [features].full", + )); + } + + for child_crate in child_crate_names { + let feature_name = feature_name_for_child_crate(child_crate); + + if let Some(full_features) = &full_features { + if !full_features.contains(feature_name.as_str()) { + report.issues.push(ManifestIssue::warning( + "missing-full-feature-member", + format!("expected [features].full to include `{feature_name}`"), + )); + } + } + + let Some(feature_values) = features + .get(feature_name.as_str()) + .and_then(toml::Value::as_array) + else { + report.issues.push(ManifestIssue::warning( + "missing-facade-child-feature", + format!("missing [features].{feature_name}"), + )); + continue; + }; + + let feature_values = array_string_set(feature_values); + let expected_dep = format!("dep:{child_crate}"); + + if !feature_values.contains(expected_dep.as_str()) { + report.issues.push(ManifestIssue::warning( + "invalid-facade-child-feature", + format!("expected [features].{feature_name} to include `{expected_dep}`"), + )); + } + } +} */ + +/* fn feature_name_for_child_crate(crate_name: &str) -> String { + crate_name + .strip_prefix("use-") + .unwrap_or(crate_name) + .to_string() +} + +fn array_string_set(array: &[toml::Value]) -> BTreeSet { + array + .iter() + .filter_map(toml::Value::as_str) + .map(ToOwned::to_owned) + .collect() +} */ + +fn has_manifest_filters(args: &DevRootManifestArgs) -> bool { + args.facade.is_some() || args.code.is_some() || args.kind.is_some() || args.severity.is_some() +} + +fn matching_issue_count(reports: &[FacadeManifestReport], args: &DevRootManifestArgs) -> usize { + reports + .iter() + .filter(|report| facade_matches_filter(report, args)) + .flat_map(|report| report.manifests.iter()) + .filter(|manifest| manifest_matches_filters(manifest, args)) + .flat_map(|manifest| manifest.issues.iter()) + .filter(|issue| issue_matches_filters(issue, args)) + .count() +} + +fn facade_matches_filter(report: &FacadeManifestReport, args: &DevRootManifestArgs) -> bool { + option_str_matches(args.facade.as_deref(), report.facade_name.as_str()) +} + +fn manifest_matches_filters(manifest: &ManifestFileReport, args: &DevRootManifestArgs) -> bool { + if !option_str_matches(args.kind.as_deref(), manifest.kind.as_str()) { + return false; + } + + if args.code.is_none() && args.severity.is_none() { + return true; + } + + manifest + .issues + .iter() + .any(|issue| issue_matches_filters(issue, args)) +} + +fn issue_matches_filters(issue: &ManifestIssue, args: &DevRootManifestArgs) -> bool { + option_str_matches(args.code.as_deref(), issue.code) + && option_str_matches(args.severity.as_deref(), issue.severity.as_str()) +} + +fn option_str_matches(filter: Option<&str>, value: &str) -> bool { + match filter { + Some(filter) => filter == value, + None => true, + } +} diff --git a/src/commands/dev/root/publish.rs b/src/commands/dev/root/publish.rs new file mode 100644 index 0000000..0123565 --- /dev/null +++ b/src/commands/dev/root/publish.rs @@ -0,0 +1,3 @@ +// Publish +// publish.rs should be a long running script. It should have a 10 minute pause in between each publish to avoid rate limiting. +// It should be able to skip crates that are on the latest version when checking local Cargo.toml version and crates.io diff --git a/src/commands/dev/root/report.rs b/src/commands/dev/root/report.rs new file mode 100644 index 0000000..e21a200 --- /dev/null +++ b/src/commands/dev/root/report.rs @@ -0,0 +1,714 @@ +//! Markdown report generation for a local RustUse development root. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use anyhow::{Context, Result}; + +use super::DevRootReportArgs; +use super::discover::{FacadeEntry, discover_facades, discover_root_repos}; +use super::manifest_flags::manifest_shape_bucket; +use super::standards::{StandardFileReport, analyze_exact_standard_files}; +use crate::commands::dev::utils::manifest::{FacadeManifestReport, analyze_manifests}; +use crate::output::Output; + +/* use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::cli::DevRootReportArgs; +use crate::dev::root::discover::{FacadeEntry, discover_facades, discover_root_repos}; +use crate::dev::root::manifest_flags::manifest_shape_bucket; +use crate::dev::root::manifests::{FacadeManifestReport, analyze_manifests}; +use crate::dev::root::standards::{StandardFileReport, analyze_exact_standard_files}; +use crate::output::Output; */ + +pub(crate) fn run(args: DevRootReportArgs, output: Output) -> Result<()> { + let root = fs::canonicalize(&args.root).unwrap_or(args.root); + let report = build_report(&root)?; + + output.line(format!( + "RustUse dev root report - root: {}", + root.display() + )); + + if args.stdout { + print!("{report}"); + return Ok(()); + } + + let output_path = args + .output + .unwrap_or_else(|| root.join("rustuse-report.md")); + + write_report(&output_path, &report)?; + + output.line(format!("wrote: {}", output_path.display())); + + Ok(()) +} + +fn write_report(path: &Path, report: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + + let file = + File::create(path).with_context(|| format!("failed to create `{}`", path.display()))?; + let mut writer = BufWriter::new(file); + + writer + .write_all(report.as_bytes()) + .with_context(|| format!("failed to write `{}`", path.display()))?; + + writer + .flush() + .with_context(|| format!("failed to flush `{}`", path.display()))?; + + Ok(()) +} + +fn build_report(root: &Path) -> Result { + let root_name = root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let has_cli = root.join("cli").is_dir(); + let has_docs = root.join("docs").is_dir(); + let facades = discover_facades(root)?; + let root_repos = discover_root_repos(root); + + let use_dir_count = facades.len(); + let git_count = facades.iter().filter(|facade| facade.has_git).count(); + let missing_git_count = use_dir_count.saturating_sub(git_count); + let missing_git = facades + .iter() + .filter(|facade| !facade.has_git) + .collect::>(); + let child_crate_count: usize = facades.iter().map(|facade| facade.child_crate_count).sum(); + + let missing_root_repos = root_repos + .iter() + .filter(|repo| !repo.present) + .map(|repo| repo.name) + .collect::>(); + let missing_root_repo_count = missing_root_repos.len(); + + let standard_file_reports = analyze_exact_standard_files(root, &facades)?; + + let has_standard_file_drift = standard_file_reports + .iter() + .any(|report| !report.is_consistent(use_dir_count)); + + let manifest_reports = analyze_manifests(root, &facades)?; + let manifest_count = manifest_reports + .iter() + .map(FacadeManifestReport::manifest_count) + .sum::(); + let manifest_issue_count = manifest_reports + .iter() + .map(FacadeManifestReport::issue_count) + .sum::(); + let manifest_error_count = manifest_reports + .iter() + .map(FacadeManifestReport::error_count) + .sum::(); + let manifest_warning_count = manifest_reports + .iter() + .map(FacadeManifestReport::warning_count) + .sum::(); + let invalid_category_count = manifest_reports + .iter() + .map(FacadeManifestReport::invalid_category_count) + .sum::(); + + let has_warning = !has_cli + || !has_docs + || missing_root_repo_count > 0 + || use_dir_count == 0 + || missing_git_count > 0 + || has_standard_file_drift + || manifest_warning_count > 0; + + let status = if manifest_error_count > 0 { + "error" + } else if has_warning { + "warning" + } else { + "ok" + }; + + let mut markdown = String::new(); + + markdown.push_str("# RustUse Development Root Report\n\n"); + markdown.push_str("## Summary\n\n"); + markdown.push_str(&format!("- Root: `{}`\n", root.display())); + markdown.push_str(&format!("- Root name: `{root_name}`\n")); + markdown.push_str(&format!( + "- Standard root name: {}\n", + if root_name == "rustuse" { + "yes" + } else { + "no, expected `rustuse`" + } + )); + + markdown.push('\n'); + markdown.push_str("### Root Repositories\n\n"); + markdown.push_str("| Repository | Present | Git |\n"); + markdown.push_str("|---|---:|---:|\n"); + + for repo in &root_repos { + markdown.push_str(&format!( + "| `{}` | {} | {} |\n", + repo.name, + yes_no(repo.present), + yes_no(repo.has_git) + )); + } + + markdown.push('\n'); + markdown.push_str(&format!("- `use-*` directories: {use_dir_count}\n")); + markdown.push_str(&format!("- Facade repos with `.git`: {git_count}\n")); + markdown.push_str(&format!( + "- `use-*` directories missing `.git`: {missing_git_count}\n" + )); + markdown.push_str(&format!("- Child crates detected: {child_crate_count}\n")); + markdown.push_str(&format!("- Cargo manifests inspected: {manifest_count}\n")); + markdown.push_str(&format!( + "- Cargo manifest issues: {manifest_issue_count} ({manifest_error_count} error(s), {manifest_warning_count} warning(s))\n" + )); + markdown.push_str(&format!( + "- Invalid crates.io category slugs: {invalid_category_count}\n" + )); + markdown.push_str(&format!("- Status: **{status}**\n\n")); + + write_contents(&mut markdown); + + write_action_plan( + &mut markdown, + root_name, + &missing_root_repos, + &missing_git, + &standard_file_reports, + &manifest_reports, + use_dir_count, + ); + + write_manifest_summary( + &mut markdown, + &manifest_reports, + manifest_count, + manifest_issue_count, + manifest_error_count, + manifest_warning_count, + invalid_category_count, + ); + + markdown.push_str("## Standard File Consistency\n\n"); + markdown.push_str("| File | Present | Missing | Variants | Consistent |\n"); + markdown.push_str("|---|---:|---:|---:|---:|\n"); + + for report in &standard_file_reports { + markdown.push_str(&format!( + "| `{}` | {}/{} | {} | {} | {} |\n", + report.file_name, + report.present_count, + use_dir_count, + report.missing.len(), + report.variants.len(), + yes_no(report.is_consistent(use_dir_count)) + )); + } + + markdown.push('\n'); + + for report in &standard_file_reports { + write_standard_file_report(&mut markdown, report, use_dir_count); + } + + markdown.push_str("## Facade Inventory\n\n"); + markdown + .push_str("| Status | Facade | Version | Git | Cargo.toml | crates/ | Child crates |\n"); + markdown.push_str("|---|---|---:|---:|---:|---:|---:|\n"); + + for facade in &facades { + markdown.push_str(&format!( + "| {} | `{}` | {} | {} | {} | {} | {} |\n", + facade.status(), + facade.name, + display_markdown_version(&facade.version), + yes_no(facade.has_git), + yes_no(facade.has_cargo_toml), + yes_no(facade.has_crates_dir), + facade.child_crate_count + )); + } + + if !missing_git.is_empty() { + markdown.push_str("\n## `use-*` Directories Missing `.git`\n\n"); + + for facade in missing_git { + markdown.push_str(&format!("- `{}`\n", facade.name)); + } + } + + markdown.push_str("\n## Notes\n\n"); + markdown.push_str("- This report is generated from the local filesystem.\n"); + markdown.push_str("- `use-*` directories are treated as facade candidates.\n"); + markdown.push_str("- A facade repo is expected to contain its own `.git` directory.\n"); + markdown.push_str( + "- `crates/` child counts only include direct child directories with `Cargo.toml`.\n", + ); + markdown.push_str( + "- Manifest errors are treated as publish blockers because crates.io rejects invalid category slugs and other invalid Cargo metadata.\n", + ); + markdown.push_str( + "- Manifest warnings are treated as RustUse standardization debt, not necessarily publish blockers.\n", + ); + + Ok(markdown) +} + +fn write_contents(markdown: &mut String) { + markdown.push_str("## Contents\n\n"); + markdown.push_str("- [Action Plan](#action-plan)\n"); + markdown.push_str("- [Cargo Manifest Health](#cargo-manifest-health)\n"); + markdown.push_str(" - [Manifest Issue Summary](#manifest-issue-summary)\n"); + markdown.push_str(" - [Manifest Shape Summary](#manifest-shape-summary)\n"); + markdown.push_str(" - [Manifest Summary by Facade](#manifest-summary-by-facade)\n"); + markdown.push_str(" - [Manifest Issues](#manifest-issues)\n"); + markdown.push_str("- [Standard File Consistency](#standard-file-consistency)\n"); + markdown.push_str("- [Facade Inventory](#facade-inventory)\n"); + markdown.push_str("- [Notes](#notes)\n\n"); +} + +fn write_action_plan( + markdown: &mut String, + root_name: &str, + missing_root_repos: &[&str], + missing_git: &[&FacadeEntry], + standard_file_reports: &[StandardFileReport], + manifest_reports: &[FacadeManifestReport], + expected_count: usize, +) { + let drifting_files = standard_file_reports + .iter() + .filter(|report| !report.is_consistent(expected_count)) + .collect::>(); + + let manifest_error_count = manifest_reports + .iter() + .map(FacadeManifestReport::error_count) + .sum::(); + let manifest_warning_count = manifest_reports + .iter() + .map(FacadeManifestReport::warning_count) + .sum::(); + let invalid_category_count = manifest_reports + .iter() + .map(FacadeManifestReport::invalid_category_count) + .sum::(); + + let manifest_error_facades = manifest_reports + .iter() + .filter(|report| report.error_count() > 0) + .collect::>(); + + let has_action = root_name != "rustuse" + || !missing_root_repos.is_empty() + || !missing_git.is_empty() + || !drifting_files.is_empty() + || manifest_error_count > 0 + || manifest_warning_count > 0; + + markdown.push_str("## Action Plan\n\n"); + + if !has_action { + markdown.push_str("- No action required.\n\n"); + return; + } + + if manifest_error_count > 0 { + markdown.push_str(&format!( + "- **Fix Cargo.toml errors first.** `scan` can be clean while manifests are still not publish-clean. There are {manifest_error_count} manifest error(s), including {invalid_category_count} invalid crates.io category slug(s).\n" + )); + markdown.push_str("- Prioritize manifest errors before standard-file drift. These can block publishing or break RustUse workspace consistency.\n"); + } + + if manifest_warning_count > 0 { + markdown.push_str(&format!( + "- Clean up Cargo.toml warnings next. There are {manifest_warning_count} warning(s). Focus on workspace shape, facade dependency/feature wiring, package metadata, README files, docs.rs metadata, lints inheritance, and inherited categories.\n" + )); + } + + if !manifest_error_facades.is_empty() { + markdown.push_str("- Manifest error facades to fix first:\n"); + + for report in manifest_error_facades { + markdown.push_str(&format!( + " - `{}`: {} error(s), {} invalid category slug(s)\n", + report.facade_name, + report.error_count(), + report.invalid_category_count() + )); + } + } + + if root_name != "rustuse" { + markdown.push_str(&format!( + "- Rename the local development root from `{root_name}` to `rustuse` when convenient. This is lower priority than manifest publish blockers.\n" + )); + } + + if !missing_root_repos.is_empty() { + markdown.push_str("- Restore missing root repositories:\n"); + + for repo in missing_root_repos { + markdown.push_str(&format!(" - `{repo}`\n")); + } + } + + if !missing_git.is_empty() { + markdown.push_str("- Fix `use-*` directories missing `.git`:\n"); + + for facade in missing_git { + markdown.push_str(&format!(" - `{}`\n", facade.name)); + } + } + + if !drifting_files.is_empty() { + markdown + .push_str("- Normalize standard files with drift after manifest errors are fixed:\n"); + + for report in drifting_files { + markdown.push_str(&format!( + " - `{}`: {} variant(s), {} missing\n", + report.file_name, + report.variants.len(), + report.missing.len() + )); + } + } + + markdown.push('\n'); +} + +fn write_manifest_summary( + markdown: &mut String, + reports: &[FacadeManifestReport], + manifest_count: usize, + issue_count: usize, + error_count: usize, + warning_count: usize, + invalid_category_count: usize, +) { + markdown.push_str("## Cargo Manifest Health\n\n"); + markdown.push_str(&format!("- Facades inspected: {}\n", reports.len())); + markdown.push_str(&format!("- Manifests inspected: {manifest_count}\n")); + markdown.push_str(&format!( + "- Issues: {issue_count} ({error_count} error(s), {warning_count} warning(s))\n" + )); + markdown.push_str(&format!( + "- Invalid crates.io category slugs: {invalid_category_count}\n" + )); + + if error_count > 0 { + markdown.push_str( + "- Candor: this is the most important section of the report right now. Standard file drift is annoying; invalid manifest metadata blocks publishing or breaks RustUse shape.\n", + ); + } else if warning_count > 0 { + markdown.push_str( + "- Candor: the manifest layer is publishable, but not standardized. The warnings are maintainability debt.\n", + ); + } else { + markdown.push_str("- Manifest layer is clean.\n"); + } + + markdown.push('\n'); + + write_manifest_issue_summary(markdown, reports); + write_manifest_shape_summary(markdown, reports); + + markdown.push_str("### Manifest Summary by Facade\n\n"); + markdown.push_str("| Status | Facade | Manifests | Errors | Warnings | Invalid Categories |\n"); + markdown.push_str("|---|---|---:|---:|---:|---:|\n"); + + for report in reports { + markdown.push_str(&format!( + "| {} | `{}` | {} | {} | {} | {} |\n", + report.status(), + report.facade_name, + report.manifest_count(), + report.error_count(), + report.warning_count(), + report.invalid_category_count() + )); + } + + markdown.push('\n'); + + write_manifest_issues(markdown, reports); +} + +fn write_manifest_issue_summary(markdown: &mut String, reports: &[FacadeManifestReport]) { + let mut summary: BTreeMap<(String, String), usize> = BTreeMap::new(); + + for facade_report in reports { + for manifest in &facade_report.manifests { + for issue in &manifest.issues { + let key = (issue.severity.as_str().to_string(), issue.code.to_string()); + *summary.entry(key).or_default() += 1; + } + } + } + + if summary.is_empty() { + return; + } + + let mut rows = summary + .into_iter() + .map(|((severity, code), count)| (severity, code, count)) + .collect::>(); + + rows.sort_by(|left, right| { + right + .2 + .cmp(&left.2) + .then_with(|| left.0.cmp(&right.0)) + .then_with(|| left.1.cmp(&right.1)) + }); + + markdown.push_str("### Manifest Issue Summary\n\n"); + markdown.push_str("| Severity | Code | Count |\n"); + markdown.push_str("|---|---|---:|\n"); + + for (severity, code, count) in rows { + markdown.push_str(&format!("| `{severity}` | `{code}` | {count} |\n")); + } + + markdown.push('\n'); +} + +fn write_manifest_shape_summary(markdown: &mut String, reports: &[FacadeManifestReport]) { + let mut buckets: BTreeMap<&'static str, usize> = BTreeMap::new(); + + for facade_report in reports { + for manifest in &facade_report.manifests { + for issue in &manifest.issues { + let bucket = manifest_shape_bucket(issue.code); + *buckets.entry(bucket).or_default() += 1; + } + } + } + + if buckets.is_empty() { + return; + } + + let mut rows = buckets.into_iter().collect::>(); + + rows.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + + markdown.push_str("### Manifest Shape Summary\n\n"); + markdown.push_str("| Shape Area | Issues |\n"); + markdown.push_str("|---|---:|\n"); + + for (bucket, count) in rows { + markdown.push_str(&format!("| {bucket} | {count} |\n")); + } + + markdown.push('\n'); +} + +/* fn manifest_shape_bucket(code: &str) -> &'static str { + match code { + "missing-standard-workspace-member" + | "non-standard-workspace-members" + | "missing-workspace" + | "missing-workspace-members" + | "invalid-workspace-members" + | "missing-workspace-resolver" + | "workspace-resolver" + | "missing-workspace-package" + | "missing-workspace-package-field" + | "invalid-workspace-repository" + | "missing-workspace-dependencies" + | "missing-workspace-dependency" + | "invalid-workspace-dependency" + | "invalid-workspace-dependency-path" + | "missing-workspace-dependency-path" + | "missing-workspace-dependency-version" + | "missing-workspace-unsafe-code-policy" + | "invalid-workspace-unsafe-code-policy" + | "missing-workspace-clippy-lints" => "Workspace shape", + + "missing-facade-dependencies" + | "missing-facade-child-dependency" + | "invalid-facade-child-dependency" + | "missing-facade-child-dependency-optional" + | "missing-facade-features" + | "invalid-facade-default-features" + | "missing-facade-default-features" + | "missing-facade-full-feature" + | "missing-full-feature-member" + | "missing-facade-child-feature" + | "invalid-facade-child-feature" => "Facade wiring", + + "invalid-package-homepage" + | "invalid-package-documentation" + | "missing-package-readme-file" + | "missing-docs-rs-all-features" + | "invalid-docs-rs-all-features" + | "missing-lints-workspace" + | "package-name-directory-mismatch" + | "invalid-facade-package-name" + | "invalid-child-package-name" => "Package shape", + + "invalid-category-slug" + | "too-many-categories" + | "duplicate-category" + | "invalid-categories-shape" + | "invalid-category-value" + | "missing-workspace-categories" + | "missing-package-categories" + | "missing-inherited-categories" => "Category metadata", + + _ => "General metadata", + } +} */ + +fn write_manifest_issues(markdown: &mut String, reports: &[FacadeManifestReport]) { + let reports_with_issues = reports + .iter() + .filter(|report| report.issue_count() > 0) + .collect::>(); + + if reports_with_issues.is_empty() { + return; + } + + markdown.push_str("### Manifest Issues\n\n"); + + for facade_report in reports_with_issues { + markdown.push_str(&format!("#### `{}`\n\n", facade_report.facade_name)); + + for manifest in &facade_report.manifests { + if manifest.issues.is_empty() { + continue; + } + + markdown.push_str(&format!("##### `{}`\n\n", manifest.path.display())); + markdown.push_str(&format!("- Kind: `{}`\n", manifest.kind.as_str())); + markdown.push_str(&format!("- Status: **{}**\n", manifest.status())); + + if let Some(package_name) = &manifest.package_name { + markdown.push_str(&format!("- Package: `{package_name}`\n")); + } + + markdown.push_str("- Issues:\n"); + + for issue in &manifest.issues { + markdown.push_str(&format!( + " - **{}** `{}`: {}\n", + issue.severity.as_str(), + issue.code, + issue.message + )); + } + + markdown.push('\n'); + } + } +} + +fn write_standard_file_report( + markdown: &mut String, + report: &StandardFileReport, + expected_count: usize, +) { + markdown.push_str(&format!("## `{}` Consistency\n\n", report.file_name)); + markdown.push_str(&format!( + "- Present: {}/{}\n", + report.present_count, expected_count + )); + markdown.push_str(&format!("- Missing: {}\n", report.missing.len())); + markdown.push_str(&format!("- Content variants: {}\n", report.variants.len())); + markdown.push_str(&format!( + "- Consistent: {}\n", + yes_no(report.is_consistent(expected_count)) + )); + + if let Some(majority) = report.variants.first() { + markdown.push_str(&format!( + "- Majority variant: `{}` used by {} facade(s)\n", + majority.hash, + majority.facades.len() + )); + } + + markdown.push('\n'); + + if !report.missing.is_empty() { + markdown.push_str(&format!("### Missing `{}`\n\n", report.file_name)); + + for facade in &report.missing { + markdown.push_str(&format!("- `{facade}`\n")); + } + + markdown.push('\n'); + } + + if !report.variants.is_empty() { + markdown.push_str(&format!("### `{}` Variants\n\n", report.file_name)); + markdown.push_str("| Variant | Facades | Lines | Bytes | Examples |\n"); + markdown.push_str("|---|---:|---:|---:|---|\n"); + + for variant in &report.variants { + markdown.push_str(&format!( + "| `{}` | {} | {} | {} | {} |\n", + variant.hash, + variant.facades.len(), + variant.line_count, + variant.byte_len, + sample_facades(&variant.facades) + )); + } + + markdown.push('\n'); + } +} + +fn sample_facades(facades: &[String]) -> String { + let sample_count = 8; + let mut names = facades + .iter() + .take(sample_count) + .map(|name| format!("`{name}`")) + .collect::>(); + + if facades.len() > sample_count { + names.push(format!("... +{} more", facades.len() - sample_count)); + } + + names.join(", ") +} + +fn display_markdown_version(version: &Option) -> String { + match version { + Some(version) => format!("`{version}`"), + None => "``".to_string(), + } +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/src/commands/dev/root/scan.rs b/src/commands/dev/root/scan.rs new file mode 100644 index 0000000..eb3621f --- /dev/null +++ b/src/commands/dev/root/scan.rs @@ -0,0 +1,114 @@ +//! Root scan command for listing local RustUse facade repositories. + +use std::fs; + +use anyhow::Result; + +use super::DevRootPathArgs; +use super::discover::{FacadeEntry, discover_facades, display_version}; +use crate::output::Output; + +pub(crate) fn run(args: DevRootPathArgs, output: Output) -> Result<()> { + let root = fs::canonicalize(&args.root).unwrap_or(args.root); + let facades = discover_facades(&root)?; + let summary = ScanSummary::from_facades(&facades); + + output.line(format!("RustUse dev root scan - root: {}", root.display())); + output.line(format!("found {} use-* directories", facades.len())); + output.line(format!("facade repos with .git: {}", summary.repo_count)); + output.line(format!( + "facades missing .git: {}", + summary.missing_git_count + )); + output.line(format!( + "child crates detected: {}", + summary.child_crate_count + )); + output.line(""); + + print_facade_table(&facades, output); + + if summary.missing_git_count > 0 { + print_missing_git_facades(&facades, output); + } + + output.line(""); + output.line(format!("status: {}", summary.status())); + + Ok(()) +} + +#[derive(Debug)] +struct ScanSummary { + repo_count: usize, + missing_git_count: usize, + child_crate_count: usize, + warning_count: usize, + is_empty: bool, +} + +impl ScanSummary { + fn from_facades(facades: &[FacadeEntry]) -> Self { + let repo_count = facades.iter().filter(|facade| facade.has_git()).count(); + let missing_git_count = facades.len().saturating_sub(repo_count); + let child_crate_count = facades.iter().map(FacadeEntry::child_crate_count).sum(); + let warning_count = facades + .iter() + .filter(|facade| facade.status() != "ok") + .count(); + + Self { + repo_count, + missing_git_count, + child_crate_count, + warning_count, + is_empty: facades.is_empty(), + } + } + + fn status(&self) -> &'static str { + if self.is_empty || self.warning_count > 0 { + "warning" + } else { + "ok" + } + } +} + +fn print_facade_table(facades: &[FacadeEntry], output: Output) { + output.line(format!( + "{:<8} {:<24} {:<10} {:<5} {:<10} {:<8} {:>6}", + "Status", "Facade", "Version", "Git", "Cargo.toml", "crates/", "Children" + )); + + output.line(format!( + "{:<8} {:<24} {:<10} {:<5} {:<10} {:<8} {:>6}", + "------", "------", "-------", "---", "----------", "-------", "--------" + )); + + for facade in facades { + output.line(format!( + "{:<8} {:<24} {:<10} {:<5} {:<10} {:<8} {:>6}", + facade.status(), + facade.name, + display_version(&facade.version), + yes_no(facade.has_git()), + yes_no(facade.has_cargo_toml()), + yes_no(facade.has_crates_dir()), + facade.child_crate_count(), + )); + } +} + +fn print_missing_git_facades(facades: &[FacadeEntry], output: Output) { + output.line(""); + output.line("facades missing .git:"); + + for facade in facades.iter().filter(|facade| !facade.has_git()) { + output.line(format!("- {}", facade.name)); + } +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/src/commands/dev/root/standards.rs b/src/commands/dev/root/standards.rs new file mode 100644 index 0000000..56b0e6e --- /dev/null +++ b/src/commands/dev/root/standards.rs @@ -0,0 +1,137 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; + +use super::discover::FacadeEntry; + +pub(crate) const EXACT_STANDARD_FILES: &[&str] = &[ + ".clippy.toml", + ".editorconfig", + ".gitattributes", + ".gitignore", + ".gitlab-ci.yml", + ".gitleaks.toml", + ".markdownlintignore", + ".rustfmt.toml", + ".taplo.toml", + ".trivyignore", + "Cargo.lock", + "Cargo.toml", + "CRATE_TEMPLATE.md", + "deny.toml", + "LICENSE-APACHE", + "LICENSE-MIT", + "Makefile", + "README.md", + "release-plz.toml", + "rust-toolchain.toml", +]; + +#[derive(Debug)] +pub(crate) struct StandardFileReport { + pub(crate) file_name: &'static str, + pub(crate) present_count: usize, + pub(crate) missing: Vec, + pub(crate) variants: Vec, +} + +impl StandardFileReport { + pub(crate) fn is_consistent(&self, expected_count: usize) -> bool { + self.present_count == expected_count && self.variants.len() == 1 + } +} + +#[derive(Debug)] +pub(crate) struct StandardFileVariant { + pub(crate) hash: String, + pub(crate) byte_len: usize, + pub(crate) line_count: usize, + pub(crate) facades: Vec, +} + +pub(crate) fn analyze_exact_standard_files( + root: &Path, + facades: &[FacadeEntry], +) -> Result> { + EXACT_STANDARD_FILES + .iter() + .map(|file_name| analyze_standard_file(root, facades, file_name)) + .collect() +} + +pub(crate) fn analyze_standard_file( + root: &Path, + facades: &[FacadeEntry], + file_name: &'static str, +) -> Result { + let mut missing = Vec::new(); + let mut variants: BTreeMap> = BTreeMap::new(); + + for facade in facades { + let file_path = root.join(&facade.name).join(file_name); + + if !file_path.is_file() { + missing.push(facade.name.clone()); + continue; + } + + let content = read_normalized_text_file(&file_path)?; + variants + .entry(content) + .or_default() + .push(facade.name.clone()); + } + + missing.sort(); + + let present_count: usize = variants.values().map(Vec::len).sum(); + + let mut variants = variants + .into_iter() + .map(|(content, mut facades)| { + facades.sort(); + + StandardFileVariant { + hash: stable_hash_hex(&content), + byte_len: content.len(), + line_count: content.lines().count(), + facades, + } + }) + .collect::>(); + + variants.sort_by(|left, right| { + right + .facades + .len() + .cmp(&left.facades.len()) + .then_with(|| left.hash.cmp(&right.hash)) + }); + + Ok(StandardFileReport { + file_name, + present_count, + missing, + variants, + }) +} + +fn read_normalized_text_file(path: &Path) -> Result { + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read `{}`", path.display()))?; + + Ok(raw.replace("\r\n", "\n").replace('\r', "\n")) +} + +fn stable_hash_hex(content: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + + for byte in content.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + + format!("{hash:016x}") +} diff --git a/src/commands/dev/utils.rs b/src/commands/dev/utils.rs new file mode 100644 index 0000000..c86aec0 --- /dev/null +++ b/src/commands/dev/utils.rs @@ -0,0 +1,15 @@ +//! Shared development utility domains used by RustUse development commands. + +pub(crate) mod artifacts; +pub(crate) mod cargo; +pub(crate) mod crates_io; +pub(crate) mod development; +pub(crate) mod documentation; +pub(crate) mod github; +pub(crate) mod gitlab; +pub(crate) mod manifest; +pub(crate) mod nonstandard; +pub(crate) mod release; +pub(crate) mod report; +pub(crate) mod scan; +pub(crate) mod tooling; diff --git a/src/commands/dev/utils/artifacts.rs b/src/commands/dev/utils/artifacts.rs new file mode 100644 index 0000000..61c1446 --- /dev/null +++ b/src/commands/dev/utils/artifacts.rs @@ -0,0 +1,57 @@ +//! Shared generated/local artifact checks for RustUse repositories. + +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub(crate) struct GeneratedArtifactReport { + pub(crate) artifacts: Vec, +} + +impl GeneratedArtifactReport { + pub(crate) fn is_empty(&self) -> bool { + self.artifacts.is_empty() + } +} + +#[derive(Debug)] +pub(crate) struct GeneratedArtifactCheck { + pub(crate) path: PathBuf, + pub(crate) label: &'static str, +} + +pub(crate) fn inspect_generated_artifacts(root: &Path) -> GeneratedArtifactReport { + let mut artifacts = Vec::new(); + let target = root.join("target"); + + if !target.is_dir() { + return GeneratedArtifactReport { artifacts }; + } + + artifacts.push(GeneratedArtifactCheck { + path: PathBuf::from("target"), + label: "Cargo build output", + }); + + if let Ok(entries) = target.read_dir() { + for entry in entries.flatten() { + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if name.starts_with("flycheck") { + artifacts.push(GeneratedArtifactCheck { + path: PathBuf::from("target").join(name), + label: "rust-analyzer flycheck output", + }); + } + } + } + + GeneratedArtifactReport { artifacts } +} diff --git a/src/commands/dev/utils/cargo.rs b/src/commands/dev/utils/cargo.rs new file mode 100644 index 0000000..6391cc5 --- /dev/null +++ b/src/commands/dev/utils/cargo.rs @@ -0,0 +1,5 @@ +pub(crate) mod check; +pub(crate) mod diagnostics; +pub(crate) mod fix; +pub(crate) mod model; +pub(crate) mod rules; diff --git a/src/commands/dev/utils/cargo/check.rs b/src/commands/dev/utils/cargo/check.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/utils/cargo/check.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/utils/cargo/diagnostics.rs b/src/commands/dev/utils/cargo/diagnostics.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/utils/cargo/diagnostics.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/utils/cargo/fix.rs b/src/commands/dev/utils/cargo/fix.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/utils/cargo/fix.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/utils/cargo/model.rs b/src/commands/dev/utils/cargo/model.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/utils/cargo/model.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/utils/cargo/rules.rs b/src/commands/dev/utils/cargo/rules.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/dev/utils/cargo/rules.rs @@ -0,0 +1 @@ + diff --git a/src/commands/dev/utils/crates_io.rs b/src/commands/dev/utils/crates_io.rs new file mode 100644 index 0000000..cb92454 --- /dev/null +++ b/src/commands/dev/utils/crates_io.rs @@ -0,0 +1 @@ +pub(crate) mod category_slugs; diff --git a/src/commands/dev/utils/crates_io/category_slugs.rs b/src/commands/dev/utils/crates_io/category_slugs.rs new file mode 100644 index 0000000..e2571c6 --- /dev/null +++ b/src/commands/dev/utils/crates_io/category_slugs.rs @@ -0,0 +1,224 @@ +#![allow(dead_code)] + +pub(crate) const MAX_CRATES_IO_CATEGORIES: usize = 5; + +/// Current crates.io category slug snapshot. +/// +// Keep this list sorted because `is_valid_category_slug` uses binary search. +// For the latest category slugs, see +pub(crate) const VALID_CATEGORY_SLUGS: &[&str] = &[ + // Assistive technology that helps overcome disabilities and impairments to make software usable by as many people as possible. + "accessibility", + // Crates for aeronautics within the atmosphere and astronautics in outer space applications. + "aerospace", + // Crates related to multicopters, fixed wing, VTOL, and airships or balloons. + "aerospace::drones", + // Crates of protocol implementations for aerospace applications. + "aerospace::protocols", + // Crates related to simulations used in aerospace, including fluids and aerodynamics. + "aerospace::simulation", + // Protocol implementations for implications in space like CCSDS. + "aerospace::space-protocols", + // Crates related to unmanned aerial vehicles like multicopters, fixed wing, VTOL, airships, balloons, rovers, boats, and submersibles. + "aerospace::unmanned-aerial-vehicles", + // Rust implementations of core algorithms such as hashing, sorting, searching, and more. + "algorithms", + // Idiomatic wrappers of specific APIs for convenient access from Rust. + "api-bindings", + // Crates for machine learning, deep learning, large language models, AI agents, and related tooling. + "artificial-intelligence", + // Crates to help deal with events independently of the main program flow. + "asynchronous", + // Crates to help with the process of confirming identities. + "authentication", + // Crates related to the automotive industry, including vehicle control and diagnostics. + "automotive", + // Crates to store the results of previous computations in order to reuse the results. + "caching", + // Crates to help create command line interfaces. + "command-line-interface", + // Applications to run at the command line. + "command-line-utilities", + // Compiler implementations, including interpreters and transpilers. + "compilers", + // Algorithms for making data smaller. + "compression", + // Crates for comprehending the world from video or images. + "computer-vision", + // Crates for implementing concurrent and parallel computation. + "concurrency", + // Crates to facilitate configuration management for applications. + "config", + // Algorithms intended for securing data. + "cryptography", + // Crates for digital currencies, wallets, and distributed ledgers. + "cryptography::cryptocurrencies", + // Rust implementations of particular ways of organizing data suited for specific purposes. + "data-structures", + // Crates to interface with database management systems. + "database", + // Database management systems implemented in Rust. + "database-implementations", + // Crates to manage the inherent complexity of dealing with the fourth dimension. + "date-and-time", + // Crates that provide developer-facing features such as testing, debugging, linting, profiling, autocompletion, and formatting. + "development-tools", + // Utilities for build scripts and other build time steps. + "development-tools::build-utils", + // Subcommands that extend the capabilities of Cargo. + "development-tools::cargo-plugins", + // Crates to help figure out what is going on with code, such as logging, tracing, or assertions. + "development-tools::debugging", + // Crates to help interface with other languages. + "development-tools::ffi", + // Crates to help write procedural macros in Rust. + "development-tools::procedural-macro-helpers", + // Crates to help figure out the performance of code. + "development-tools::profiling", + // Crates to help verify the correctness of code. + "development-tools::testing", + // Crates to help with sending, receiving, formatting, and parsing email. + "email", + // Crates that are primarily useful on embedded devices or without an operating system. + "embedded", + // Emulators that allow one computer to behave like another. + "emulators", + // Encoding and/or decoding data from one data format to another. + "encoding", + // Direct Rust FFI bindings to libraries written in other languages. + "external-ffi-bindings", + // Crates for dealing with files and filesystems. + "filesystem", + // Crates for dealing with money, accounting, trading, investments, taxes, banking, and payment processing. + "finance", + // Crates that focus on some individual part of accelerating game development. + "game-development", + // Crates that try to provide a one-stop-shop for game development. + "game-engines", + // Applications for fun and entertainment. + "games", + // Crates for graphics libraries and applications, including raster and vector graphics primitives such as geometry, curves, and color. + "graphics", + // Crates to help create a graphical user interface. + "gui", + // Crates to interface with specific CPU or other hardware features. + "hardware-support", + // Crates to help develop software capable of adapting to various languages and regions. + "internationalization", + // Crates to help adapt internationalized software to specific languages and regions. + "localization", + // Crates with a mathematical aspect. + "mathematics", + // Crates to help with allocation, memory mapping, garbage collection, reference counting, or foreign memory interfaces. + "memory-management", + // Crates that provide audio, video, and image processing or rendering engines. + "multimedia", + // Crates that record, output, or process audio. + "multimedia::audio", + // Crates that encode or decode binary data in multimedia formats. + "multimedia::encoding", + // Crates that process or build images. + "multimedia::images", + // Crates that record, output, or process video. + "multimedia::video", + // Crates dealing with higher-level or lower-level network protocols. + "network-programming", + // Crates that are able to function without the Rust standard library. + "no-std", + // Crates that are able to function without the Rust alloc crate. + "no-std::no-alloc", + // Bindings to operating system-specific APIs. + "os", + // Bindings to Android-specific APIs. + "os::android-apis", + // Bindings to FreeBSD-specific APIs. + "os::freebsd-apis", + // Bindings to Linux-specific APIs. + "os::linux-apis", + // Bindings to macOS-specific APIs. + "os::macos-apis", + // Bindings to Unix-specific APIs. + "os::unix-apis", + // Bindings to Windows-specific APIs. + "os::windows-apis", + // Parsers implemented for particular formats or languages. + "parser-implementations", + // Crates to help create parsers of binary and text formats. + "parsing", + // Real-time or offline rendering of 2D or 3D graphics, usually with the help of a graphics card. + "rendering", + // Loading and parsing of data formats related to 2D or 3D rendering. + "rendering::data-formats", + // High-level solutions for rendering on the screen. + "rendering::engine", + // Crates that provide direct access to hardware or operating system rendering capabilities. + "rendering::graphics-api", + // Shared solutions for particular situations specific to programming in Rust. + "rust-patterns", + // Crates related to solving problems involving physics, chemistry, biology, geoscience, and other scientific fields. + "science", + // Crates for processing large-scale biological data. + "science::bioinformatics", + // Crates for processing genetic data, including sequences, abundance, variants, and analysis. + "science::bioinformatics::genomics", + // Crates for processing protein data, including sequences, abundance, and analysis. + "science::bioinformatics::proteomics", + // Crates for processing biological sequences, including alignment, assembly, and annotation. + "science::bioinformatics::sequence-analysis", + // Crates for computational modeling and simulation of biological systems. + "science::computational-biology", + // Crates for protein and biomolecular structure prediction, docking, model refinement, and physics-based biomolecular simulation. + "science::computational-biology::structural-modeling", + // Crates for network modeling, pathway and metabolic modeling, and whole-system simulations. + "science::computational-biology::systems-biology", + // Crates for computational methods in chemistry, including electronic-structure calculations, molecular simulation, and cheminformatics. + "science::computational-chemistry", + // Crates for molecular representations, descriptors, chemical graph algorithms, file format parsing, and QSAR tooling. + "science::computational-chemistry::cheminformatics", + // Crates for quantum chemistry and electronic-structure methods such as DFT, ab initio, and correlated techniques. + "science::computational-chemistry::electronic-structure", + // Crates for molecular dynamics, Monte Carlo, force fields, and statistical mechanics simulations. + "science::computational-chemistry::molecular-simulation", + // Processing of spatial information, maps, navigation data, and geographic information systems. + "science::geo", + // Crates for the study, characterization, and simulation of condensed matter and materials. + "science::materials", + // Crates for research tools and processing of data related to the brain and nervous system. + "science::neuroscience", + // Crates for quantum computing, including circuit construction, simulation, quantum algorithms, intermediate representations, and hardware backends. + "science::quantum-computing", + // Crates related to robotics. + "science::robotics", + // Crates related to cybersecurity, penetration testing, code review, vulnerability research, and reverse engineering. + "security", + // Crates used to model or construct models for some activity. + "simulation", + // Crates designed to combine templates with data to produce result documents. + "template-engine", + // Applications for editing text. + "text-editors", + // Crates to deal with the complexities of human language when expressed in textual form. + "text-processing", + // Crates to allow an application to format values for display to a user. + "value-formatting", + // Crates for the creation and management of virtual environments and resources, including containerization systems. + "virtualization", + // Ways to view data, such as plotting or graphing. + "visualization", + // Crates for use when targeting WebAssembly, or for manipulating WebAssembly. + "wasm", + // Crates to create applications for the web. + "web-programming", + // Crates to make HTTP network requests. + "web-programming::http-client", + // Crates to serve data over HTTP. + "web-programming::http-server", + // Crates to communicate over the WebSocket protocol. + "web-programming::websocket", +]; + +pub(crate) fn is_valid_category_slug(slug: &str) -> bool { + VALID_CATEGORY_SLUGS + .binary_search_by(|candidate| candidate.cmp(&slug)) + .is_ok() +} diff --git a/src/commands/dev/utils/development.rs b/src/commands/dev/utils/development.rs new file mode 100644 index 0000000..2ff584a --- /dev/null +++ b/src/commands/dev/utils/development.rs @@ -0,0 +1,63 @@ +//! Shared development environment surface checks for RustUse repositories. + +use std::path::Path; + +use crate::commands::dev::utils::report::PresenceCheck; + +#[derive(Debug)] +pub(crate) struct DevelopmentSurfaceReport { + pub(crate) surface: Vec, +} + +impl DevelopmentSurfaceReport { + pub(crate) fn total_count(&self) -> usize { + self.surface.len() + } + + pub(crate) fn present_count(&self) -> usize { + self.surface.iter().filter(|check| check.present).count() + } + + pub(crate) fn status(&self) -> &'static str { + if self.surface.iter().all(|check| check.present) { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn inspect_development_surface(root: &Path) -> DevelopmentSurfaceReport { + let surface = vec![ + PresenceCheck::new( + ".cargo/config.toml", + file_exists(root, ".cargo/config.toml"), + ), + PresenceCheck::new( + ".devcontainer/devcontainer.json", + file_exists(root, ".devcontainer/devcontainer.json"), + ), + PresenceCheck::new( + ".devcontainer/post-create.sh", + file_exists(root, ".devcontainer/post-create.sh"), + ), + PresenceCheck::new( + "scripts/bootstrap-dev-tools.ps1", + file_exists(root, "scripts/bootstrap-dev-tools.ps1"), + ), + PresenceCheck::new( + "scripts/bootstrap-dev-tools.sh", + file_exists(root, "scripts/bootstrap-dev-tools.sh"), + ), + PresenceCheck::new( + "scripts/sync-mirrors.sh", + file_exists(root, "scripts/sync-mirrors.sh"), + ), + ]; + + DevelopmentSurfaceReport { surface } +} + +fn file_exists(root: &Path, path: &str) -> bool { + root.join(path).is_file() +} diff --git a/src/commands/dev/utils/documentation.rs b/src/commands/dev/utils/documentation.rs new file mode 100644 index 0000000..f13614c --- /dev/null +++ b/src/commands/dev/utils/documentation.rs @@ -0,0 +1,44 @@ +//! Shared documentation surface checks for RustUse repositories. + +use std::path::Path; + +use crate::commands::dev::utils::report::PresenceCheck; + +#[derive(Debug)] +pub(crate) struct DocumentationSurfaceReport { + pub(crate) surface: Vec, +} + +impl DocumentationSurfaceReport { + pub(crate) fn total_count(&self) -> usize { + self.surface.len() + } + + pub(crate) fn present_count(&self) -> usize { + self.surface.iter().filter(|check| check.present).count() + } + + pub(crate) fn status(&self) -> &'static str { + if self.surface.iter().all(|check| check.present) { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn inspect_documentation_surface(root: &Path) -> DocumentationSurfaceReport { + let surface = vec![ + PresenceCheck::new("README.md", file_exists(root, "README.md")), + PresenceCheck::new("CHANGELOG.md", file_exists(root, "CHANGELOG.md")), + PresenceCheck::new("CONTRIBUTING.md", file_exists(root, "CONTRIBUTING.md")), + PresenceCheck::new("GOVERNANCE.md", file_exists(root, "GOVERNANCE.md")), + PresenceCheck::new("MAINTAINERS.md", file_exists(root, "MAINTAINERS.md")), + ]; + + DocumentationSurfaceReport { surface } +} + +fn file_exists(root: &Path, path: &str) -> bool { + root.join(path).is_file() +} diff --git a/src/commands/dev/utils/github.rs b/src/commands/dev/utils/github.rs new file mode 100644 index 0000000..134b418 --- /dev/null +++ b/src/commands/dev/utils/github.rs @@ -0,0 +1,111 @@ +#![allow(dead_code)] + +//! `.github` directory inspection, reporting, and repair. +//! +//! This module owns RustUse's standard GitHub repository metadata for each +//! facade repository. It intentionally covers the whole `.github/` directory, +//! not only GitHub Actions workflows. +//! +//! Command adapters such as `dev root report` and future `dev facade ...` +//! commands should stay thin and delegate GitHub-specific logic here. +//! + +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::commands::dev::root::discover::FacadeEntry; + +pub(crate) mod check; +// pub(crate) mod fix; +pub(crate) mod hash; +pub(crate) mod issue; +pub(crate) mod model; +pub(crate) mod policy; +pub(crate) mod report; +pub(crate) mod workflows; + +/// Directory name expected at the root of every facade repository. +pub(crate) const GITHUB_DIR_NAME: &str = ".github"; + +/// Relative path to the GitHub workflows directory. +pub(crate) const WORKFLOWS_DIR_RELATIVE_PATH: &str = ".github/workflows"; + +/// Relative path to Dependabot configuration. +pub(crate) const DEPENDABOT_RELATIVE_PATH: &str = ".github/dependabot.yml"; + +/// Relative path to the GitHub funding file. +pub(crate) const FUNDING_RELATIVE_PATH: &str = ".github/FUNDING.yml"; + +/// Relative path to the pull request template. +pub(crate) const PULL_REQUEST_TEMPLATE_RELATIVE_PATH: &str = ".github/pull_request_template.md"; + +/// Relative path to the issue template directory. +pub(crate) const ISSUE_TEMPLATE_DIR_RELATIVE_PATH: &str = ".github/ISSUE_TEMPLATE"; + +#[derive(Debug)] +pub(crate) struct RootGithubReport { + pub(crate) markdown: String, + pub(crate) facade_count: usize, + pub(crate) clean_count: usize, + pub(crate) warning_count: usize, + pub(crate) error_count: usize, +} + +impl RootGithubReport { + pub(crate) fn status(&self) -> &'static str { + if self.error_count > 0 { + "error" + } else if self.warning_count > 0 { + "warning" + } else { + "ok" + } + } +} + +pub(crate) fn analyze_root_github( + root: &Path, + facades: &[FacadeEntry], +) -> Result { + let mut markdown = String::new(); + + markdown.push_str("# RustUse GitHub Report\n\n"); + markdown.push_str(&format!("- Root: `{}`\n", root.display())); + markdown.push_str(&format!("- Facades inspected: `{}`\n\n", facades.len())); + + let mut clean_count = 0usize; + let mut warning_count = 0usize; + let mut error_count = 0usize; + + for facade in facades { + let facade_path = root.join(&facade.name); + + let facade_report = check::check_facade(&facade_path) + .with_context(|| format!("failed to check GitHub metadata for `{}`", facade.name))?; + + if facade_report.has_errors() { + error_count += 1; + } else if facade_report.warning_count() > 0 { + warning_count += 1; + } else { + clean_count += 1; + } + + markdown.push_str(&report::render_markdown(&facade_report)); + markdown.push('\n'); + } + + markdown.push_str("## Summary\n\n"); + markdown.push_str(&format!("- Clean: `{clean_count}`\n")); + markdown.push_str(&format!("- Warning: `{warning_count}`\n")); + markdown.push_str(&format!("- Error: `{error_count}`\n")); + + Ok(RootGithubReport { + markdown, + facade_count: facades.len(), + clean_count, + warning_count, + error_count, + }) +} diff --git a/src/commands/dev/utils/github/check.rs b/src/commands/dev/utils/github/check.rs new file mode 100644 index 0000000..a9b5151 --- /dev/null +++ b/src/commands/dev/utils/github/check.rs @@ -0,0 +1,263 @@ +#![allow(dead_code)] +/* //! Check `.github` directory consistency for RustUse facade repositories. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::commands::dev::github::hash::hash_bytes; +use crate::commands::dev::github::issue::{GithubIssue, GithubIssueCode, GithubIssueSeverity}; +use crate::commands::dev::github::model::{ + GithubCheckOptions, GithubCheckReport, GithubFileCheck, GithubFileKind, GithubFileStatus, + GithubTarget, +}; +use crate::commands::dev::github::policy::{GithubPolicy, standard_github_policy}; +use crate::commands::dev::github::{ + GITHUB_DIR_NAME, ISSUE_TEMPLATE_DIR_RELATIVE_PATH, WORKFLOWS_DIR_RELATIVE_PATH, +}; */ + +//! Check `.github` directory consistency for RustUse facade repositories. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::hash::hash_bytes; +use super::issue::{GithubIssue, GithubIssueCode, GithubIssueSeverity}; +use super::model::{ + GithubCheckOptions, GithubCheckReport, GithubFileCheck, GithubFileKind, GithubFileStatus, + GithubTarget, +}; +use super::policy::{GithubPolicy, standard_github_policy}; +use super::{GITHUB_DIR_NAME, ISSUE_TEMPLATE_DIR_RELATIVE_PATH, WORKFLOWS_DIR_RELATIVE_PATH}; + +/// Checks a single facade repository against the standard RustUse `.github` policy. +pub fn check_facade(path: impl AsRef) -> Result { + check_facade_with_options(path, GithubCheckOptions::default()) +} + +/// Checks a single facade repository against the standard RustUse `.github` policy. +pub fn check_facade_with_options( + path: impl AsRef, + options: GithubCheckOptions, +) -> Result { + let facade_path = path.as_ref().to_path_buf(); + let policy = standard_github_policy(); + + check_facade_with_policy(facade_path, &policy, options) +} + +/// Checks a single facade repository against a provided `.github` policy. +pub fn check_facade_with_policy( + facade_path: PathBuf, + policy: &GithubPolicy, + options: GithubCheckOptions, +) -> Result { + let target = GithubTarget::from_facade_path(&facade_path); + let github_dir = facade_path.join(GITHUB_DIR_NAME); + let workflows_dir = facade_path.join(WORKFLOWS_DIR_RELATIVE_PATH); + let issue_template_dir = facade_path.join(ISSUE_TEMPLATE_DIR_RELATIVE_PATH); + + let mut checks = Vec::new(); + let mut issues = Vec::new(); + + if !github_dir.exists() { + issues.push(GithubIssue::new( + GithubIssueSeverity::Warning, + GithubIssueCode::MissingGithubDirectory, + GITHUB_DIR_NAME, + "Facade repository is missing `.github/`.", + )); + } else if !github_dir.is_dir() { + issues.push(GithubIssue::new( + GithubIssueSeverity::Error, + GithubIssueCode::InvalidGithubDirectory, + GITHUB_DIR_NAME, + "Expected `.github/` to be a directory.", + )); + } + + if !workflows_dir.exists() { + issues.push(GithubIssue::new( + GithubIssueSeverity::Warning, + GithubIssueCode::MissingGithubWorkflowsDirectory, + WORKFLOWS_DIR_RELATIVE_PATH, + "Facade repository is missing `.github/workflows/`.", + )); + } else if !workflows_dir.is_dir() { + issues.push(GithubIssue::new( + GithubIssueSeverity::Error, + GithubIssueCode::InvalidGithubWorkflowsDirectory, + WORKFLOWS_DIR_RELATIVE_PATH, + "Expected `.github/workflows/` to be a directory.", + )); + } + + if policy.requires_issue_template_directory && !issue_template_dir.exists() { + issues.push(GithubIssue::new( + GithubIssueSeverity::Warning, + GithubIssueCode::MissingGithubIssueTemplateDirectory, + ISSUE_TEMPLATE_DIR_RELATIVE_PATH, + "Facade repository is missing `.github/ISSUE_TEMPLATE/`.", + )); + } + + /* for required_file in &policy.required_files { + let check = check_required_file( + &facade_path, + required_file.relative_path, + required_file.contents, + ) + .with_context(|| { + format!( + "failed to check required `.github` file `{}`", + required_file.relative_path + ) + })?; + + if !check.is_ok() { + issues.push(issue_for_file_check(&check, required_file.kind)); + } + + checks.push(check); + } */ + + for required_file in policy.required_files { + let check = check_required_file( + &facade_path, + required_file.relative_path, + required_file.contents, + ) + .with_context(|| { + format!( + "failed to check required `.github` file `{}`", + required_file.relative_path + ) + })?; + + if !check.is_ok() { + issues.push(issue_for_file_check(&check, required_file.kind)); + } + + checks.push(check); + } + + let mut report = GithubCheckReport { + target, + facade_path, + checks, + issues, + }; + + if options.sort_issues { + report.sort_issues(); + } + + Ok(report) +} + +fn check_required_file( + facade_path: &Path, + relative_path: &'static str, + expected_contents: &'static str, +) -> Result { + let path = facade_path.join(relative_path); + + if !path.exists() { + return Ok(GithubFileCheck { + relative_path, + status: GithubFileStatus::Missing, + expected_hash: Some(hash_bytes(expected_contents.as_bytes())), + actual_hash: None, + }); + } + + if !path.is_file() { + return Ok(GithubFileCheck { + relative_path, + status: GithubFileStatus::InvalidKind, + expected_hash: Some(hash_bytes(expected_contents.as_bytes())), + actual_hash: None, + }); + } + + let actual_contents = fs::read(&path) + .with_context(|| format!("failed to read `.github` file `{}`", path.display()))?; + + let expected_hash = hash_bytes(expected_contents.as_bytes()); + let actual_hash = hash_bytes(&actual_contents); + + let status = if expected_hash == actual_hash { + GithubFileStatus::Ok + } else { + GithubFileStatus::Stale + }; + + Ok(GithubFileCheck { + relative_path, + status, + expected_hash: Some(expected_hash), + actual_hash: Some(actual_hash), + }) +} + +fn issue_for_file_check(check: &GithubFileCheck, kind: GithubFileKind) -> GithubIssue { + match check.status { + GithubFileStatus::Ok => GithubIssue::new( + GithubIssueSeverity::Info, + GithubIssueCode::Ok, + check.relative_path, + "File is consistent with the RustUse `.github` policy.", + ), + GithubFileStatus::Missing => GithubIssue::new( + GithubIssueSeverity::Warning, + missing_code_for_kind(kind), + check.relative_path, + "Required `.github` file is missing.", + ), + GithubFileStatus::Stale => GithubIssue::new( + GithubIssueSeverity::Warning, + stale_code_for_kind(kind), + check.relative_path, + "Required `.github` file differs from the RustUse standard template.", + ), + GithubFileStatus::InvalidKind => GithubIssue::new( + GithubIssueSeverity::Error, + invalid_kind_code_for_kind(kind), + check.relative_path, + "Expected `.github` path to be a file.", + ), + } +} + +fn missing_code_for_kind(kind: GithubFileKind) -> GithubIssueCode { + match kind { + GithubFileKind::Workflow => GithubIssueCode::MissingGithubWorkflow, + GithubFileKind::Dependabot => GithubIssueCode::MissingGithubDependabot, + GithubFileKind::Funding => GithubIssueCode::MissingGithubFunding, + GithubFileKind::IssueTemplate => GithubIssueCode::MissingGithubIssueTemplate, + GithubFileKind::PullRequestTemplate => GithubIssueCode::MissingGithubPullRequestTemplate, + } +} + +fn stale_code_for_kind(kind: GithubFileKind) -> GithubIssueCode { + match kind { + GithubFileKind::Workflow => GithubIssueCode::StaleGithubWorkflow, + GithubFileKind::Dependabot => GithubIssueCode::StaleGithubDependabot, + GithubFileKind::Funding => GithubIssueCode::StaleGithubFunding, + GithubFileKind::IssueTemplate => GithubIssueCode::StaleGithubIssueTemplate, + GithubFileKind::PullRequestTemplate => GithubIssueCode::StaleGithubPullRequestTemplate, + } +} + +fn invalid_kind_code_for_kind(kind: GithubFileKind) -> GithubIssueCode { + match kind { + GithubFileKind::Workflow => GithubIssueCode::InvalidGithubWorkflow, + GithubFileKind::Dependabot => GithubIssueCode::InvalidGithubDependabot, + GithubFileKind::Funding => GithubIssueCode::InvalidGithubFunding, + GithubFileKind::IssueTemplate => GithubIssueCode::InvalidGithubIssueTemplate, + GithubFileKind::PullRequestTemplate => GithubIssueCode::InvalidGithubPullRequestTemplate, + } +} diff --git a/src/commands/dev/utils/github/hash.rs b/src/commands/dev/utils/github/hash.rs new file mode 100644 index 0000000..2529b4d --- /dev/null +++ b/src/commands/dev/utils/github/hash.rs @@ -0,0 +1,79 @@ +#![allow(dead_code)] + +//! Deterministic content hashing helpers for `.github` consistency checks. +//! +//! These hashes are used for drift detection and report output only. They are +//! not intended for cryptographic use. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// FNV-1a 64-bit offset basis. +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + +/// FNV-1a 64-bit prime. +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +/// Returns a deterministic 16-character lowercase hex hash for the provided bytes. +/// +/// This intentionally avoids adding a hashing dependency for simple report drift +/// detection. The output shape matches RustUse's short variant hashes. +#[must_use] +pub fn hash_bytes(bytes: &[u8]) -> String { + let mut hash = FNV_OFFSET_BASIS; + + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + + format!("{hash:016x}") +} + +/// Returns a deterministic 16-character lowercase hex hash for the provided text. +#[must_use] +pub fn hash_str(text: &str) -> String { + hash_bytes(text.as_bytes()) +} + +/// Reads a file and returns its deterministic 16-character lowercase hex hash. +pub fn hash_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .with_context(|| format!("failed to read file for hashing `{}`", path.display()))?; + + Ok(hash_bytes(&bytes)) +} + +/// Compares expected text contents against an existing file by hash. +pub fn file_matches_contents(path: impl AsRef, expected_contents: &str) -> Result { + let actual_hash = hash_file(path)?; + let expected_hash = hash_str(expected_contents); + + Ok(actual_hash == expected_hash) +} + +#[cfg(test)] +mod tests { + use super::{hash_bytes, hash_str}; + + #[test] + fn hash_bytes_is_deterministic() { + assert_eq!(hash_bytes(b"rustuse"), hash_bytes(b"rustuse")); + } + + #[test] + fn hash_str_matches_hash_bytes() { + assert_eq!(hash_str("rustuse"), hash_bytes(b"rustuse")); + } + + #[test] + fn hash_output_is_16_hex_chars() { + let hash = hash_str("rustuse"); + + assert_eq!(hash.len(), 16); + assert!(hash.chars().all(|character| character.is_ascii_hexdigit())); + } +} diff --git a/src/commands/dev/utils/github/issue.rs b/src/commands/dev/utils/github/issue.rs new file mode 100644 index 0000000..cb6a2b1 --- /dev/null +++ b/src/commands/dev/utils/github/issue.rs @@ -0,0 +1,231 @@ +#![allow(dead_code)] + +//! Issue model for `.github` consistency checks. + +use std::fmt; + +/// Severity for a `.github` consistency issue. +/// +/// The variant order is intentional so default sorting places errors first. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum GithubIssueSeverity { + /// A publish or CI/CD blocker. + Error, + + /// Standardization drift that should be fixed. + Warning, + + /// Informational issue. + Info, +} + +impl GithubIssueSeverity { + /// User-facing severity label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Info => "info", + } + } +} + +impl fmt::Display for GithubIssueSeverity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Stable issue code for a `.github` consistency issue. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum GithubIssueCode { + /// No issue; used only for successful check records when needed. + Ok, + + /// The facade repository is missing `.github/`. + MissingGithubDirectory, + + /// `.github/` exists but is not a directory. + InvalidGithubDirectory, + + /// The facade repository is missing `.github/workflows/`. + MissingGithubWorkflowsDirectory, + + /// `.github/workflows/` exists but is not a directory. + InvalidGithubWorkflowsDirectory, + + /// The facade repository is missing `.github/ISSUE_TEMPLATE/`. + MissingGithubIssueTemplateDirectory, + + /// A required GitHub Actions workflow is missing. + MissingGithubWorkflow, + + /// A required GitHub Actions workflow differs from the RustUse standard. + StaleGithubWorkflow, + + /// A GitHub Actions workflow path exists but is not a file. + InvalidGithubWorkflow, + + /// `.github/dependabot.yml` is missing. + MissingGithubDependabot, + + /// `.github/dependabot.yml` differs from the RustUse standard. + StaleGithubDependabot, + + /// `.github/dependabot.yml` exists but is not a file. + InvalidGithubDependabot, + + /// `.github/FUNDING.yml` is missing. + MissingGithubFunding, + + /// `.github/FUNDING.yml` differs from the RustUse standard. + StaleGithubFunding, + + /// `.github/FUNDING.yml` exists but is not a file. + InvalidGithubFunding, + + /// A required issue template is missing. + MissingGithubIssueTemplate, + + /// A required issue template differs from the RustUse standard. + StaleGithubIssueTemplate, + + /// An issue template path exists but is not a file. + InvalidGithubIssueTemplate, + + /// `.github/pull_request_template.md` is missing. + MissingGithubPullRequestTemplate, + + /// `.github/pull_request_template.md` differs from the RustUse standard. + StaleGithubPullRequestTemplate, + + /// `.github/pull_request_template.md` exists but is not a file. + InvalidGithubPullRequestTemplate, +} + +impl GithubIssueCode { + /// Stable kebab-case issue code. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::MissingGithubDirectory => "missing-github-directory", + Self::InvalidGithubDirectory => "invalid-github-directory", + Self::MissingGithubWorkflowsDirectory => "missing-github-workflows-directory", + Self::InvalidGithubWorkflowsDirectory => "invalid-github-workflows-directory", + Self::MissingGithubIssueTemplateDirectory => "missing-github-issue-template-directory", + Self::MissingGithubWorkflow => "missing-github-workflow", + Self::StaleGithubWorkflow => "stale-github-workflow", + Self::InvalidGithubWorkflow => "invalid-github-workflow", + Self::MissingGithubDependabot => "missing-github-dependabot", + Self::StaleGithubDependabot => "stale-github-dependabot", + Self::InvalidGithubDependabot => "invalid-github-dependabot", + Self::MissingGithubFunding => "missing-github-funding", + Self::StaleGithubFunding => "stale-github-funding", + Self::InvalidGithubFunding => "invalid-github-funding", + Self::MissingGithubIssueTemplate => "missing-github-issue-template", + Self::StaleGithubIssueTemplate => "stale-github-issue-template", + Self::InvalidGithubIssueTemplate => "invalid-github-issue-template", + Self::MissingGithubPullRequestTemplate => "missing-github-pr-template", + Self::StaleGithubPullRequestTemplate => "stale-github-pr-template", + Self::InvalidGithubPullRequestTemplate => "invalid-github-pr-template", + } + } +} + +impl fmt::Display for GithubIssueCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One `.github` consistency issue. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GithubIssue { + /// Issue severity. + pub severity: GithubIssueSeverity, + + /// Stable issue code. + pub code: GithubIssueCode, + + /// Repository-relative path associated with the issue. + pub path: String, + + /// Human-readable issue message. + pub message: String, +} + +impl GithubIssue { + /// Creates a new `.github` consistency issue. + #[must_use] + pub fn new( + severity: GithubIssueSeverity, + code: GithubIssueCode, + path: impl Into, + message: impl Into, + ) -> Self { + Self { + severity, + code, + path: path.into(), + message: message.into(), + } + } + + /// Returns true when this issue is an error. + #[must_use] + pub const fn is_error(&self) -> bool { + matches!(self.severity, GithubIssueSeverity::Error) + } + + /// Returns true when this issue is a warning. + #[must_use] + pub const fn is_warning(&self) -> bool { + matches!(self.severity, GithubIssueSeverity::Warning) + } + + /// Returns true when this issue is informational. + #[must_use] + pub const fn is_info(&self) -> bool { + matches!(self.severity, GithubIssueSeverity::Info) + } +} + +#[cfg(test)] +mod tests { + use super::{GithubIssue, GithubIssueCode, GithubIssueSeverity}; + + #[test] + fn severity_labels_are_stable() { + assert_eq!(GithubIssueSeverity::Error.as_str(), "error"); + assert_eq!(GithubIssueSeverity::Warning.as_str(), "warning"); + assert_eq!(GithubIssueSeverity::Info.as_str(), "info"); + } + + #[test] + fn issue_code_labels_are_stable() { + assert_eq!( + GithubIssueCode::MissingGithubWorkflow.as_str(), + "missing-github-workflow" + ); + assert_eq!( + GithubIssueCode::StaleGithubPullRequestTemplate.as_str(), + "stale-github-pr-template" + ); + } + + #[test] + fn issue_helpers_match_severity() { + let issue = GithubIssue::new( + GithubIssueSeverity::Error, + GithubIssueCode::InvalidGithubDirectory, + ".github", + "Expected `.github/` to be a directory.", + ); + + assert!(issue.is_error()); + assert!(!issue.is_warning()); + assert!(!issue.is_info()); + } +} diff --git a/src/commands/dev/utils/github/model.rs b/src/commands/dev/utils/github/model.rs new file mode 100644 index 0000000..85b43d8 --- /dev/null +++ b/src/commands/dev/utils/github/model.rs @@ -0,0 +1,277 @@ +#![allow(dead_code)] +/* //! Data model for `.github` consistency checks. + +use std::path::{Path, PathBuf}; + +use crate::dev::github::issue::{GithubIssue, GithubIssueSeverity}; */ + +//! Data model for `.github` consistency checks. + +use std::path::{Path, PathBuf}; + +use super::issue::{GithubIssue, GithubIssueSeverity}; + +/// Options used when checking a facade repository's `.github` directory. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GithubCheckOptions { + /// Sort issues before returning the report. + pub sort_issues: bool, + + /// Include successful file checks in rendered reports. + pub include_ok_checks: bool, +} + +impl Default for GithubCheckOptions { + fn default() -> Self { + Self { + sort_issues: true, + include_ok_checks: true, + } + } +} + +/// A checked `.github` target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GithubTarget { + /// Facade name, usually the directory name such as `use-quant`. + pub facade: String, + + /// Facade repository path. + pub path: PathBuf, +} + +impl GithubTarget { + /// Creates a target from a facade repository path. + #[must_use] + pub fn from_facade_path(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + + let facade = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(".") + .to_owned(); + + Self { facade, path } + } +} + +/// Full `.github` check report for one facade repository. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GithubCheckReport { + /// Checked target. + pub target: GithubTarget, + + /// Facade repository path. + pub facade_path: PathBuf, + + /// Individual file checks. + pub checks: Vec, + + /// Issues discovered while checking the target. + pub issues: Vec, +} + +impl GithubCheckReport { + /// Returns true when there are no warning or error issues. + #[must_use] + pub fn is_clean(&self) -> bool { + self.error_count() == 0 && self.warning_count() == 0 + } + + /// Returns true when the report has one or more error issues. + #[must_use] + pub fn has_errors(&self) -> bool { + self.error_count() > 0 + } + + /// Number of error issues. + #[must_use] + pub fn error_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == GithubIssueSeverity::Error) + .count() + } + + /// Number of warning issues. + #[must_use] + pub fn warning_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == GithubIssueSeverity::Warning) + .count() + } + + /// Number of informational issues. + #[must_use] + pub fn info_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == GithubIssueSeverity::Info) + .count() + } + + /// Number of file checks with status `ok`. + #[must_use] + pub fn ok_check_count(&self) -> usize { + self.checks + .iter() + .filter(|check| check.status == GithubFileStatus::Ok) + .count() + } + + /// Number of missing required files. + #[must_use] + pub fn missing_check_count(&self) -> usize { + self.checks + .iter() + .filter(|check| check.status == GithubFileStatus::Missing) + .count() + } + + /// Number of stale required files. + #[must_use] + pub fn stale_check_count(&self) -> usize { + self.checks + .iter() + .filter(|check| check.status == GithubFileStatus::Stale) + .count() + } + + /// Number of invalid required paths. + #[must_use] + pub fn invalid_kind_check_count(&self) -> usize { + self.checks + .iter() + .filter(|check| check.status == GithubFileStatus::InvalidKind) + .count() + } + + /// Sorts issues by severity, path, then code. + pub fn sort_issues(&mut self) { + self.issues.sort_by(|left, right| { + left.severity + .cmp(&right.severity) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.code.cmp(&right.code)) + }); + } +} + +/// Result of checking one required `.github` file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GithubFileCheck { + /// Repository-relative path, such as `.github/workflows/ci.yml`. + pub relative_path: &'static str, + + /// File check status. + pub status: GithubFileStatus, + + /// Expected content hash when the file is template-backed. + pub expected_hash: Option, + + /// Actual content hash when the file exists and is readable. + pub actual_hash: Option, +} + +impl GithubFileCheck { + /// Returns true when the file matches the expected RustUse standard. + #[must_use] + pub fn is_ok(&self) -> bool { + self.status == GithubFileStatus::Ok + } + + /// Returns true when the file is missing. + #[must_use] + pub fn is_missing(&self) -> bool { + self.status == GithubFileStatus::Missing + } + + /// Returns true when the file exists but differs from the expected template. + #[must_use] + pub fn is_stale(&self) -> bool { + self.status == GithubFileStatus::Stale + } + + /// Returns true when the path exists but is not a normal file. + #[must_use] + pub fn is_invalid_kind(&self) -> bool { + self.status == GithubFileStatus::InvalidKind + } +} + +/// Status for a required `.github` file. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GithubFileStatus { + /// File exists and matches the expected template. + Ok, + + /// File does not exist. + Missing, + + /// File exists but differs from the expected template. + Stale, + + /// Path exists but is not a file. + InvalidKind, +} + +impl GithubFileStatus { + /// User-facing status label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Missing => "missing", + Self::Stale => "stale", + Self::InvalidKind => "invalid-kind", + } + } +} + +/// Kind of `.github` file being checked. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GithubFileKind { + /// GitHub Actions workflow under `.github/workflows/`. + Workflow, + + /// Dependabot configuration. + Dependabot, + + /// GitHub funding metadata. + Funding, + + /// GitHub issue template. + IssueTemplate, + + /// GitHub pull request template. + PullRequestTemplate, +} + +impl GithubFileKind { + /// User-facing kind label. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Workflow => "workflow", + Self::Dependabot => "dependabot", + Self::Funding => "funding", + Self::IssueTemplate => "issue-template", + Self::PullRequestTemplate => "pull-request-template", + } + } +} + +/// One required file in the RustUse `.github` policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RequiredGithubFile { + /// Repository-relative path. + pub relative_path: &'static str, + + /// Expected file contents. + pub contents: &'static str, + + /// Required file kind. + pub kind: GithubFileKind, +} diff --git a/src/commands/dev/utils/github/policy.rs b/src/commands/dev/utils/github/policy.rs new file mode 100644 index 0000000..6c69cb3 --- /dev/null +++ b/src/commands/dev/utils/github/policy.rs @@ -0,0 +1,343 @@ +#![allow(dead_code)] + +//! Standard RustUse `.github` policy. + +/* use crate::dev::github::model::{GithubFileKind, RequiredGithubFile}; +use crate::dev::github::{ + DEPENDABOT_RELATIVE_PATH, FUNDING_RELATIVE_PATH, ISSUE_TEMPLATE_DIR_RELATIVE_PATH, + PULL_REQUEST_TEMPLATE_RELATIVE_PATH, +}; */ + +use super::model::{GithubFileKind, RequiredGithubFile}; +/* use super::{ + DEPENDABOT_RELATIVE_PATH, FUNDING_RELATIVE_PATH, ISSUE_TEMPLATE_DIR_RELATIVE_PATH, + PULL_REQUEST_TEMPLATE_RELATIVE_PATH, +}; */ + +use super::{DEPENDABOT_RELATIVE_PATH, PULL_REQUEST_TEMPLATE_RELATIVE_PATH}; + +/// Standard RustUse CI workflow path. +pub const CI_WORKFLOW_RELATIVE_PATH: &str = ".github/workflows/ci.yml"; + +/// Standard RustUse release-plz workflow path. +pub const RELEASE_PLZ_WORKFLOW_RELATIVE_PATH: &str = ".github/workflows/release-plz.yml"; + +/// Standard RustUse security workflow path. +pub const SECURITY_WORKFLOW_RELATIVE_PATH: &str = ".github/workflows/security.yml"; + +/// Standard bug report issue template path. +pub const BUG_REPORT_TEMPLATE_RELATIVE_PATH: &str = ".github/ISSUE_TEMPLATE/bug_report.yml"; + +/// Standard feature request issue template path. +pub const FEATURE_REQUEST_TEMPLATE_RELATIVE_PATH: &str = + ".github/ISSUE_TEMPLATE/feature_request.yml"; + +/// Standard `.github` policy for RustUse facade repositories. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GithubPolicy { + /// Required template-backed files. + pub required_files: &'static [RequiredGithubFile], + + /// Whether `.github/ISSUE_TEMPLATE/` must exist. + pub requires_issue_template_directory: bool, +} + +/// Returns the standard RustUse `.github` policy. +#[must_use] +pub const fn standard_github_policy() -> GithubPolicy { + GithubPolicy { + required_files: REQUIRED_GITHUB_FILES, + requires_issue_template_directory: true, + } +} + +/// Required `.github` files for every RustUse facade repository. +pub const REQUIRED_GITHUB_FILES: &[RequiredGithubFile] = &[ + RequiredGithubFile { + relative_path: CI_WORKFLOW_RELATIVE_PATH, + contents: CI_WORKFLOW, + kind: GithubFileKind::Workflow, + }, + RequiredGithubFile { + relative_path: RELEASE_PLZ_WORKFLOW_RELATIVE_PATH, + contents: RELEASE_PLZ_WORKFLOW, + kind: GithubFileKind::Workflow, + }, + RequiredGithubFile { + relative_path: SECURITY_WORKFLOW_RELATIVE_PATH, + contents: SECURITY_WORKFLOW, + kind: GithubFileKind::Workflow, + }, + RequiredGithubFile { + relative_path: DEPENDABOT_RELATIVE_PATH, + contents: DEPENDABOT, + kind: GithubFileKind::Dependabot, + }, + // RequiredGithubFile { + // relative_path: FUNDING_RELATIVE_PATH, + // contents: FUNDING, + // kind: GithubFileKind::Funding, + // }, + RequiredGithubFile { + relative_path: BUG_REPORT_TEMPLATE_RELATIVE_PATH, + contents: BUG_REPORT_TEMPLATE, + kind: GithubFileKind::IssueTemplate, + }, + RequiredGithubFile { + relative_path: FEATURE_REQUEST_TEMPLATE_RELATIVE_PATH, + contents: FEATURE_REQUEST_TEMPLATE, + kind: GithubFileKind::IssueTemplate, + }, + RequiredGithubFile { + relative_path: PULL_REQUEST_TEMPLATE_RELATIVE_PATH, + contents: PULL_REQUEST_TEMPLATE, + kind: GithubFileKind::PullRequestTemplate, + }, +]; + +/// Standard RustUse CI workflow. +pub const CI_WORKFLOW: &str = r#"name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Format + run: cargo fmt --all --check + + - name: Check + run: cargo check --workspace --all-targets --all-features + + - name: Clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Test + run: cargo test --workspace --all-features + + - name: Docs + run: cargo doc --workspace --all-features --no-deps +"#; + +/// Standard RustUse release-plz workflow. +pub const RELEASE_PLZ_WORKFLOW: &str = r#"name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-plz: + name: Release + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Release + uses: release-plz/action@v0.5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} +"#; + +/// Standard RustUse security workflow. +pub const SECURITY_WORKFLOW: &str = r#"name: Security + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "0 8 * * 1" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + cargo-deny: + name: cargo-deny + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-deny + run: cargo install cargo-deny --locked + + - name: Run cargo-deny + run: cargo deny check +"#; + +/// Standard RustUse Dependabot configuration. +pub const DEPENDABOT: &str = r#"version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 +"#; + +/// Standard RustUse funding metadata. +/// +/// Empty by default until RustUse chooses a funding surface. +pub const FUNDING: &str = r#"# github: RustUse +"#; + +/// Standard RustUse bug report issue template. +pub const BUG_REPORT_TEMPLATE: &str = r#"name: Bug report +description: Report a reproducible problem. +title: "bug: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a RustUse issue. Please include a minimal reproduction when possible. + + - type: textarea + id: description + attributes: + label: Description + description: What happened? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Provide the smallest example that reproduces the issue. + render: rust + validations: + required: false + + - type: input + id: rust-version + attributes: + label: Rust version + placeholder: rustc 1.95.0 + validations: + required: false + + - type: input + id: crate-version + attributes: + label: Crate version + placeholder: use-example 0.1.0 + validations: + required: false +"#; + +/// Standard RustUse feature request issue template. +pub const FEATURE_REQUEST_TEMPLATE: &str = r#"name: Feature request +description: Suggest an improvement or new primitive. +title: "feat: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + RustUse favors small, composable, well-documented primitives. + + - type: textarea + id: problem + attributes: + label: Problem + description: What problem should this solve? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposal + description: What API, crate, or behavior do you suggest? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: What alternatives did you consider? + validations: + required: false +"#; + +/// Standard RustUse pull request template. +pub const PULL_REQUEST_TEMPLATE: &str = r#"## Summary + +Describe the change. + +## Checklist + +- [ ] Tests were added or updated when useful. +- [ ] Documentation was added or updated when useful. +- [ ] Public API changes are intentional. +- [ ] `cargo fmt --all --check` passes. +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes. +- [ ] `cargo test --workspace --all-features` passes. +"#; diff --git a/src/commands/dev/utils/github/report.rs b/src/commands/dev/utils/github/report.rs new file mode 100644 index 0000000..cfe98d6 --- /dev/null +++ b/src/commands/dev/utils/github/report.rs @@ -0,0 +1,256 @@ +#![allow(dead_code)] + +//! Report rendering for `.github` consistency checks. + +use std::fmt::Write; + +use super::model::{GithubCheckReport, GithubFileCheck, GithubFileStatus}; + +/// Options used while rendering a `.github` report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GithubReportOptions { + /// Include checks whose status is `ok`. + pub(crate) include_ok_checks: bool, + + /// Include expected and actual content hashes. + pub(crate) include_hashes: bool, +} + +impl Default for GithubReportOptions { + fn default() -> Self { + Self { + include_ok_checks: true, + include_hashes: true, + } + } +} + +/// Renders a GitHub-flavored Markdown `.github` report. +#[must_use] +pub(crate) fn render_markdown(report: &GithubCheckReport) -> String { + render_markdown_with_options(report, &GithubReportOptions::default()) +} + +/// Renders a GitHub-flavored Markdown `.github` report with options. +#[must_use] +pub(crate) fn render_markdown_with_options( + report: &GithubCheckReport, + options: &GithubReportOptions, +) -> String { + let mut output = String::new(); + + writeln!(output, "## .github Consistency").expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + + writeln!(output, "- Facade: `{}`", report.target.facade) + .expect("writing to string should not fail"); + writeln!(output, "- Path: `{}`", report.facade_path.display()) + .expect("writing to string should not fail"); + writeln!(output, "- Status: **{}**", report_status(report)) + .expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + + writeln!(output, "### Summary").expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + writeln!(output, "| Metric | Count |").expect("writing to string should not fail"); + writeln!(output, "|---|---:|").expect("writing to string should not fail"); + writeln!(output, "| Checks | {} |", report.checks.len()) + .expect("writing to string should not fail"); + writeln!(output, "| Ok | {} |", report.ok_check_count()) + .expect("writing to string should not fail"); + writeln!(output, "| Missing | {} |", report.missing_check_count()) + .expect("writing to string should not fail"); + writeln!(output, "| Stale | {} |", report.stale_check_count()) + .expect("writing to string should not fail"); + writeln!( + output, + "| Invalid kind | {} |", + report.invalid_kind_check_count() + ) + .expect("writing to string should not fail"); + writeln!(output, "| Errors | {} |", report.error_count()) + .expect("writing to string should not fail"); + writeln!(output, "| Warnings | {} |", report.warning_count()) + .expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + + render_markdown_issues(report, &mut output); + render_markdown_checks(report, options, &mut output); + + output +} + +fn render_markdown_issues(report: &GithubCheckReport, output: &mut String) { + writeln!(output, "### Issues").expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + + if report.issues.is_empty() { + writeln!(output, "No `.github` issues found.").expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + return; + } + + writeln!(output, "| Severity | Path | Code | Message |") + .expect("writing to string should not fail"); + writeln!(output, "|---|---|---|---|").expect("writing to string should not fail"); + + for issue in &report.issues { + writeln!( + output, + "| {} | `{}` | `{}` | {} |", + issue.severity.as_str(), + escape_markdown_table_cell(&issue.path), + issue.code.as_str(), + escape_markdown_table_cell(&issue.message) + ) + .expect("writing to string should not fail"); + } + + writeln!(output).expect("writing to string should not fail"); +} + +fn render_markdown_checks( + report: &GithubCheckReport, + options: &GithubReportOptions, + output: &mut String, +) { + writeln!(output, "### File Checks").expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + + let checks = visible_checks(report, options); + + if checks.is_empty() { + writeln!(output, "No `.github` file checks to show.") + .expect("writing to string should not fail"); + writeln!(output).expect("writing to string should not fail"); + return; + } + + if options.include_hashes { + writeln!(output, "| Status | Path | Expected hash | Actual hash |") + .expect("writing to string should not fail"); + writeln!(output, "|---|---|---:|---:|").expect("writing to string should not fail"); + + for check in checks { + writeln!( + output, + "| {} | `{}` | `{}` | `{}` |", + check.status.as_str(), + escape_markdown_table_cell(check.relative_path), + option_hash(check.expected_hash.as_deref()), + option_hash(check.actual_hash.as_deref()) + ) + .expect("writing to string should not fail"); + } + } else { + writeln!(output, "| Status | Path |").expect("writing to string should not fail"); + writeln!(output, "|---|---|").expect("writing to string should not fail"); + + for check in checks { + writeln!( + output, + "| {} | `{}` |", + check.status.as_str(), + escape_markdown_table_cell(check.relative_path) + ) + .expect("writing to string should not fail"); + } + } + + writeln!(output).expect("writing to string should not fail"); +} + +fn visible_checks<'a>( + report: &'a GithubCheckReport, + options: &GithubReportOptions, +) -> Vec<&'a GithubFileCheck> { + report + .checks + .iter() + .filter(|check| options.include_ok_checks || check.status != GithubFileStatus::Ok) + .collect() +} + +fn report_status(report: &GithubCheckReport) -> &'static str { + if report.has_errors() { + "error" + } else if report.warning_count() > 0 { + "warning" + } else { + "ok" + } +} + +fn option_hash(hash: Option<&str>) -> &str { + hash.unwrap_or("-") +} + +fn escape_markdown_table_cell(value: &str) -> String { + value.replace('|', "\\|").replace('\n', "
") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::super::issue::{GithubIssue, GithubIssueCode, GithubIssueSeverity}; + use super::super::model::{GithubCheckReport, GithubFileCheck, GithubFileStatus, GithubTarget}; + use super::{GithubReportOptions, render_markdown, render_markdown_with_options}; + + #[test] + fn markdown_report_includes_summary_and_issues() { + let report = sample_report(); + + let rendered = render_markdown(&report); + + assert!(rendered.contains("## .github Consistency")); + assert!(rendered.contains("| Warnings | 1 |")); + assert!(rendered.contains("missing-github-workflow")); + } + + #[test] + fn markdown_report_can_hide_ok_checks() { + let report = sample_report(); + + let rendered = render_markdown_with_options( + &report, + &GithubReportOptions { + include_ok_checks: false, + include_hashes: false, + }, + ); + + assert!(!rendered.contains(".github/dependabot.yml")); + assert!(rendered.contains(".github/workflows/ci.yml")); + } + + fn sample_report() -> GithubCheckReport { + GithubCheckReport { + target: GithubTarget { + facade: "use-example".to_owned(), + path: PathBuf::from("use-example"), + }, + facade_path: PathBuf::from("use-example"), + checks: vec![ + GithubFileCheck { + relative_path: ".github/dependabot.yml", + status: GithubFileStatus::Ok, + expected_hash: Some("expected".to_owned()), + actual_hash: Some("expected".to_owned()), + }, + GithubFileCheck { + relative_path: ".github/workflows/ci.yml", + status: GithubFileStatus::Missing, + expected_hash: Some("expected".to_owned()), + actual_hash: None, + }, + ], + issues: vec![GithubIssue::new( + GithubIssueSeverity::Warning, + GithubIssueCode::MissingGithubWorkflow, + ".github/workflows/ci.yml", + "Required `.github` file is missing.", + )], + } + } +} diff --git a/src/commands/dev/utils/github/workflows.rs b/src/commands/dev/utils/github/workflows.rs new file mode 100644 index 0000000..117a475 --- /dev/null +++ b/src/commands/dev/utils/github/workflows.rs @@ -0,0 +1,123 @@ +//! Shared GitHub workflow policy checks for RustUse repositories. + +use std::path::Path; + +use crate::commands::dev::utils::report::PresenceCheck; + +pub(crate) const REQUIRED_GITHUB_WORKFLOWS: &[&str] = &[ + "advisory-rust-quality.yml", + "cargo-audit.yml", + "cargo-deny.yml", + "ci.yml", + "codeql.yml", + "facade-publish-readiness.yml", + "mirror.yml", + "publish-readiness.yml", + "pull-request.yml", + "release-plz-pr.yml", + "release-plz-release.yml", + "sbom.yml", + "secrets.yml", + "trivy.yml", +]; + +#[derive(Debug)] +pub(crate) struct GitHubWorkflowReport { + pub(crate) required_surface: Vec, + pub(crate) required_workflows: Vec, +} + +impl GitHubWorkflowReport { + pub(crate) fn status(&self) -> &'static str { + if self.has_missing_required_paths() { + "warning" + } else { + "ok" + } + } + + pub(crate) fn required_count(&self) -> usize { + self.required_surface.len() + self.required_workflows.len() + } + + pub(crate) fn present_required_count(&self) -> usize { + self.required_surface + .iter() + .chain(self.required_workflows.iter()) + .filter(|check| check.present) + .count() + } + + pub(crate) fn workflow_count(&self) -> usize { + self.required_workflows.len() + } + + pub(crate) fn present_workflow_count(&self) -> usize { + self.required_workflows + .iter() + .filter(|check| check.present) + .count() + } + + pub(crate) fn missing_required_count(&self) -> usize { + self.required_surface + .iter() + .chain(self.required_workflows.iter()) + .filter(|check| !check.present) + .count() + } + + pub(crate) fn has_missing_required_paths(&self) -> bool { + self.required_surface + .iter() + .chain(self.required_workflows.iter()) + .any(|check| !check.present) + } + + pub(crate) fn missing_required_paths(&self) -> Vec<&str> { + self.required_surface + .iter() + .chain(self.required_workflows.iter()) + .filter(|check| !check.present) + .map(|check| check.path.as_str()) + .collect() + } +} + +pub(crate) fn inspect_github_workflows(root: &Path) -> GitHubWorkflowReport { + let required_surface = vec![ + PresenceCheck::new(".github/", dir_exists(root, ".github")), + PresenceCheck::new(".github/workflows/", dir_exists(root, ".github/workflows")), + PresenceCheck::new( + ".github/dependabot.yml", + file_exists(root, ".github/dependabot.yml"), + ), + ]; + + let required_workflows = REQUIRED_GITHUB_WORKFLOWS + .iter() + .map(|workflow| { + let path = required_github_workflow_path(workflow); + let present = file_exists(root, &path); + + PresenceCheck::new(path, present) + }) + .collect(); + + GitHubWorkflowReport { + required_surface, + required_workflows, + } +} + +fn required_github_workflow_path(file_name: &str) -> String { + format!(".github/workflows/{file_name}") +} + +fn file_exists(root: &Path, path: &str) -> bool { + root.join(path).is_file() +} + +fn dir_exists(root: &Path, path: &str) -> bool { + root.join(path).is_dir() +} diff --git a/src/commands/dev/utils/gitlab.rs b/src/commands/dev/utils/gitlab.rs new file mode 100644 index 0000000..0326fa9 --- /dev/null +++ b/src/commands/dev/utils/gitlab.rs @@ -0,0 +1,37 @@ +//! Shared GitLab CI surface checks for RustUse repositories. + +use std::path::Path; + +use crate::commands::dev::utils::report::PresenceCheck; + +#[derive(Debug)] +pub(crate) struct GitLabReport { + pub(crate) surface: Vec, +} + +impl GitLabReport { + pub(crate) fn total_count(&self) -> usize { + self.surface.len() + } + + pub(crate) fn present_count(&self) -> usize { + self.surface.iter().filter(|check| check.present).count() + } +} + +pub(crate) fn inspect_gitlab(root: &Path) -> GitLabReport { + let surface = vec![ + PresenceCheck::new(".gitlab/", dir_exists(root, ".gitlab")), + PresenceCheck::new(".gitlab-ci.yml", file_exists(root, ".gitlab-ci.yml")), + ]; + + GitLabReport { surface } +} + +fn file_exists(root: &Path, path: &str) -> bool { + root.join(path).is_file() +} + +fn dir_exists(root: &Path, path: &str) -> bool { + root.join(path).is_dir() +} diff --git a/src/commands/dev/utils/manifest.rs b/src/commands/dev/utils/manifest.rs new file mode 100644 index 0000000..f55b6cf --- /dev/null +++ b/src/commands/dev/utils/manifest.rs @@ -0,0 +1,1237 @@ +//! Shared Cargo manifest analysis for RustUse development commands. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::commands::dev::root::discover::FacadeEntry; +use crate::commands::dev::utils::crates_io::category_slugs::{ + MAX_CRATES_IO_CATEGORIES, is_valid_category_slug, +}; + +const EXPECTED_WORKSPACE_MEMBERS: &[&str] = &["crates/*"]; + +const RUSTUSE_GITHUB_ORG: &str = "https://github.com/RustUse"; + +const REQUIRED_WORKSPACE_PACKAGE_FIELDS: &[&str] = &[ + "authors", + "edition", + "license", + "repository", + "rust-version", +]; + +const REQUIRED_PACKAGE_FIELDS: &[&str] = &[ + "name", + "version", + "publish", + "keywords", + "description", + "homepage", + "documentation", + "readme", +]; + +const EXPECTED_WORKSPACE_INHERITED_PACKAGE_FIELDS: &[&str] = &[ + "authors", + "edition", + "rust-version", + "license", + "repository", +]; + +#[derive(Debug)] +pub(crate) struct FacadeManifestReport { + pub(crate) facade_name: String, + pub(crate) manifests: Vec, +} + +impl FacadeManifestReport { + pub(crate) fn status(&self) -> &'static str { + if self.error_count() > 0 { + "error" + } else if self.warning_count() > 0 { + "warning" + } else { + "ok" + } + } + + pub(crate) fn manifest_count(&self) -> usize { + self.manifests.len() + } + + pub(crate) fn issue_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::issue_count) + .sum() + } + + pub(crate) fn error_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::error_count) + .sum() + } + + pub(crate) fn warning_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::warning_count) + .sum() + } + + pub(crate) fn invalid_category_count(&self) -> usize { + self.manifests + .iter() + .map(ManifestFileReport::invalid_category_count) + .sum() + } +} + +#[derive(Debug)] +pub(crate) struct ManifestFileReport { + pub(crate) path: PathBuf, + pub(crate) kind: ManifestKind, + pub(crate) package_name: Option, + pub(crate) issues: Vec, +} + +impl ManifestFileReport { + pub(crate) fn status(&self) -> &'static str { + if self.error_count() > 0 { + "error" + } else if self.warning_count() > 0 { + "warning" + } else { + "ok" + } + } + + pub(crate) fn issue_count(&self) -> usize { + self.issues.len() + } + + pub(crate) fn error_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == ManifestIssueSeverity::Error) + .count() + } + + pub(crate) fn warning_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.severity == ManifestIssueSeverity::Warning) + .count() + } + + pub(crate) fn invalid_category_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| issue.code == "invalid-category-slug") + .count() + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum ManifestKind { + WorkspaceRoot, + FacadePackage, + ChildPackage, +} + +impl ManifestKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::WorkspaceRoot => "workspace-root", + Self::FacadePackage => "facade-package", + Self::ChildPackage => "child-package", + } + } +} + +#[derive(Debug)] +pub(crate) struct ManifestIssue { + pub(crate) severity: ManifestIssueSeverity, + pub(crate) code: &'static str, + pub(crate) message: String, +} + +impl ManifestIssue { + pub(crate) fn error(code: &'static str, message: impl Into) -> Self { + Self { + severity: ManifestIssueSeverity::Error, + code, + message: message.into(), + } + } + + pub(crate) fn warning(code: &'static str, message: impl Into) -> Self { + Self { + severity: ManifestIssueSeverity::Warning, + code, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum ManifestIssueSeverity { + Error, + Warning, +} + +impl ManifestIssueSeverity { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + } + } +} + +pub(crate) fn analyze_manifests( + root: &Path, + facades: &[FacadeEntry], +) -> Result> { + facades + .iter() + .map(|facade| analyze_facade_manifests(root, facade)) + .collect() +} + +pub(crate) fn analyze_facade_manifests( + root: &Path, + facade: &FacadeEntry, +) -> Result { + let facade_root = root.join(&facade.name); + let workspace_manifest_path = facade_root.join("Cargo.toml"); + let crates_dir = facade_root.join("crates"); + + let mut manifests = Vec::new(); + + let crate_manifest_paths = discover_crate_manifest_paths(&crates_dir)?; + + let crate_dir_names = crate_manifest_paths + .iter() + .filter_map(|path| crate_dir_name(path)) + .collect::>(); + + /* let child_crate_names = crate_dir_names + .iter() + .filter(|name| name.as_str() != facade.name.as_str()) + .cloned() + .collect::>(); + + let workspace_categories = analyze_workspace_root_manifest( + root, + &workspace_manifest_path, + facade, + &child_crate_names, + &mut manifests, + ); */ + + let child_crate_names = crate_dir_names + .iter() + .filter(|name| name.as_str() != facade.name.as_str()) + .cloned() + .collect::>(); + + let child_crates = collect_child_crate_info(&crate_manifest_paths, &facade.name); + + let workspace_categories = analyze_workspace_root_manifest( + root, + &workspace_manifest_path, + facade, + &child_crates, + &mut manifests, + ); + + for manifest_path in crate_manifest_paths { + let crate_dir_name = manifest_path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let kind = if crate_dir_name == facade.name { + ManifestKind::FacadePackage + } else { + ManifestKind::ChildPackage + }; + + analyze_package_manifest( + root, + &manifest_path, + kind, + &facade.name, + &child_crate_names, + workspace_categories.as_deref(), + &mut manifests, + ); + } + + Ok(FacadeManifestReport { + facade_name: facade.name.clone(), + manifests, + }) +} + +pub(crate) fn analyze_facade_repository_manifests( + facade_root: &Path, + facade_name: &str, +) -> Result { + let workspace_manifest_path = facade_root.join("Cargo.toml"); + let crates_dir = facade_root.join("crates"); + + let mut manifests = Vec::new(); + + let crate_manifest_paths = discover_crate_manifest_paths(&crates_dir)?; + + let crate_dir_names = crate_manifest_paths + .iter() + .filter_map(|path| crate_dir_name(path)) + .collect::>(); + + let child_crate_names = crate_dir_names + .iter() + .filter(|name| name.as_str() != facade_name) + .cloned() + .collect::>(); + + let child_crates = collect_child_crate_info(&crate_manifest_paths, facade_name); + + let facade = FacadeEntry { + name: facade_name.to_string(), + version: None, + has_git: facade_root.join(".git").is_dir(), + has_cargo_toml: workspace_manifest_path.is_file(), + has_crates_dir: crates_dir.is_dir(), + child_crate_count: crate_manifest_paths.len(), + }; + + let workspace_categories = analyze_workspace_root_manifest( + facade_root, + &workspace_manifest_path, + &facade, + &child_crates, + &mut manifests, + ); + + for manifest_path in crate_manifest_paths { + let crate_dir_name = manifest_path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let kind = if crate_dir_name == facade_name { + ManifestKind::FacadePackage + } else { + ManifestKind::ChildPackage + }; + + analyze_package_manifest( + facade_root, + &manifest_path, + kind, + facade_name, + &child_crate_names, + workspace_categories.as_deref(), + &mut manifests, + ); + } + + Ok(FacadeManifestReport { + facade_name: facade_name.to_string(), + manifests, + }) +} + +/* fn analyze_workspace_root_manifest( + root: &Path, + manifest_path: &Path, + facade: &FacadeEntry, + child_crate_names: &BTreeSet, + manifests: &mut Vec, +) -> Option> { */ +fn analyze_workspace_root_manifest( + root: &Path, + manifest_path: &Path, + facade: &FacadeEntry, + child_crates: &BTreeMap, + manifests: &mut Vec, +) -> Option> { + let mut report = ManifestFileReport { + path: relative_path(root, manifest_path), + kind: ManifestKind::WorkspaceRoot, + package_name: None, + issues: Vec::new(), + }; + + if !manifest_path.is_file() { + report.issues.push(ManifestIssue::error( + "missing-workspace-manifest", + "missing workspace root Cargo.toml", + )); + manifests.push(report); + return None; + } + + let Some(manifest) = read_manifest(manifest_path, &mut report) else { + manifests.push(report); + return None; + }; + + let workspace_categories = + analyze_workspace_table(&manifest, facade, &mut report, child_crates); + + manifests.push(report); + + workspace_categories +} + +fn analyze_package_manifest( + root: &Path, + manifest_path: &Path, + kind: ManifestKind, + facade_name: &str, + child_crate_names: &BTreeSet, + workspace_categories: Option<&[String]>, + manifests: &mut Vec, +) { + let mut report = ManifestFileReport { + path: relative_path(root, manifest_path), + kind, + package_name: None, + issues: Vec::new(), + }; + + let Some(manifest) = read_manifest(manifest_path, &mut report) else { + manifests.push(report); + return; + }; + + analyze_package_table( + &manifest, + kind, + facade_name, + child_crate_names, + workspace_categories, + &mut report, + ); + + manifests.push(report); +} + +fn analyze_workspace_table( + manifest: &toml::Value, + facade: &FacadeEntry, + report: &mut ManifestFileReport, + child_crates: &BTreeMap, +) -> Option> { + let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::error( + "missing-workspace", + "workspace root manifest is missing [workspace]", + )); + return None; + }; + + match workspace.get("resolver").and_then(toml::Value::as_str) { + Some("3") => {}, + Some(resolver) => report.issues.push(ManifestIssue::warning( + "workspace-resolver", + format!("expected [workspace].resolver = \"3\", found \"{resolver}\""), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-resolver", + "missing [workspace].resolver", + )), + } + + match workspace.get("members") { + Some(value) if value.as_array().is_some() => {}, + Some(_) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-members", + "expected [workspace].members to be an array", + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-members", + "missing [workspace].members", + )), + } + + validate_workspace_members(workspace, report); + // validate_workspace_dependencies(workspace, child_crate_names, report); + validate_workspace_dependencies(workspace, child_crates, report); + validate_workspace_lints(workspace, report); + + let Some(workspace_package) = workspace.get("package").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-package", + "missing [workspace.package]", + )); + return None; + }; + + validate_workspace_repository(workspace_package, &facade.name, report); + + for field in REQUIRED_WORKSPACE_PACKAGE_FIELDS { + if !workspace_package.contains_key(*field) { + report.issues.push(ManifestIssue::warning( + "missing-workspace-package-field", + format!("missing [workspace.package].{field}"), + )); + } + } + + let Some(categories_value) = workspace_package.get("categories") else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-categories", + "missing [workspace.package].categories", + )); + return None; + }; + + let categories = + collect_category_strings(categories_value, "[workspace.package].categories", report); + + if let Some(categories) = categories.as_ref() { + validate_categories(categories, "[workspace.package].categories", report); + } + + categories +} + +fn analyze_package_table( + manifest: &toml::Value, + kind: ManifestKind, + facade_name: &str, + child_crate_names: &BTreeSet, + workspace_categories: Option<&[String]>, + report: &mut ManifestFileReport, +) { + let Some(package) = manifest.get("package").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::error( + "missing-package", + "crate manifest is missing [package]", + )); + return; + }; + + report.package_name = package + .get("name") + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned); + + for field in REQUIRED_PACKAGE_FIELDS { + if !package.contains_key(*field) { + report.issues.push(ManifestIssue::warning( + "missing-package-field", + format!("missing [package].{field}"), + )); + } + } + + if package.get("name").and_then(toml::Value::as_str).is_none() { + report.issues.push(ManifestIssue::error( + "invalid-package-name", + "expected [package].name to be a string", + )); + } + + if !has_string_or_workspace_true(package, "version") { + report.issues.push(ManifestIssue::warning( + "invalid-package-version", + "expected [package].version to be a string or use version.workspace = true", + )); + } + + match package.get("publish").and_then(toml::Value::as_bool) { + Some(true) => {}, + Some(false) => report.issues.push(ManifestIssue::warning( + "package-publish", + "expected [package].publish = true for publishable RustUse crates", + )), + None => report.issues.push(ManifestIssue::warning( + "missing-package-publish", + "missing [package].publish", + )), + } + + for field in EXPECTED_WORKSPACE_INHERITED_PACKAGE_FIELDS { + check_workspace_inherited_package_field(package, field, report); + } + + analyze_package_categories(package, workspace_categories, report); + + if kind == ManifestKind::FacadePackage { + validate_facade_dependency_and_feature_wiring( + manifest, + facade_name, + child_crate_names, + report, + ); + } +} + +fn analyze_package_categories( + package: &toml::Table, + workspace_categories: Option<&[String]>, + report: &mut ManifestFileReport, +) { + let Some(categories_value) = package.get("categories") else { + report.issues.push(ManifestIssue::warning( + "missing-package-categories", + "missing [package].categories or categories.workspace = true", + )); + return; + }; + + if is_workspace_true(categories_value) { + if workspace_categories.is_none() { + report.issues.push(ManifestIssue::error( + "missing-inherited-categories", + "package uses categories.workspace = true, but [workspace.package].categories is missing", + )); + } + + return; + } + + let Some(categories) = + collect_category_strings(categories_value, "[package].categories", report) + else { + return; + }; + + validate_categories(&categories, "[package].categories", report); +} + +fn check_workspace_inherited_package_field( + package: &toml::Table, + field: &'static str, + report: &mut ManifestFileReport, +) { + let Some(value) = package.get(field) else { + report.issues.push(ManifestIssue::warning( + "missing-package-inherited-field", + format!("missing [package].{field}.workspace = true"), + )); + return; + }; + + if is_workspace_true(value) { + return; + } + + report.issues.push(ManifestIssue::warning( + "package-field-not-inherited", + format!("expected [package].{field}.workspace = true"), + )); +} + +fn collect_category_strings( + value: &toml::Value, + field_name: &'static str, + report: &mut ManifestFileReport, +) -> Option> { + let Some(array) = value.as_array() else { + report.issues.push(ManifestIssue::error( + "invalid-categories-shape", + format!("expected {field_name} to be an array of strings"), + )); + return None; + }; + + let mut categories = Vec::new(); + + for (index, item) in array.iter().enumerate() { + let Some(category) = item.as_str() else { + report.issues.push(ManifestIssue::error( + "invalid-category-value", + format!("expected {field_name}[{index}] to be a string"), + )); + continue; + }; + + categories.push(category.to_string()); + } + + Some(categories) +} + +fn validate_categories( + categories: &[String], + field_name: &'static str, + report: &mut ManifestFileReport, +) { + if categories.len() > MAX_CRATES_IO_CATEGORIES { + report.issues.push(ManifestIssue::error( + "too-many-categories", + format!( + "{field_name} has {} categories; crates.io allows at most {}", + categories.len(), + MAX_CRATES_IO_CATEGORIES + ), + )); + } + + let mut seen = BTreeSet::new(); + + for category in categories { + if !seen.insert(category.as_str()) { + report.issues.push(ManifestIssue::warning( + "duplicate-category", + format!("{field_name} contains duplicate category `{category}`"), + )); + } + + if !is_valid_category_slug(category) { + report.issues.push(ManifestIssue::error( + "invalid-category-slug", + format!("`{category}` is not a valid crates.io category slug"), + )); + } + } +} + +fn has_string_or_workspace_true(package: &toml::Table, field: &'static str) -> bool { + let Some(value) = package.get(field) else { + return false; + }; + + value.as_str().is_some() || is_workspace_true(value) +} + +fn is_workspace_true(value: &toml::Value) -> bool { + value + .as_table() + .and_then(|table| table.get("workspace")) + .and_then(toml::Value::as_bool) + .unwrap_or(false) +} + +fn read_manifest(path: &Path, report: &mut ManifestFileReport) -> Option { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) => { + report.issues.push(ManifestIssue::error( + "read-manifest", + format!("failed to read `{}`: {error}", path.display()), + )); + return None; + }, + }; + + match toml::from_str(&raw) { + Ok(value) => Some(value), + Err(error) => { + report.issues.push(ManifestIssue::error( + "parse-manifest", + format!("failed to parse `{}`: {error}", path.display()), + )); + None + }, + } +} + +fn discover_crate_manifest_paths(crates_dir: &Path) -> Result> { + if !crates_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut manifests = Vec::new(); + + for entry in fs::read_dir(crates_dir) + .with_context(|| format!("failed to read `{}`", crates_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let manifest = path.join("Cargo.toml"); + + if manifest.is_file() { + manifests.push(manifest); + } + } + + manifests.sort(); + + Ok(manifests) +} + +fn relative_path(root: &Path, path: &Path) -> PathBuf { + path.strip_prefix(root).unwrap_or(path).to_path_buf() +} + +fn crate_dir_name(manifest_path: &Path) -> Option { + manifest_path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) +} + +#[derive(Debug, Clone)] +pub struct ChildCrateInfo { + // dir_name: String, + // package_name: Option, + version: Option, +} + +fn collect_child_crate_info( + crate_manifest_paths: &[PathBuf], + facade_name: &str, +) -> BTreeMap { + let mut child_crates = BTreeMap::new(); + + for manifest_path in crate_manifest_paths { + let Some(dir_name) = crate_dir_name(manifest_path) else { + continue; + }; + + if dir_name == facade_name { + continue; + } + + let raw = fs::read_to_string(manifest_path).ok(); + let manifest = raw + .as_deref() + .and_then(|raw| toml::from_str::(raw).ok()); + + let package = manifest + .as_ref() + .and_then(|manifest| manifest.get("package")) + .and_then(toml::Value::as_table); + + /* let package_name = package + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned); */ + + let version = package + .and_then(|package| package.get("version")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned); + + child_crates.insert( + dir_name.clone(), + ChildCrateInfo { + // dir_name, + // package_name, + version, + }, + ); + } + + child_crates +} + +fn validate_workspace_members(workspace: &toml::Table, report: &mut ManifestFileReport) { + let Some(members) = workspace.get("members").and_then(toml::Value::as_array) else { + return; + }; + + let members = members + .iter() + .filter_map(toml::Value::as_str) + .collect::>(); + + for expected in EXPECTED_WORKSPACE_MEMBERS { + if !members.contains(expected) { + report.issues.push(ManifestIssue::warning( + "missing-standard-workspace-member", + format!("expected [workspace].members to include `{expected}`"), + )); + } + } + + if members.len() != EXPECTED_WORKSPACE_MEMBERS.len() { + report.issues.push(ManifestIssue::warning( + "non-standard-workspace-members", + "expected [workspace].members to be exactly [\"crates/*\"]", + )); + } +} + +fn validate_workspace_repository( + workspace_package: &toml::Table, + facade_name: &str, + report: &mut ManifestFileReport, +) { + let expected = format!("{RUSTUSE_GITHUB_ORG}/{facade_name}"); + + match workspace_package + .get("repository") + .and_then(toml::Value::as_str) + { + Some(actual) if actual == expected => {}, + Some(actual) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-repository", + format!("expected [workspace.package].repository = `{expected}`, found `{actual}`"), + )), + None => {}, + } +} + +fn validate_workspace_dependencies( + workspace: &toml::Table, + child_crates: &BTreeMap, + report: &mut ManifestFileReport, +) { + let Some(dependencies) = workspace + .get("dependencies") + .and_then(toml::Value::as_table) + else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependencies", + "missing [workspace.dependencies]", + )); + return; + }; + + validate_all_workspace_dependency_versions(dependencies, report); + validate_child_workspace_dependencies(dependencies, child_crates, report); + validate_orphan_workspace_dependency_paths(dependencies, child_crates, report); +} + +fn validate_all_workspace_dependency_versions( + dependencies: &toml::Table, + report: &mut ManifestFileReport, +) { + for (dependency_name, dependency) in dependencies { + if let Some(version) = dependency.as_str() { + if version.trim().is_empty() { + report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-version", + format!( + "expected [workspace.dependencies].{dependency_name} to use a non-empty version string" + ), + )); + } + + continue; + } + + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-shape", + format!( + "expected [workspace.dependencies].{dependency_name} to be a version string or an inline table with version" + ), + )); + continue; + }; + + match dependency_table.get("version") { + Some(version) if version.as_str().is_some_and(|value| !value.trim().is_empty()) => {}, + Some(_) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-version", + format!( + "expected [workspace.dependencies].{dependency_name}.version to be a non-empty string" + ), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency-version", + format!("missing [workspace.dependencies].{dependency_name}.version"), + )), + } + } +} + +fn validate_child_workspace_dependencies( + dependencies: &toml::Table, + child_crates: &BTreeMap, + report: &mut ManifestFileReport, +) { + for (crate_name, child_crate) in child_crates { + let Some(dependency) = dependencies.get(crate_name) else { + report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency", + format!("missing [workspace.dependencies].{crate_name}"), + )); + continue; + }; + + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-shape", + format!("expected [workspace.dependencies].{crate_name} to be an inline table"), + )); + continue; + }; + + let expected_path = format!("crates/{crate_name}"); + + match dependency_table.get("path").and_then(toml::Value::as_str) { + Some(actual_path) if actual_path == expected_path => {}, + Some(actual_path) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-dependency-path", + format!( + "expected [workspace.dependencies].{crate_name}.path = `{expected_path}`, found `{actual_path}`" + ), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-dependency-path", + format!("missing [workspace.dependencies].{crate_name}.path"), + )), + } + + let actual_version = dependency_table + .get("version") + .and_then(toml::Value::as_str); + + if let (Some(expected_version), Some(actual_version)) = + (child_crate.version.as_deref(), actual_version) + && actual_version != expected_version + { + /* report.issues.push(ManifestIssue::warning( + "mismatched-workspace-dependency-version", + format!( + "workspace dependency `{}` uses version `{}`, but child crate version is `{}`", + child_crate.name, actual_version, expected_version + ), + )); */ + + report.issues.push(ManifestIssue::warning( + "mismatched-workspace-dependency-version", + format!( + "workspace dependency uses version `{actual_version}`, but child crate version is `{expected_version}`" + ), + )); + } + } +} + +fn validate_orphan_workspace_dependency_paths( + dependencies: &toml::Table, + child_crates: &BTreeMap, + report: &mut ManifestFileReport, +) { + for (dependency_name, dependency) in dependencies { + let Some(dependency_table) = dependency.as_table() else { + continue; + }; + + let Some(path) = dependency_table.get("path").and_then(toml::Value::as_str) else { + continue; + }; + + let Some(crate_name) = path.strip_prefix("crates/") else { + continue; + }; + + if !child_crates.contains_key(crate_name) { + report.issues.push(ManifestIssue::warning( + "orphan-workspace-dependency-path", + format!( + "[workspace.dependencies].{dependency_name}.path points to `{path}`, but no matching child crate manifest was found" + ), + )); + } + } +} + +fn validate_workspace_lints(workspace: &toml::Table, report: &mut ManifestFileReport) { + let unsafe_code = workspace + .get("lints") + .and_then(|lints| lints.get("rust")) + .and_then(|rust| rust.get("unsafe_code")) + .and_then(toml::Value::as_str); + + match unsafe_code { + Some("forbid") => {}, + Some(actual) => report.issues.push(ManifestIssue::warning( + "invalid-workspace-unsafe-code-policy", + format!("expected [workspace.lints.rust].unsafe_code = \"forbid\", found `{actual}`"), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-workspace-unsafe-code-policy", + "missing [workspace.lints.rust].unsafe_code = \"forbid\"", + )), + } + + let clippy = workspace + .get("lints") + .and_then(|lints| lints.get("clippy")) + .and_then(toml::Value::as_table); + + if clippy.is_none() { + report.issues.push(ManifestIssue::warning( + "missing-workspace-clippy-lints", + "missing [workspace.lints.clippy]", + )); + } +} + +fn validate_facade_dependency_and_feature_wiring( + manifest: &toml::Value, + _facade_name: &str, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + validate_facade_child_dependencies(manifest, child_crate_names, report); + validate_facade_features(manifest, child_crate_names, report); +} + +fn validate_facade_child_dependencies( + manifest: &toml::Value, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + if child_crate_names.is_empty() { + return; + } + + let Some(dependencies) = manifest.get("dependencies").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::warning( + "missing-facade-dependencies", + "facade crate has child crates but no [dependencies]", + )); + return; + }; + + for child_crate in child_crate_names { + let Some(dependency) = dependencies.get(child_crate) else { + report.issues.push(ManifestIssue::warning( + "missing-facade-child-dependency", + format!("missing [dependencies].{child_crate}"), + )); + continue; + }; + + let Some(dependency_table) = dependency.as_table() else { + report.issues.push(ManifestIssue::warning( + "invalid-facade-child-dependency", + format!("expected [dependencies].{child_crate} to be an inline table"), + )); + continue; + }; + + if !is_workspace_true(dependency) { + report.issues.push(ManifestIssue::warning( + "invalid-facade-child-dependency", + format!("expected [dependencies].{child_crate}.workspace = true"), + )); + } + + match dependency_table + .get("optional") + .and_then(toml::Value::as_bool) + { + Some(true) => {}, + Some(false) => report.issues.push(ManifestIssue::warning( + "invalid-facade-child-dependency", + format!("expected [dependencies].{child_crate}.optional = true"), + )), + None => report.issues.push(ManifestIssue::warning( + "missing-facade-child-dependency-optional", + format!("missing [dependencies].{child_crate}.optional = true"), + )), + } + } +} + +fn validate_facade_features( + manifest: &toml::Value, + child_crate_names: &BTreeSet, + report: &mut ManifestFileReport, +) { + if child_crate_names.is_empty() { + return; + } + + let Some(features) = manifest.get("features").and_then(toml::Value::as_table) else { + report.issues.push(ManifestIssue::warning( + "missing-facade-features", + "facade crate has child crates but no [features]", + )); + return; + }; + + match features.get("default").and_then(toml::Value::as_array) { + Some(default_features) if default_features.is_empty() => {}, + Some(_) => report.issues.push(ManifestIssue::warning( + "invalid-facade-default-features", + "expected [features].default = []", + )), + None => report.issues.push(ManifestIssue::warning( + "missing-facade-default-features", + "missing [features].default = []", + )), + } + + let full_features = features + .get("full") + .and_then(toml::Value::as_array) + .map(|values| array_string_set(values)); + + if full_features.is_none() { + report.issues.push(ManifestIssue::warning( + "missing-facade-full-feature", + "missing [features].full", + )); + } + + for child_crate in child_crate_names { + let feature_name = feature_name_for_child_crate(child_crate); + + if let Some(full_features) = &full_features + && !full_features.contains(feature_name.as_str()) + { + report.issues.push(ManifestIssue::warning( + "missing-full-feature-member", + format!( + "feature `{}` is not included in the `full` feature", + feature_name + ), + )); + } + + let Some(feature_values) = features + .get(feature_name.as_str()) + .and_then(toml::Value::as_array) + else { + report.issues.push(ManifestIssue::warning( + "missing-facade-child-feature", + format!("missing [features].{feature_name}"), + )); + continue; + }; + + let feature_values = array_string_set(feature_values); + let expected_dep = format!("dep:{child_crate}"); + + if !feature_values.contains(expected_dep.as_str()) { + report.issues.push(ManifestIssue::warning( + "invalid-facade-child-feature", + format!("expected [features].{feature_name} to include `{expected_dep}`"), + )); + } + } +} + +fn feature_name_for_child_crate(crate_name: &str) -> String { + crate_name + .strip_prefix("use-") + .unwrap_or(crate_name) + .to_string() +} + +fn array_string_set(array: &[toml::Value]) -> BTreeSet { + array + .iter() + .filter_map(toml::Value::as_str) + .map(ToOwned::to_owned) + .collect() +} diff --git a/src/commands/dev/utils/nonstandard.rs b/src/commands/dev/utils/nonstandard.rs new file mode 100644 index 0000000..7277846 --- /dev/null +++ b/src/commands/dev/utils/nonstandard.rs @@ -0,0 +1,71 @@ +//! Shared non-standard path checks for RustUse repositories. + +use std::path::Path; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum NonStandardPathKind { + Directory, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct NonStandardPathRule { + pub(crate) path: &'static str, + pub(crate) kind: NonStandardPathKind, + pub(crate) recommendation: &'static str, +} + +#[derive(Debug)] +pub(crate) struct NonStandardPathCheck { + pub(crate) path: &'static str, + pub(crate) recommendation: &'static str, + pub(crate) present: bool, +} + +#[derive(Debug)] +pub(crate) struct NonStandardPathReport { + pub(crate) checks: Vec, +} + +impl NonStandardPathReport { + pub(crate) fn present_checks(&self) -> Vec<&NonStandardPathCheck> { + self.checks.iter().filter(|check| check.present).collect() + } + + pub(crate) fn total_count(&self) -> usize { + self.checks.len() + } + + pub(crate) fn present_count(&self) -> usize { + self.checks.iter().filter(|check| check.present).count() + } + + pub(crate) fn status(&self) -> &'static str { + if self.present_count() == 0 { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn inspect_non_standard_paths( + root: &Path, + rules: &[NonStandardPathRule], +) -> NonStandardPathReport { + let checks = rules + .iter() + .map(|rule| { + let present = match rule.kind { + NonStandardPathKind::Directory => root.join(rule.path).is_dir(), + }; + + NonStandardPathCheck { + path: rule.path, + recommendation: rule.recommendation, + present, + } + }) + .collect(); + + NonStandardPathReport { checks } +} diff --git a/src/commands/dev/utils/release.rs b/src/commands/dev/utils/release.rs new file mode 100644 index 0000000..9bac874 --- /dev/null +++ b/src/commands/dev/utils/release.rs @@ -0,0 +1,61 @@ +//! Shared release surface checks for RustUse repositories. + +use std::path::Path; + +use crate::commands::dev::utils::report::PresenceCheck; + +#[derive(Debug)] +pub(crate) struct ReleaseSurfaceReport { + pub(crate) surface: Vec, + pub(crate) ci_surface: Vec, +} + +impl ReleaseSurfaceReport { + pub(crate) fn total_count(&self) -> usize { + self.surface.len() + } + + pub(crate) fn present_count(&self) -> usize { + self.surface.iter().filter(|check| check.present).count() + } + + pub(crate) fn ci_total_count(&self) -> usize { + self.ci_surface.len() + } + + pub(crate) fn ci_present_count(&self) -> usize { + self.ci_surface.iter().filter(|check| check.present).count() + } + + pub(crate) fn status(&self) -> &'static str { + if self.surface.iter().all(|check| check.present) { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn inspect_release_surface(root: &Path) -> ReleaseSurfaceReport { + let surface = vec![ + PresenceCheck::new("release-plz.toml", file_exists(root, "release-plz.toml")), + PresenceCheck::new("RELEASE.md", file_exists(root, "RELEASE.md")), + PresenceCheck::new("RELEASING.md", file_exists(root, "RELEASING.md")), + PresenceCheck::new("CHANGELOG.md", file_exists(root, "CHANGELOG.md")), + PresenceCheck::new("Cargo.lock", file_exists(root, "Cargo.lock")), + ]; + + let ci_surface = vec![PresenceCheck::new( + "release-plz.toml", + file_exists(root, "release-plz.toml"), + )]; + + ReleaseSurfaceReport { + surface, + ci_surface, + } +} + +fn file_exists(root: &Path, path: &str) -> bool { + root.join(path).is_file() +} diff --git a/src/commands/dev/utils/report.rs b/src/commands/dev/utils/report.rs new file mode 100644 index 0000000..0c36e14 --- /dev/null +++ b/src/commands/dev/utils/report.rs @@ -0,0 +1,76 @@ +//! Shared Markdown report output helpers for RustUse development commands. + +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +pub(crate) const DEFAULT_REPORT_FILE_NAME: &str = "rustuse-report.md"; + +#[derive(Debug, Clone)] +pub(crate) struct PresenceCheck { + pub(crate) path: String, + pub(crate) present: bool, +} + +impl PresenceCheck { + pub(crate) fn new(path: impl Into, present: bool) -> Self { + Self { + path: path.into(), + present, + } + } +} + +pub(crate) fn markdown_path(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + +pub(crate) fn write_presence_table(markdown: &mut String, checks: &[PresenceCheck]) { + markdown.push_str("| Surface | Present |\n"); + markdown.push_str("|---|---:|\n"); + + for check in checks { + markdown.push_str(&format!( + "| `{}` | {} |\n", + check.path, + yes_no(check.present) + )); + } + + markdown.push('\n'); +} + +pub(crate) fn default_report_path(root: &Path) -> PathBuf { + root.join(DEFAULT_REPORT_FILE_NAME) +} + +pub(crate) fn resolve_report_path(root: &Path, output: Option) -> PathBuf { + output.unwrap_or_else(|| default_report_path(root)) +} + +pub(crate) fn write_markdown_report(path: &Path, report: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + + let file = + File::create(path).with_context(|| format!("failed to create `{}`", path.display()))?; + let mut writer = BufWriter::new(file); + + writer + .write_all(report.as_bytes()) + .with_context(|| format!("failed to write `{}`", path.display()))?; + + writer + .flush() + .with_context(|| format!("failed to flush `{}`", path.display()))?; + + Ok(()) +} + +pub(crate) fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/src/commands/dev/utils/scan.rs b/src/commands/dev/utils/scan.rs new file mode 100644 index 0000000..debf9a9 --- /dev/null +++ b/src/commands/dev/utils/scan.rs @@ -0,0 +1,155 @@ +//! Shared filesystem surface scanning for RustUse development reports. + +use std::path::Path; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) enum SurfaceCheckStatus { + Ok, + Warning, +} + +impl SurfaceCheckStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warning => "warning", + } + } + + fn combine(self, other: Self) -> Self { + self.max(other) + } +} + +#[derive(Debug)] +pub(crate) struct SurfaceProfile { + pub(crate) required_files: &'static [(&'static str, &'static str)], + pub(crate) optional_files: &'static [(&'static str, &'static str)], + pub(crate) required_directories: &'static [(&'static str, &'static str)], + pub(crate) optional_directories: &'static [(&'static str, &'static str)], +} + +#[derive(Debug)] +pub(crate) struct RepositorySurfaceReport { + pub(crate) files: Vec, + pub(crate) directories: Vec, +} + +impl RepositorySurfaceReport { + pub(crate) fn status(&self) -> SurfaceCheckStatus { + self.files + .iter() + .map(|check| check.status) + .chain(self.directories.iter().map(|check| check.status)) + .fold(SurfaceCheckStatus::Ok, SurfaceCheckStatus::combine) + } + + pub(crate) fn missing_required_files(&self) -> Vec<&FileSurfaceCheck> { + self.files + .iter() + .filter(|check| check.required && !check.present) + .collect() + } + + pub(crate) fn missing_required_directories(&self) -> Vec<&DirectorySurfaceCheck> { + self.directories + .iter() + .filter(|check| check.required && !check.present) + .collect() + } +} + +#[derive(Debug)] +pub(crate) struct FileSurfaceCheck { + pub(crate) path: &'static str, + pub(crate) label: &'static str, + pub(crate) required: bool, + pub(crate) present: bool, + pub(crate) status: SurfaceCheckStatus, +} + +#[derive(Debug)] +pub(crate) struct DirectorySurfaceCheck { + pub(crate) path: &'static str, + pub(crate) label: &'static str, + pub(crate) required: bool, + pub(crate) present: bool, + pub(crate) status: SurfaceCheckStatus, +} + +pub(crate) fn inspect_repository_surface( + root: &Path, + profile: &SurfaceProfile, +) -> RepositorySurfaceReport { + let files = profile + .required_files + .iter() + .map(|(path, label)| inspect_file(root, path, label, true)) + .chain( + profile + .optional_files + .iter() + .map(|(path, label)| inspect_file(root, path, label, false)), + ) + .collect(); + + let directories = profile + .required_directories + .iter() + .map(|(path, label)| inspect_directory(root, path, label, true)) + .chain( + profile + .optional_directories + .iter() + .map(|(path, label)| inspect_directory(root, path, label, false)), + ) + .collect(); + + RepositorySurfaceReport { files, directories } +} + +fn inspect_file( + root: &Path, + path: &'static str, + label: &'static str, + required: bool, +) -> FileSurfaceCheck { + let present = root.join(path).is_file(); + + let status = if required && !present { + SurfaceCheckStatus::Warning + } else { + SurfaceCheckStatus::Ok + }; + + FileSurfaceCheck { + path, + label, + required, + present, + status, + } +} + +fn inspect_directory( + root: &Path, + path: &'static str, + label: &'static str, + required: bool, +) -> DirectorySurfaceCheck { + let present = root.join(path).is_dir(); + + let status = if required && !present { + SurfaceCheckStatus::Warning + } else { + SurfaceCheckStatus::Ok + }; + + DirectorySurfaceCheck { + path, + label, + required, + present, + status, + } +} diff --git a/src/commands/dev/utils/tooling.rs b/src/commands/dev/utils/tooling.rs new file mode 100644 index 0000000..46aa1c3 --- /dev/null +++ b/src/commands/dev/utils/tooling.rs @@ -0,0 +1,54 @@ +//! Shared tooling configuration surface checks for RustUse repositories. + +use std::path::Path; + +use crate::commands::dev::utils::report::PresenceCheck; + +#[derive(Debug)] +pub(crate) struct ToolingSurfaceReport { + pub(crate) surface: Vec, +} + +impl ToolingSurfaceReport { + pub(crate) fn total_count(&self) -> usize { + self.surface.len() + } + + pub(crate) fn present_count(&self) -> usize { + self.surface.iter().filter(|check| check.present).count() + } + + pub(crate) fn status(&self) -> &'static str { + if self.surface.iter().all(|check| check.present) { + "ok" + } else { + "warning" + } + } +} + +pub(crate) fn inspect_tooling_surface(root: &Path) -> ToolingSurfaceReport { + let surface = vec![ + PresenceCheck::new(".cargo", dir_exists(root, ".cargo")), + PresenceCheck::new(".clippy.toml", file_exists(root, ".clippy.toml")), + PresenceCheck::new(".rustfmt.toml", file_exists(root, ".rustfmt.toml")), + PresenceCheck::new(".taplo.toml", file_exists(root, ".taplo.toml")), + PresenceCheck::new("deny.toml", file_exists(root, "deny.toml")), + PresenceCheck::new(".gitleaks.toml", file_exists(root, ".gitleaks.toml")), + PresenceCheck::new(".trivyignore", file_exists(root, ".trivyignore")), + PresenceCheck::new( + "rust-toolchain.toml", + file_exists(root, "rust-toolchain.toml"), + ), + ]; + + ToolingSurfaceReport { surface } +} + +fn file_exists(root: &Path, path: &str) -> bool { + root.join(path).is_file() +} + +fn dir_exists(root: &Path, path: &str) -> bool { + root.join(path).is_dir() +} diff --git a/src/commands/docs.rs b/src/commands/docs.rs index 69f579a..114338d 100644 --- a/src/commands/docs.rs +++ b/src/commands/docs.rs @@ -1,10 +1,25 @@ use anyhow::{Context, Result}; +use clap::Args; -use crate::cli::DocsArgs; +use crate::cli::NamedCommandArgs; use crate::output::Output; use super::entry_for; +#[derive(Debug, Args)] +pub struct DocsArgs { + #[command(flatten)] + pub target: NamedCommandArgs, + + /// Print API RustDocs URL. + #[arg(long, conflicts_with = "workspace")] + pub api: bool, + + /// Print workspace RustDocs URL. + #[arg(long, conflicts_with = "api")] + pub workspace: bool, +} + pub fn run(args: DocsArgs, output: Output) -> Result<()> { let entry = entry_for(&args.target.name)?; diff --git a/src/commands/init.rs b/src/commands/init.rs index 4cb1e93..bf09902 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,10 +1,39 @@ use anyhow::{Context, Result, bail}; +use clap::Args; +// use std::path::PathBuf; -use crate::cli::InitArgs; +// use crate::cli::InitArgs; use crate::config::{self, RustUseConfig}; use crate::output::Output; use crate::project::{self, CONFIG_FILE}; +#[derive(Debug, Args)] +pub struct InitArgs { + /// Prefer copy-mode defaults in rustuse.toml. + #[arg(long)] + pub copy_first: bool, + + /// Prefer Cargo-mode defaults in rustuse.toml. + #[arg(long)] + pub cargo_first: bool, + + /// Accept the v0.1 defaults without prompting. + #[arg(long)] + pub yes: bool, + + /// Show what would be created without writing files. + #[arg(long)] + pub dry_run: bool, + + /// Override the configured copy root. + #[arg(long, value_name = "PATH")] + pub copy_root: Option, + + /// Override the configured test root. + #[arg(long, value_name = "PATH")] + pub test_root: Option, +} + pub fn run(args: InitArgs, output: Output) -> Result<()> { if args.copy_first && args.cargo_first { bail!("--copy-first and --cargo-first cannot be used together"); diff --git a/src/commands/search.rs b/src/commands/search.rs index 87b90e9..8fe61d7 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -1,9 +1,16 @@ use anyhow::Result; +use clap::Args; -use crate::cli::SearchArgs; +// use crate::cli::SearchArgs; use crate::index; use crate::output::Output; +#[derive(Debug, Args)] +pub struct SearchArgs { + /// Search text, such as geometry or use-slug. + pub query: String, +} + pub fn run(args: SearchArgs, output: Output) -> Result<()> { let total_entries = index::all_entries().len(); let matches = index::search(&args.query); diff --git a/src/lib.rs b/src/lib.rs index 1f74af0..651730f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,16 @@ +//! RustUse command-line adoption helper. +//! +//! This crate powers the `rustuse` and `cargo-rustuse` binaries. +//! +//! The stable runtime entry points are [`run`] and [`run_cargo_subcommand`]. +//! Most modules are internal command adapters and development utilities. +//! +//! Use this while working on CLI internals: +//! +//! ```text +//! cargo doc --no-deps --document-private-items --open +//! ``` + #![forbid(unsafe_code)] use std::ffi::{OsStr, OsString}; @@ -5,6 +18,7 @@ use std::ffi::{OsStr, OsString}; mod cli; mod commands; mod config; +// mod dev; mod index; mod manifest; mod output; @@ -23,7 +37,7 @@ pub fn run_cargo_subcommand() -> Result<()> { run_from(cargo_subcommand_args()) } -fn run_from(args: I) -> Result<()> +pub fn run_from(args: I) -> Result<()> where I: IntoIterator, T: Into + Clone, @@ -41,9 +55,9 @@ fn cargo_subcommand_args() -> Vec { } match raw_args.next() { - Some(first) if first.as_os_str() == OsStr::new("rustuse") => {} + Some(first) if first.as_os_str() == OsStr::new("rustuse") => {}, Some(first) => args.push(first), - None => {} + None => {}, } args.extend(raw_args); diff --git a/tests/cli_help.rs b/tests/cli_help.rs index 5ed0c4b..83f671a 100644 --- a/tests/cli_help.rs +++ b/tests/cli_help.rs @@ -98,6 +98,72 @@ impl Drop for TempProject { } } +#[test] +fn dev_facade_run_reports_basic_facade_shape() { + let project = TempProject::new("dev-facade-run"); + + fs::write(project.path().join("Cargo.toml"), "[workspace]\n") + .expect("failed to write Cargo.toml"); + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("crates/use-example")) + .expect("failed to create crate directory"); + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "run", "."], + project.path(), + ); + + assert!(output.contains("RustUse dev facade run")); + assert!(output.contains("git: yes")); + assert!(output.contains("Cargo.toml: yes")); + assert!(output.contains("crates/: yes")); + assert!(output.contains("crate manifests: 1")); + assert!(output.contains("status: ok")); +} + +#[test] +fn dev_root_scan_reports_basic_facade_inventory() { + let project = TempProject::new("dev-root-scan"); + let facade_root = project.path().join("use-example"); + + fs::create_dir_all(facade_root.join(".git")).expect("failed to create facade .git directory"); + fs::create_dir_all(facade_root.join("crates/use-example")) + .expect("failed to create facade crate directory"); + + fs::write( + facade_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\n", + ) + .expect("failed to write facade workspace Cargo.toml"); + + fs::write( + facade_root.join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write facade package Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "root", "scan", "."], + project.path(), + ); + + assert!(output.contains("RustUse dev root scan - root:")); + assert!(output.contains("found 1 use-* directories")); + assert!(output.contains("facade repos with .git: 1")); + assert!(output.contains("facades missing .git: 0")); + assert!(output.contains("child crates detected: 1")); + assert!(output.contains("use-example")); + assert!(output.contains("0.1.0")); + assert!(output.contains("status: ok")); +} + #[test] fn root_help_works() { let output = run_help(CliBinary::Rustuse, &["--help"]); @@ -150,6 +216,41 @@ fn doctor_help_works() { assert!(output.contains("Check this directory")); } +#[test] +fn dev_help_works() { + let output = run_help(CliBinary::Rustuse, &["dev", "--help"]); + + assert!(output.contains("check")); + assert!(output.contains("facade")); + assert!(output.contains("info")); + assert!(output.contains("root")); +} + +#[test] +fn dev_root_help_works() { + let output = run_help(CliBinary::Rustuse, &["dev", "root", "--help"]); + + assert!(output.contains("inspect")); + assert!(output.contains("manifests")); + assert!(output.contains("report")); + assert!(output.contains("scan")); +} + +#[test] +fn dev_facade_help_works() { + let output = run_help(CliBinary::Rustuse, &["dev", "facade", "--help"]); + + assert!(output.contains("run")); + assert!(output.contains("report")); +} + +#[test] +fn dev_facade_run_help_works() { + let output = run_help(CliBinary::Rustuse, &["dev", "facade", "run", "--help"]); + + assert!(output.contains("Facade repository root")); +} + #[test] fn rustuse_search_geometry_works() { let project = TempProject::new("rustuse-search-geometry"); @@ -280,8 +381,10 @@ fn init_rejects_conflicting_modes() { ); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr) - .contains("--copy-first and --cargo-first cannot be used together")); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("--copy-first and --cargo-first cannot be used together") + ); } #[test] @@ -332,3 +435,482 @@ fn cargo_rustuse_add_use_geometry_works() { assert!(output.contains("Would add use-geometry as a Cargo dependency")); assert!(!project.path().join("rustuse.toml").exists()); } + +#[test] +fn dev_without_subcommand_shows_help() { + let project = TempProject::new("dev-no-subcommand"); + let output = run_raw(CliBinary::Rustuse, &["dev"], project.path()); + + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage:")); + assert!(stderr.contains("Commands:")); +} + +#[test] +fn dev_root_without_subcommand_shows_help() { + let project = TempProject::new("dev-root-no-subcommand"); + let output = run_raw(CliBinary::Rustuse, &["dev", "root"], project.path()); + + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage:")); + assert!(stderr.contains("Commands:")); +} + +#[test] +fn dev_facade_without_subcommand_shows_help() { + let project = TempProject::new("dev-facade-no-subcommand"); + let output = run_raw(CliBinary::Rustuse, &["dev", "facade"], project.path()); + + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage:")); + assert!(stderr.contains("Commands:")); +} + +#[test] +fn dev_root_report_stdout_includes_basic_sections() { + let project = TempProject::new("dev-root-report-stdout"); + let facade_root = project.path().join("use-example"); + + fs::create_dir_all(facade_root.join(".git")).expect("failed to create facade .git directory"); + fs::create_dir_all(facade_root.join("crates/use-example")) + .expect("failed to create facade crate directory"); + + fs::write( + facade_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"3\"\n", + ) + .expect("failed to write facade workspace Cargo.toml"); + + fs::write( + facade_root.join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write facade package Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "root", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("RustUse dev root report - root:")); + assert!(output.contains("# RustUse Development Root Report")); + assert!(output.contains("## Summary")); + assert!(output.contains("## Action Plan")); + assert!(output.contains("## Cargo Manifest Health")); + assert!(output.contains("## Facade Inventory")); + assert!(output.contains("use-example")); +} + +#[test] +fn dev_facade_report_help_works() { + let output = run_help(CliBinary::Rustuse, &["dev", "facade", "report", "--help"]); + + assert!(output.contains("Facade repository root")); + assert!(output.contains("--output")); + assert!(output.contains("--stdout")); +} + +#[test] +fn dev_facade_report_stdout_includes_basic_sections() { + let project = TempProject::new("dev-facade-report-stdout"); + + fs::write(project.path().join("Cargo.toml"), "[workspace]\n") + .expect("failed to write Cargo.toml"); + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("crates/use-example")) + .expect("failed to create crate directory"); + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("RustUse dev facade report - root:")); + assert!(output.contains("# RustUse Facade Report")); + assert!(output.contains("## Summary")); + assert!(output.contains("## Action Plan")); + assert!(output.contains("## Facade Shape")); + assert!(output.contains("- [Cargo Manifest Health](#cargo-manifest-health)")); + assert!(output.contains("## Cargo Manifest Health")); + assert!(output.contains("### Manifest Inventory")); + assert!(output.contains("## Child Crates")); + assert!(output.contains("use-example")); + assert!(output.contains("crates/use-example/Cargo.toml")); + assert!(output.contains("Status:")); + assert!(output.contains("- Status: **warning**")); + assert!(output.contains("- Clean up manifest warnings.")); +} +#[test] +fn dev_facade_report_writes_default_report() { + let project = TempProject::new("dev-facade-report-write"); + + fs::write(project.path().join("Cargo.toml"), "[workspace]\n") + .expect("failed to write Cargo.toml"); + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("crates/use-example")) + .expect("failed to create crate directory"); + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", "."], + project.path(), + ); + + let report_path = project.path().join("rustuse-report.md"); + let report = fs::read_to_string(&report_path).expect("failed to read generated report"); + + assert!(output.contains("RustUse dev facade report - root:")); + assert!(output.contains("wrote:")); + assert!(report_path.is_file()); + assert!(report.contains("# RustUse Facade Report")); + assert!(report.contains("## Summary")); + assert!(report.contains("## Action Plan")); + assert!(report.contains("## Facade Shape")); + assert!(report.contains("- [Cargo Manifest Health](#cargo-manifest-health)")); + assert!(report.contains("## Cargo Manifest Health")); + assert!(report.contains("### Manifest Inventory")); + assert!(report.contains("## Child Crates")); + assert!(report.contains("use-example")); + assert!(report.contains("crates/use-example/Cargo.toml")); + assert!(report.contains("- Status: **warning**")); + assert!(report.contains("- Clean up manifest warnings.")); +} + +#[test] +fn dev_facade_report_writes_custom_report_path() { + let project = TempProject::new("dev-facade-report-custom-output"); + + fs::write(project.path().join("Cargo.toml"), "[workspace]\n") + .expect("failed to write Cargo.toml"); + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("crates/use-example")) + .expect("failed to create crate directory"); + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &[ + "dev", + "facade", + "report", + ".", + "--output", + "reports/facade-report.md", + ], + project.path(), + ); + + let report_path = project.path().join("reports/facade-report.md"); + let report = fs::read_to_string(&report_path).expect("failed to read generated report"); + + assert!(output.contains("wrote:")); + assert!(report_path.is_file()); + assert!(report.contains("# RustUse Facade Report")); + assert!(report.contains("- [Cargo Manifest Health](#cargo-manifest-health)")); + assert!(report.contains("## Cargo Manifest Health")); + assert!(report.contains("### Manifest Inventory")); + assert!(report.contains("use-example")); + assert!(report.contains("crates/use-example/Cargo.toml")); +} + +#[test] +fn dev_facade_report_includes_manifest_issue_counts() { + let project = TempProject::new("dev-facade-report-manifest-health"); + + fs::write(project.path().join("Cargo.toml"), "[workspace]\n") + .expect("failed to write Cargo.toml"); + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("crates/use-example")) + .expect("failed to create crate directory"); + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("## Cargo Manifest Health")); + assert!(output.contains("- Status: **warning**")); + assert!(output.contains("- Manifests inspected: `2`")); + assert!(output.contains("- Issues: `")); + assert!(output.contains("- Warnings: `")); + assert!(output.contains("### Manifest Issues")); + assert!(output.contains("missing-workspace-resolver")); + assert!(output.contains("- Clean up manifest warnings.")); +} + +#[test] +fn dev_facade_report_stdout_includes_repository_surface_sections() { + let project = TempProject::new("dev-facade-report-repository-surface"); + + fs::write( + project.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"3\"\n", + ) + .expect("failed to write Cargo.toml"); + + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join(".cargo")).expect("failed to create .cargo"); + fs::create_dir_all(project.path().join(".github")).expect("failed to create .github"); + fs::create_dir_all(project.path().join("crates/use-example/src")) + .expect("failed to create crate src"); + + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + fs::write( + project.path().join("crates/use-example/src/lib.rs"), + "#![forbid(unsafe_code)]\n", + ) + .expect("failed to write lib.rs"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("## Repository Surface")); + assert!(output.contains("## Standard File Consistency")); + assert!(output.contains("## Tooling Configuration")); + assert!(output.contains("## Development Environment")); + assert!(output.contains(".cargo/config.toml")); + assert!(output.contains("## CI/CD Surface")); + assert!(output.contains("## Documentation Surface")); + assert!(output.contains("## Release Surface")); + assert!(output.contains("## Generated / Local Artifacts")); + assert!(output.contains("| `.cargo` | yes |")); + assert!(output.contains("| `.github/` | yes |")); +} + +#[test] +fn dev_facade_report_flags_docs_directory_as_non_standard() { + let project = TempProject::new("dev-facade-report-non-standard-docs"); + + fs::write( + project.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"3\"\n", + ) + .expect("failed to write Cargo.toml"); + + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("docs")).expect("failed to create docs directory"); + fs::create_dir_all(project.path().join("crates/use-example/src")) + .expect("failed to create crate src"); + + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + fs::write( + project.path().join("crates/use-example/src/lib.rs"), + "#![forbid(unsafe_code)]\n", + ) + .expect("failed to write lib.rs"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("## Non-standard Paths")); + assert!( + output.contains("| `docs/` | Move facade documentation to the central docs repository. |") + ); + assert!(output.contains("## Documentation Surface")); + assert!(!output.contains("docs/maintainer-release-flow.md")); + assert!(output.contains("- [Non-standard Paths](#non-standard-paths)")); +} + +#[test] +fn dev_facade_report_includes_github_gitlab_and_release_ci_surfaces() { + let project = TempProject::new("use-example"); + + fs::write( + project.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"3\"\n", + ) + .expect("failed to write Cargo.toml"); + + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join(".github/workflows")) + .expect("failed to create GitHub workflows directory"); + fs::create_dir_all(project.path().join(".gitlab")).expect("failed to create GitLab directory"); + + fs::write( + project.path().join(".github/dependabot.yml"), + "version: 2\nupdates: []\n", + ) + .expect("failed to write dependabot.yml"); + + fs::write(project.path().join(".gitlab-ci.yml"), "stages: []\n") + .expect("failed to write .gitlab-ci.yml"); + + fs::write(project.path().join("release-plz.toml"), "[workspace]\n") + .expect("failed to write release-plz.toml"); + + for (path, contents) in [ + ("README.md", "# use-example\n"), + ("CHANGELOG.md", "# Changelog\n"), + ("CONTRIBUTING.md", "# Contributing\n"), + ("GOVERNANCE.md", "# Governance\n"), + ("MAINTAINERS.md", "# Maintainers\n"), + ("RELEASE.md", "# Release\n"), + ("RELEASING.md", "# Releasing\n"), + ( + "Cargo.lock", + "# This file is automatically @generated by Cargo.\n", + ), + ] { + fs::write(project.path().join(path), contents) + .unwrap_or_else(|error| panic!("failed to write {path}: {error}")); + } + + fs::create_dir_all(project.path().join("crates/use-example/src")) + .expect("failed to create facade crate src"); + + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write facade Cargo.toml"); + + fs::write( + project.path().join("crates/use-example/README.md"), + "# use-example\n", + ) + .expect("failed to write README.md"); + + fs::write( + project.path().join("crates/use-example/src/lib.rs"), + "#![forbid(unsafe_code)]\n", + ) + .expect("failed to write lib.rs"); + + fs::write( + project.path().join("crates/use-example/src/prelude.rs"), + "pub use crate::*;\n", + ) + .expect("failed to write prelude.rs"); + + fs::create_dir_all(project.path().join(".cargo")).expect("failed to create .cargo"); + fs::create_dir_all(project.path().join(".devcontainer")) + .expect("failed to create .devcontainer"); + fs::create_dir_all(project.path().join("scripts")).expect("failed to create scripts"); + + for (path, contents) in [ + (".cargo/config.toml", ""), + (".clippy.toml", ""), + (".rustfmt.toml", ""), + (".taplo.toml", ""), + ("deny.toml", ""), + (".gitleaks.toml", ""), + (".trivyignore", ""), + ("rust-toolchain.toml", "[toolchain]\nchannel = \"1.95.0\"\n"), + (".devcontainer/devcontainer.json", "{}\n"), + (".devcontainer/post-create.sh", "#!/usr/bin/env sh\n"), + ("scripts/bootstrap-dev-tools.ps1", ""), + ("scripts/bootstrap-dev-tools.sh", "#!/usr/bin/env sh\n"), + ("scripts/sync-mirrors.sh", "#!/usr/bin/env sh\n"), + ] { + fs::write(project.path().join(path), contents) + .unwrap_or_else(|error| panic!("failed to write {path}: {error}")); + } + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("## CI/CD Surface")); + assert!(output.contains("### GitHub CI/CD Surface")); + assert!(output.contains("### Required GitHub Workflows")); + assert!(output.contains("### GitLab CI Surface")); + assert!(output.contains("### Release CI/CD Surface")); + assert!(output.contains("- GitLab CI surface: `2/2`")); + assert!(output.contains("| `.gitlab/` | yes |")); + assert!(output.contains("| `.gitlab-ci.yml` | yes |")); + assert!(output.contains("| `release-plz.toml` | yes |")); + assert!(output.contains("## Documentation Surface\n\n- Status: **ok**\n- Present: `5/5`")); + assert!(output.contains("## Tooling Configuration\n\n- Status: **ok**\n- Present: `8/8`")); + assert!(output.contains("## Development Environment\n\n- Status: **ok**\n- Present: `6/6`")); + assert!(output.contains("## Release Surface\n\n- Status: **ok**\n- Present: `5/5`")); + assert!(output.contains("- Release CI/CD surface: `1/1`")); +} + +#[test] +fn dev_facade_report_includes_generated_local_artifacts() { + let project = TempProject::new("dev-facade-report-generated-artifacts"); + + fs::write( + project.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"3\"\n", + ) + .expect("failed to write Cargo.toml"); + + fs::create_dir_all(project.path().join(".git")).expect("failed to create .git directory"); + fs::create_dir_all(project.path().join("crates/use-example/src")) + .expect("failed to create crate src"); + + fs::write( + project.path().join("crates/use-example/Cargo.toml"), + "[package]\nname = \"use-example\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("failed to write child Cargo.toml"); + + fs::write( + project.path().join("crates/use-example/src/lib.rs"), + "#![forbid(unsafe_code)]\n", + ) + .expect("failed to write lib.rs"); + + fs::create_dir_all(project.path().join("target/flycheck0")) + .expect("failed to create flycheck0 directory"); + fs::create_dir_all(project.path().join("target/flycheck20")) + .expect("failed to create flycheck20 directory"); + + let output = run_success( + CliBinary::Rustuse, + &["dev", "facade", "report", ".", "--stdout"], + project.path(), + ); + + assert!(output.contains("## Generated / Local Artifacts")); + assert!(output.contains("| `target` | Cargo build output |")); + assert!(output.contains("| `target/flycheck0` | rust-analyzer flycheck output |")); + assert!(output.contains("| `target/flycheck20` | rust-analyzer flycheck output |")); +}