diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..81c82ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Release + +# Tag a version to cut a release: +# cargo set-version 0.1.0 # or edit Cargo.toml +# git tag v0.1.0 && git push origin v0.1.0 +# +# This builds a binary per platform, attaches them to the GitHub Release, and +# publishes the crate to crates.io. Because the assets are named +# `frd-.{tar.gz,zip}` with the binary at the archive root, `cargo binstall frd` +# finds them with no `[package.metadata.binstall]` config (it matches binstall's +# default naming). `cargo install frd` keeps working by compiling from source. + +on: + push: + tags: ["v*"] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + permissions: + contents: write # create the release and upload assets + strategy: + fail-fast: false + matrix: + include: + - { os: macos-latest, target: aarch64-apple-darwin } + - { os: macos-latest, target: x86_64-apple-darwin } + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu } + - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu } + - { os: windows-latest, target: x86_64-pc-windows-msvc } + steps: + - uses: actions/checkout@v6 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + archive="frd-${{ matrix.target }}.tar.gz" + tar -czf "$archive" -C "target/${{ matrix.target }}/release" frd + echo "ASSET=$archive" >> "$GITHUB_ENV" + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $archive = "frd-${{ matrix.target }}.zip" + Compress-Archive -Path "target/${{ matrix.target }}/release/frd.exe" -DestinationPath $archive + "ASSET=$archive" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload to release + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.ASSET }} + fail_on_unmatched_files: true + generate_release_notes: true + + publish-crate: + name: publish to crates.io + # Only publish once every platform binary built, so a failed build never ships a + # crates.io version whose GitHub Release is missing binaries. + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Publish + run: cargo publish --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 45223d1..653c969 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "bstr" version = "1.12.1" @@ -174,6 +180,7 @@ dependencies = [ "console", "dirs", "similar", + "sysinfo", "toml_edit", ] @@ -237,6 +244,34 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -333,6 +368,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -414,19 +463,152 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bd744f2..4f67455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" description = "Speed up local Rust builds and shrink target/ with interactive cargo and profile tuning" license = "MIT" repository = "https://github.com/cs50victor/fast-rust-dev" +readme = "README.md" +keywords = ["cargo", "build", "performance", "disk", "optimization"] +categories = ["command-line-utilities", "development-tools"] [dependencies] anyhow = "1.0.102" @@ -12,4 +15,11 @@ clap = { version = "4.6.1", features = ["derive"] } console = "0.16.3" dirs = "6.0.0" similar = "3.1.1" +# Pinned to 0.36 on purpose: it has MSRV 1.75 (below our edition-2024 floor, so no cost), +# while 0.37+ raises the MSRV and 0.39 needs Rust 1.95. Only RAM and disk facts are used. +sysinfo = { version = "0.36", default-features = false, features = ["system", "disk"] } toml_edit = "0.25.12" + +# frd recommends `strip` to the projects it tunes, so it ships its own binaries stripped too. +[profile.release] +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..f752706 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +
+

frd — fast rust dev

+

Interactive optimizer for local Rust builds.

+
+ +`frd` reads your machine and project, then walks you through a catalog of build-speed +and `target/`-shrinking changes one at a time. You accept or skip each one; nothing is +written without your say-so. + +Works on macOS, Linux, and Windows. It works best on macOS, where every probe and +suggestion applies; on other platforms the catalog narrows to what fits (for example, +the macOS-only `split-debuginfo` tweak is hidden). + +## Install + +Prebuilt binary (no compile), via [cargo-binstall](https://github.com/cargo-bins/cargo-binstall): + +```sh +cargo binstall frd +``` + +From source: + +```sh +cargo install frd +``` + +Prebuilt binaries for macOS (Apple Silicon + Intel), Linux (x86_64 + arm64), and +Windows (x86_64) are attached to each [GitHub release](https://github.com/cs50victor/fast-rust-dev/releases). + +## Usage + +Run it inside a Cargo project (or anywhere, for the global suggestions): + +```sh +frd # report, then the interactive wizard +frd report # print the system and project report, then exit +frd --dry-run # show every change as a diff, write and run nothing +frd --yes # accept every applicable suggestion without prompting +frd --root DIR # operate on DIR instead of the current directory +``` + +In the wizard, each suggestion is a card: `[a]ccept`, `[s]kip`, or `[q]uit`. + +## What it can change + +Every edit is format-preserving (comments and ordering survive) and backed up with a +timestamped `.frd-bak-*` copy before writing. + +- **`~/.cargo/config.toml`** — a shared `target-dir` so repos and git worktrees stop + duplicating `target/`; on nightly, `no-embed-metadata`; route `rustc` through + `sccache` once it is installed. +- **`./Cargo.toml` profiles** — `dev` debug as `line-tables-only`, optimized + dependencies, a disk-light `fast-build` profile, `release` `strip = true`, and on + macOS `split-debuginfo = "unpacked"`. +- **`./.cargo/config.toml`** — on nightly, parallel-frontend and share-generics + rustflags, kept project-local so they do not override a repo's own flags. +- **Tools** — install `sccache`, `cargo-sweep`, and `cargo-machete` (preferring + `cargo-binstall` when present), and sweep stale build artifacts. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/src/cli.rs b/src/cli.rs index 3c4eaa0..70632f8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; #[command( name = "frd", version, - about = "fast rust dev: build and disk optimizer for macOS" + about = "fast rust dev: interactive build-speed and disk optimizer for cargo projects (best on macOS)" )] pub struct Cli { #[command(subcommand)] diff --git a/src/system.rs b/src/system.rs index 4a3ef4f..4d0d7b9 100644 --- a/src/system.rs +++ b/src/system.rs @@ -1,8 +1,12 @@ //! Best-effort system and project facts. Every probe degrades to None instead of -//! failing, so a missing tool never aborts the report. +//! failing, so a missing tool never aborts the report. Memory and disk facts come +//! from sysinfo (native on macOS, Linux, and Windows) rather than shelling out to +//! Unix-only tools, so the same probes work on every supported platform. +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use sysinfo::{Disks, System}; #[derive(Debug, Clone)] pub struct SystemReport { @@ -47,7 +51,7 @@ impl SystemReport { os: std::env::consts::OS, arch: std::env::consts::ARCH, cores: cores(), - ram_bytes: sysctl_u64("hw.memsize"), + ram_bytes: total_ram_bytes(), disk_total_bytes, disk_free_bytes, rustc_version, @@ -72,9 +76,24 @@ impl SystemReport { /// True if `tool` resolves to a file on PATH. Cheap enough to call ad hoc. pub fn have(tool: &str) -> bool { - std::env::var_os("PATH") - .map(|paths| std::env::split_paths(&paths).any(|d| d.join(tool).is_file())) - .unwrap_or(false) + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| { + if dir.join(tool).is_file() { + return true; + } + // On Windows the binaries cargo installs carry an extension (e.g. sccache.exe), + // so a bare-name lookup misses them. Probe the usual executable extensions. + #[cfg(windows)] + { + ["exe", "cmd", "bat"] + .iter() + .any(|ext| dir.join(format!("{tool}.{ext}")).is_file()) + } + #[cfg(not(windows))] + false + }) } fn cores() -> usize { @@ -83,12 +102,13 @@ fn cores() -> usize { .unwrap_or(1) } -fn sysctl_u64(key: &str) -> Option { - let out = Command::new("sysctl").arg("-n").arg(key).output().ok()?; - if !out.status.success() { - return None; - } - String::from_utf8_lossy(&out.stdout).trim().parse().ok() +/// Total physical RAM in bytes, via sysinfo (works on macOS, Linux, and Windows). +/// sysinfo reports 0 when it cannot read memory, which we treat as unknown. +fn total_ram_bytes() -> Option { + let mut sys = System::new(); + sys.refresh_memory(); + let total = sys.total_memory(); + (total > 0).then_some(total) } fn rustc_version() -> Option { @@ -99,42 +119,53 @@ fn rustc_version() -> Option { Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) } -/// `df -Pk ` data row: Filesystem 1024-blocks Used Available Capacity Mount. +/// Total and free bytes of the filesystem holding `path`, via sysinfo. Picks the +/// mounted disk whose mount point is the longest prefix of `path` (the same disk +/// `df ` would report). Returns (None, None) if no mount point matches. fn disk_total_free(path: &Path) -> (Option, Option) { - let Ok(out) = Command::new("df").arg("-Pk").arg(path).output() else { - return (None, None); - }; - if !out.status.success() { - return (None, None); + // Make the path absolute without resolving symlinks: canonicalize() on Windows + // returns a `\\?\` verbatim prefix that breaks mount-point prefix matching. + let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); + let disks = Disks::new_with_refreshed_list(); + let best = disks + .list() + .iter() + .filter(|d| abs.starts_with(d.mount_point())) + .max_by_key(|d| d.mount_point().as_os_str().len()); + match best { + Some(d) => (Some(d.total_space()), Some(d.available_space())), + None => (None, None), } - let text = String::from_utf8_lossy(&out.stdout); - let Some(row) = text.lines().nth(1) else { - return (None, None); - }; - let f: Vec<&str> = row.split_whitespace().collect(); - let kib = |i: usize| { - f.get(i) - .and_then(|s| s.parse::().ok()) - .map(|k| k * 1024) - }; - (kib(1), kib(3)) } -/// `du -sk ` first column (KiB). None when the path is absent. +/// Total size in bytes of every regular file under `path`, summed iteratively so a +/// deep tree cannot overflow the stack. Symlinks are skipped to avoid double-counting +/// and cycles. None when the path is absent. Cross-platform replacement for `du -sk`. fn dir_size_bytes(path: &Path) -> Option { if !path.exists() { return None; } - let out = Command::new("du").arg("-sk").arg(path).output().ok()?; - if !out.status.success() { - return None; + let mut total: u64 = 0; + let mut stack = vec![path.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + stack.push(entry.path()); + } else if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + } } - let text = String::from_utf8_lossy(&out.stdout); - text.split_whitespace() - .next()? - .parse::() - .ok() - .map(|k| k * 1024) + Some(total) } fn global_cargo_config() -> PathBuf {