diff --git a/Cargo.lock b/Cargo.lock index 653c969..3fb3637 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cfg-if" version = "1.0.4" @@ -120,6 +126,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cliclack" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd" +dependencies = [ + "console", + "indicatif", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -177,6 +197,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "cliclack", "console", "dirs", "similar", @@ -184,6 +205,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -217,12 +262,37 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.186" @@ -272,6 +342,12 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -284,6 +360,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -313,6 +401,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "serde" version = "1.0.228" @@ -351,6 +445,18 @@ dependencies = [ "bstr", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "strsim" version = "0.11.1" @@ -382,6 +488,17 @@ dependencies = [ "windows", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -445,12 +562,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "utf8parse" version = "0.2.2" @@ -463,6 +592,61 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -619,3 +803,23 @@ checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 4f67455..08d1150 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ categories = ["command-line-utilities", "development-tools"] [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } +cliclack = "0.5.4" console = "0.16.3" dirs = "6.0.0" similar = "3.1.1" diff --git a/README.md b/README.md index f752706..44074e4 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,12 @@

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. +`frd` reads your machine and project, then walks you through build-speed and +`target/`-shrinking changes one at a time. Accept or skip each; it changes nothing +without your approval. -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). +Runs on macOS, Linux, and Windows. macOS gets the full catalog; elsewhere `frd` shows +only the suggestions that fit (the `split-debuginfo` tweak, for one, is macOS-only). ## Install @@ -25,8 +24,8 @@ From source: 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). +Or download a binary directly: each [GitHub release](https://github.com/cs50victor/fast-rust-dev/releases) +ships macOS (Apple Silicon + Intel), Linux (x86_64 + arm64), and Windows (x86_64) builds. ## Usage @@ -40,12 +39,14 @@ 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`. +Each suggestion is a card: why it helps, the exact diff, and a color for what it +optimizes (disk, speed, or both). Choose Accept, Skip, or Quit. Installs and sweeps +stream their output, then fold to one line when they finish. ## What it can change -Every edit is format-preserving (comments and ordering survive) and backed up with a -timestamped `.frd-bak-*` copy before writing. +Every edit preserves your comments and ordering, and copies the file to a timestamped +`.frd-bak-*` backup first. - **`~/.cargo/config.toml`** — a shared `target-dir` so repos and git worktrees stop duplicating `target/`; on nightly, `no-embed-metadata`; route `rustc` through diff --git a/src/catalog.rs b/src/catalog.rs index dbea5ff..3e43bb0 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -19,7 +19,6 @@ pub fn build(r: &SystemReport) -> Vec { // --- global: ~/.cargo/config.toml --- out.push(toml_sug( - "shared-target-dir", "Shared target dir for every project and worktree", Tag::Disk, format!( @@ -35,7 +34,6 @@ pub fn build(r: &SystemReport) -> Vec { if r.nightly { out.push(toml_sug( - "no-embed-metadata", "Stop duplicating crate metadata into rlibs", Tag::Disk, "Nightly Cargo flag: roughly 5-35% smaller target/ by not embedding metadata \ @@ -54,7 +52,6 @@ pub fn build(r: &SystemReport) -> Vec { // re-run frd and the wrapper appears. if have("sccache") { out.push(toml_sug( - "sccache-wrapper", "Route rustc through sccache", Tag::Both, "Caveat: this disables incremental compilation, so it helps cold and cross-project \ @@ -68,7 +65,6 @@ pub fn build(r: &SystemReport) -> Vec { )); } else { out.push(install_sug( - "install-sccache", "Install sccache (cross-project compile cache)", Tag::Both, "Caches compiled crates across every project and survives cargo clean. Best for \ @@ -82,7 +78,6 @@ pub fn build(r: &SystemReport) -> Vec { // --- project: ./Cargo.toml profiles --- if p.has_cargo_toml { out.push(toml_sug( - "dev-debug-line-tables", "dev profile: debug = line-tables-only", Tag::Disk, "Debug info is the biggest single contributor to target/ size. line-tables-only \ @@ -97,7 +92,6 @@ pub fn build(r: &SystemReport) -> Vec { if r.is_macos() { out.push(toml_sug( - "dev-split-debuginfo", "dev profile: split-debuginfo = unpacked (macOS)", Tag::Speed, "On macOS this speeds relinking in the edit loop by keeping debug info out \ @@ -112,7 +106,6 @@ pub fn build(r: &SystemReport) -> Vec { } out.push(toml_sug( - "dev-deps-opt", "dev profile: opt-level = 2 for dependencies", Tag::Speed, "Compile dependencies optimized while your own crate stays at 0: snappier dev \ @@ -126,7 +119,6 @@ pub fn build(r: &SystemReport) -> Vec { )); out.push(toml_sug( - "fast-build-profile", "Add a disk-light fast-build profile", Tag::Both, "A profile with no debug info for when you do not need a debugger: smallest \ @@ -147,7 +139,6 @@ pub fn build(r: &SystemReport) -> Vec { )); out.push(toml_sug( - "release-strip", "release profile: strip = true", Tag::Disk, "Strip symbols and debug info from shipped binaries, shrinking target/release.".into(), @@ -164,7 +155,6 @@ pub fn build(r: &SystemReport) -> Vec { // a repo's own flags, which is the trap Oxide documents. if r.nightly && p.has_cargo_toml { out.push(toml_sug( - "nightly-frontend", "Nightly: parallel frontend + share-generics", Tag::Speed, "-Zthreads=0 parallelizes the compiler frontend; -Zshare-generics=y cuts \ @@ -181,7 +171,6 @@ pub fn build(r: &SystemReport) -> Vec { // --- tools --- if !have("cargo-sweep") { out.push(install_sug( - "install-cargo-sweep", "Install cargo-sweep (disk reclaim)", Tag::Disk, "Garbage-collects stale build artifacts that Cargo never removes, by age, instead \ @@ -192,7 +181,6 @@ pub fn build(r: &SystemReport) -> Vec { )); } out.push(run_sug( - "run-cargo-sweep", "Sweep stale artifacts in this project", Tag::Disk, "Removes artifacts untouched for more than 15 days while keeping warm ones. Re-run \ @@ -205,7 +193,6 @@ pub fn build(r: &SystemReport) -> Vec { if !have("cargo-machete") { out.push(install_sug( - "install-cargo-machete", "Install cargo-machete (find unused deps)", Tag::Both, "Finds dependencies you no longer use. Fewer deps means less to compile and store." @@ -227,16 +214,8 @@ fn shared_target_dir() -> String { .to_string() } -fn toml_sug( - id: &'static str, - title: &str, - tag: Tag, - why: String, - scope: Scope, - ops: Vec, -) -> Suggestion { +fn toml_sug(title: &str, tag: Tag, why: String, scope: Scope, ops: Vec) -> Suggestion { Suggestion { - id, title: title.into(), tag, why, @@ -244,16 +223,8 @@ fn toml_sug( } } -fn install_sug( - id: &'static str, - title: &str, - tag: Tag, - why: String, - crate_name: &str, - bin: &str, -) -> Suggestion { +fn install_sug(title: &str, tag: Tag, why: String, crate_name: &str, bin: &str) -> Suggestion { Suggestion { - id, title: title.into(), tag, why, @@ -265,7 +236,6 @@ fn install_sug( } fn run_sug( - id: &'static str, title: &str, tag: Tag, why: String, @@ -274,7 +244,6 @@ fn run_sug( cwd: Option, ) -> Suggestion { Suggestion { - id, title: title.into(), tag, why, diff --git a/src/main.rs b/src/main.rs index 6ef890f..ae2cd90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,12 @@ mod runner; mod suggestion; mod system; mod toml_ops; +mod ui; mod wizard; use anyhow::Result; use clap::Parser; +use cliclack::{intro, log, outro, outro_cancel}; use console::style; use suggestion::{Action, Status, Suggestion}; use system::{SystemReport, human_bytes}; @@ -19,22 +21,21 @@ fn main() -> Result<()> { None => std::env::current_dir()?, }; let report = SystemReport::gather(&root); + + ui::reset(); + let _ = intro(style(" frd · fast rust dev ").black().on_cyan().bold()); print_report(&report); if matches!(args.command, Some(cli::Commands::Report)) { + let _ = outro(style("read-only report").dim()); return Ok(()); } if !report.project.has_cargo_toml { - println!( - "{}", - style(format!( - "No Cargo.toml in {}; only global suggestions apply.", - root.display() - )) - .yellow() - ); - println!(); + let _ = log::warning(format!( + "No Cargo.toml in {} — only global suggestions apply.", + ui::tildify(&root) + )); } let all = catalog::build(&report); @@ -43,56 +44,52 @@ fn main() -> Result<()> { .partition(|s| status_of(&report, s) == Status::Pending); if !already.is_empty() { - println!( - "{}", - style(format!("Already applied ({}):", already.len())).dim() - ); - for s in &already { - println!("{}", style(format!(" + {}", s.title)).dim()); - } - println!(); + let titles = already + .iter() + .map(|s| s.title.as_str()) + .collect::>() + .join(", "); + let _ = log::info(format!( + "Already tuned ({}): {}", + already.len(), + style(titles).dim() + )); } if pending.is_empty() { - println!( - "{}", - style("Nothing to do: your setup already covers the catalog.").green() - ); + let _ = outro(style("Nothing to do — your setup already covers the catalog.").green()); return Ok(()); } if args.dry_run { - println!( - "{}", - style("(dry-run: nothing will be written or run)").yellow() - ); - println!(); + let _ = log::warning("dry-run — nothing will be written or run"); } let mut runner = runner::Runner::new(); let summary = wizard::run(&report, pending, &mut runner, args.dry_run, args.yes)?; - println!( - "{}", - style(format!( - "Done: {} applied, {} skipped, {} failed{}.", - summary.applied, - summary.skipped, - summary.failed, - if summary.quit { " (quit early)" } else { "" } - )) - .bold() - ); if report.project.target_bytes.is_some() { - println!( - "{}", - style( - "Note: existing target/ dirs are not moved automatically. Accept the sweep, \ - or run cargo clean per project, to reclaim space now." - ) - .dim() + let _ = log::remark( + "Existing target/ dirs are not moved automatically. Accept the sweep, or run \ + cargo clean per project, to reclaim space now.", ); } + + let counts = format!( + "{} applied {} skipped {} failed", + style(summary.applied).green().bold(), + style(summary.skipped).dim(), + if summary.failed > 0 { + style(summary.failed).red().bold().to_string() + } else { + style(summary.failed).dim().to_string() + } + ); + if summary.quit { + let _ = outro_cancel(format!("Stopped early {counts}")); + } else { + let _ = outro(counts); + } Ok(()) } @@ -116,29 +113,45 @@ fn status_of(r: &SystemReport, s: &Suggestion) -> Status { } } -fn print_report(r: &SystemReport) { - println!("{}", style("frd fast rust dev").bold().cyan()); +/// One report fact as a cliclack info line with a fixed-width bold label, so the +/// values line up into a scannable left edge. +fn fact(label: &str, value: String) { + let _ = log::info(format!("{}{}", style(format!("{label:<8}")).bold(), value)); +} +fn print_report(r: &SystemReport) { let ram = r.ram_bytes.map(human_bytes).unwrap_or_else(|| "?".into()); - let disk = match (r.disk_free_bytes, r.disk_used_pct()) { - (Some(free), Some(pct)) => format!("disk {pct}% used, {} free", human_bytes(free)), - _ => "disk ?".into(), - }; - println!( - " {} {}, {} cores, {} RAM, {}", - r.os, r.arch, r.cores, ram, disk + fact( + "System", + format!("{} {} · {} cores · {} RAM", r.os, r.arch, r.cores, ram), ); + match (r.disk_free_bytes, r.disk_used_pct()) { + (Some(free), Some(pct)) => fact( + "Disk", + format!( + "{} used · {} free", + ui::disk_style(pct).apply_to(format!("{pct}%")), + human_bytes(free) + ), + ), + _ => fact("Disk", style("unknown").dim().to_string()), + } + match &r.rustc_version { + // The "Rust" label already says what this is, so drop rustc's own prefix. Some(v) => { - let note = if r.nightly { - style(" (nightly: -Z flags available)").green().to_string() + let v = v.strip_prefix("rustc ").unwrap_or(v); + if r.nightly { + fact( + "Rust", + format!("{v} {}", style("· -Z flags ready").green()), + ); } else { - String::new() - }; - println!(" {v}{note}"); + fact("Rust", v.to_string()); + } } - None => println!(" rustc: not found"), + None => fact("Rust", style("not found").red().to_string()), } let tgt = r @@ -146,10 +159,9 @@ fn print_report(r: &SystemReport) { .target_bytes .map(human_bytes) .unwrap_or_else(|| "none".into()); - println!( - " project: {} (target/: {})", - r.project.root.display(), - tgt + fact( + "Project", + format!("{} · target/ {tgt}", ui::tildify(&r.project.root)), ); let tools = [ @@ -159,12 +171,25 @@ fn print_report(r: &SystemReport) { "cargo-nextest", "cargo-binstall", ]; - let present: Vec<&str> = tools.iter().copied().filter(|t| system::have(t)).collect(); - let missing: Vec<&str> = tools.iter().copied().filter(|t| !system::have(t)).collect(); - println!( - " tools: have [{}] missing [{}]", - present.join(", "), - missing.join(", ") - ); - println!(); + let have: Vec = tools + .iter() + .filter(|t| system::have(t)) + .map(|t| format!("{} {t}", ui::check())) + .collect(); + let miss: Vec = tools + .iter() + .filter(|t| !system::have(t)) + .map(|t| format!("{} {}", ui::cross(), style(t).dim())) + .collect(); + let tools_line = match (have.is_empty(), miss.is_empty()) { + (true, _) => miss.join(" "), + (_, true) => have.join(" "), + _ => format!( + "{} {} {}", + have.join(" "), + style("·").dim(), + miss.join(" ") + ), + }; + fact("Tools", tools_line); } diff --git a/src/runner.rs b/src/runner.rs index 963e8da..b2cc3f0 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,14 +1,25 @@ -//! Runs installs and commands with live output. Install path follows the user's rule: -//! use cargo-binstall if present; if absent, ask once whether to install it, otherwise -//! fall back to `cargo install`. +//! Runs installs and commands, streaming their output into a live tail window +//! that folds into a single summary line once the job finishes. Install path +//! follows the user's rule: use cargo-binstall if present; if absent, ask once +//! whether to install it, otherwise fall back to `cargo install`. -use crate::suggestion::{InstallSpec, RunSpec}; -use crate::system; +use crate::suggestion::{InstallSpec, RunSpec, Tag}; +use crate::{system, ui}; use anyhow::{Context, Result, bail}; -use console::{Term, style}; -use std::io::Write; +use cliclack::log; +use console::{Style, Term, strip_ansi_codes, style, truncate_str}; +use std::collections::VecDeque; +use std::io::{BufRead, BufReader}; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::Instant; + +/// How many recent output lines the live tail window keeps on screen. +const TAIL_LINES: usize = 6; +/// How many captured lines to replay when a job fails, so the error is visible. +const FAIL_TAIL: usize = 40; #[derive(PartialEq, Eq)] enum Binstall { @@ -45,12 +56,9 @@ impl Runner { } } - pub fn install(&mut self, spec: &InstallSpec, dry_run: bool) -> Result<()> { + pub fn install(&mut self, spec: &InstallSpec, tag: Tag, dry_run: bool) -> Result<()> { if system::have(&spec.bin_name) { - println!( - "{}", - style(format!(" {} already installed", spec.bin_name)).green() - ); + log::success(format!("{} already installed", spec.bin_name))?; return Ok(()); } let use_binstall = self.ensure_binstall(dry_run)?; @@ -59,11 +67,27 @@ impl Runner { } else { vec!["install".into(), spec.crate_name.clone()] }; - run_streaming("cargo", &args, None, dry_run) + let job = Job { + running: format!("Installing {}", spec.crate_name), + done: format!("Installed {}", spec.crate_name), + tag, + }; + run_streaming("cargo", &args, None, &job, dry_run) } - pub fn run(&self, spec: &RunSpec, dry_run: bool) -> Result<()> { - run_streaming(&spec.program, &spec.args, spec.cwd.as_deref(), dry_run) + pub fn run(&self, spec: &RunSpec, tag: Tag, dry_run: bool) -> Result<()> { + let job = Job { + running: format!("Running {}", spec.display()), + done: format!("Ran {}", spec.display()), + tag, + }; + run_streaming( + &spec.program, + &spec.args, + spec.cwd.as_deref(), + &job, + dry_run, + ) } fn ensure_binstall(&mut self, dry_run: bool) -> Result { @@ -74,15 +98,23 @@ impl Runner { if dry_run { return Ok(false); } - let install_it = confirm( + let install_it = cliclack::confirm( "cargo-binstall not found. Install it for fast prebuilt binaries? \ (otherwise fall back to cargo install)", - )?; + ) + .initial_value(true) + .interact()?; if install_it { + let job = Job { + running: "Installing cargo-binstall".into(), + done: "Installed cargo-binstall".into(), + tag: Tag::Both, + }; run_streaming( "cargo", &["install".into(), "cargo-binstall".into()], None, + &job, dry_run, )?; self.binstall = Binstall::Available; @@ -96,49 +128,173 @@ impl Runner { } } -fn confirm(msg: &str) -> Result { - print!( - " {} {} {} ", - style("?").yellow(), - msg, - style("[y/N]").dim() - ); - std::io::stdout().flush().ok(); - let term = Term::stdout(); - loop { - match term.read_char() { - Ok('y' | 'Y') => { - println!("y"); - return Ok(true); - } - Ok('n' | 'N' | '\n' | '\r') => { - println!("n"); - return Ok(false); - } - Ok(_) => continue, - Err(e) => bail!("input error (need a TTY): {e}"), - } - } +/// Display labels and category color for one streamed job. +struct Job { + running: String, + done: String, + tag: Tag, } -fn run_streaming(prog: &str, args: &[String], cwd: Option<&Path>, dry_run: bool) -> Result<()> { +fn run_streaming( + prog: &str, + args: &[String], + cwd: Option<&Path>, + job: &Job, + dry_run: bool, +) -> Result<()> { let shown = format!("{prog} {}", args.join(" ")); if dry_run { - println!( - "{}", - style(format!(" [dry-run] would run: {shown}")).yellow() - ); + log::info(format!("would run {}", style(shown).dim()))?; return Ok(()); } - println!("{}", style(format!(" $ {shown}")).dim()); + let mut cmd = Command::new(prog); - cmd.args(args); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); if let Some(d) = cwd { cmd.current_dir(d); } - let status = cmd.status().with_context(|| format!("spawn {prog}"))?; - if !status.success() { + let mut child = cmd.spawn().with_context(|| format!("spawn {prog}"))?; + + // Read stdout and stderr on separate threads so a full pipe never deadlocks, + // funneling every line into one ordered channel for the tail window. + let (tx, rx) = mpsc::channel::(); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let tx2 = tx.clone(); + let h_out = thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + let h_err = thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if tx2.send(line).is_err() { + break; + } + } + }); + + let start = Instant::now(); + let mut window = TailWindow::new(ui::accent(job.tag)); + window.open(&job.running); + let mut full: Vec = Vec::new(); + for line in rx { + full.push(line.clone()); + window.push(line); + } + let _ = h_out.join(); + let _ = h_err.join(); + let status = child.wait().with_context(|| format!("wait {prog}"))?; + window.close(); + let elapsed = start.elapsed().as_secs_f64(); + + if status.success() { + log::success(format!( + "{} {}", + job.done, + style(format!("({elapsed:.1}s)")).dim() + ))?; + Ok(()) + } else { + // Replay the captured output so the failure is debuggable; the wizard + // prints the single error summary line from the returned error. + replay_on_failure(&full); bail!("{shown} exited with {status}"); } - Ok(()) +} + +/// Print the tail of a failed job's output so the error stays debuggable, even +/// though the live window has been folded away. +fn replay_on_failure(full: &[String]) { + let term = Term::stderr(); + let bar = style("│").dim(); + let start = full.len().saturating_sub(FAIL_TAIL); + for line in &full[start..] { + let _ = term.write_line(&format!("{bar} {}", style(strip_ansi_codes(line)).dim())); + } +} + +/// A self-redrawing window showing a header plus the last few output lines. On a +/// TTY it folds in place when closed; off a TTY it just passes lines through. +struct TailWindow { + term: Term, + interactive: bool, + accent: Style, + header: String, + lines: VecDeque, + drawn: usize, +} + +impl TailWindow { + fn new(accent: Style) -> Self { + let term = Term::stderr(); + let interactive = term.is_term(); + TailWindow { + term, + interactive, + accent, + header: String::new(), + lines: VecDeque::with_capacity(TAIL_LINES), + drawn: 0, + } + } + + fn open(&mut self, header: &str) { + self.header = header.to_string(); + if self.interactive { + self.redraw(); + } else { + let _ = self.term.write_line(&format!( + "{} {}", + self.accent.apply_to("◇"), + style(header).bold() + )); + } + } + + fn push(&mut self, line: String) { + if !self.interactive { + let _ = self + .term + .write_line(&format!(" {}", strip_ansi_codes(&line))); + return; + } + if self.lines.len() == TAIL_LINES { + self.lines.pop_front(); + } + self.lines.push_back(line); + self.redraw(); + } + + fn redraw(&mut self) { + if self.drawn > 0 { + let _ = self.term.clear_last_lines(self.drawn); + } + let inner = (self.term.size().1 as usize).saturating_sub(3).max(8); + let _ = self.term.write_line(&format!( + "{} {}", + self.accent.apply_to("◇"), + style(&self.header).bold() + )); + let bar = self.accent.apply_to("│"); + for line in &self.lines { + let clean = strip_ansi_codes(line); + let shown = truncate_str(clean.trim_end(), inner, "…"); + let _ = self + .term + .write_line(&format!("{bar} {}", style(shown).dim())); + } + self.drawn = 1 + self.lines.len(); + } + + /// Fold the live region away, leaving the caller to print the summary line. + fn close(&mut self) { + if self.interactive && self.drawn > 0 { + let _ = self.term.clear_last_lines(self.drawn); + } + self.drawn = 0; + self.lines.clear(); + } } diff --git a/src/suggestion.rs b/src/suggestion.rs index a584e2c..097ac12 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -103,7 +103,6 @@ pub enum Status { #[derive(Clone)] pub struct Suggestion { - pub id: &'static str, pub title: String, pub tag: Tag, pub why: String, diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..1377405 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,80 @@ +//! Presentation helpers layered on cliclack/console: per-category theming so a +//! whole suggestion card is colored by what it optimizes, plus status glyphs and +//! path tidying shared by the report and the wizard. + +use crate::suggestion::Tag; +use cliclack::{Theme, ThemeState}; +use console::{Style, StyledObject, style}; +use std::path::Path; + +/// Accent color per category: disk is the scarce-resource warning hue, speed the +/// cool "fast" hue, both the green win-win. Reused for the gutter, glyphs, and the +/// live job tail so a card reads as one unit. +pub fn accent(tag: Tag) -> Style { + match tag { + Tag::Disk => Style::new().yellow(), + Tag::Speed => Style::new().cyan(), + Tag::Both => Style::new().green(), + } +} + +/// A cliclack theme that paints the vertical gutter and step glyph in one +/// category color. Errors and cancels stay red so failures remain obvious. +struct CategoryTheme { + color: Style, +} + +impl Theme for CategoryTheme { + fn bar_color(&self, state: &ThemeState) -> Style { + match state { + ThemeState::Cancel | ThemeState::Error(_) => Style::new().red(), + _ => self.color.clone(), + } + } + + fn state_symbol_color(&self, state: &ThemeState) -> Style { + self.bar_color(state) + } +} + +/// Tint the global cliclack theme for the next card's category. +pub fn set_category(tag: Tag) { + cliclack::set_theme(CategoryTheme { color: accent(tag) }); +} + +/// Restore the default cliclack look for framing, the report, and the summary. +pub fn reset() { + cliclack::reset_theme(); +} + +/// Color a disk-usage percentage by how alarming it is. +pub fn disk_style(used_pct: u64) -> Style { + match used_pct { + 90..=u64::MAX => Style::new().red().bold(), + 75..=89 => Style::new().yellow(), + _ => Style::new().dim(), + } +} + +pub fn check() -> StyledObject<&'static str> { + style("✓").green() +} + +pub fn cross() -> StyledObject<&'static str> { + style("✗").red().dim() +} + +/// Replace the home prefix with `~` for display only, so long paths stay short. +pub fn tildify(path: &Path) -> String { + let s = path.display().to_string(); + let Some(home) = dirs::home_dir() else { + return s; + }; + let home = home.display().to_string(); + if !home.is_empty() + && let Some(rest) = s.strip_prefix(&home) + { + return format!("~{rest}"); + } + s +} diff --git a/src/wizard.rs b/src/wizard.rs index 38378ae..44d02b1 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -1,12 +1,14 @@ -//! The accept/skip wizard: one card per suggestion, single-key decision, apply on accept. +//! The accept/skip wizard: one cliclack card per suggestion (themed by its +//! optimization category), a single Select decision, and apply on accept. use crate::runner::Runner; -use crate::suggestion::{Action, Suggestion, Tag}; +use crate::suggestion::{Action, Suggestion}; use crate::system::SystemReport; use crate::toml_ops::{self, TomlPlan}; +use crate::ui; use anyhow::{Result, anyhow}; -use console::{StyledObject, Term, style}; -use std::io::Write; +use cliclack::{log, note, select}; +use console::style; use std::path::Path; pub struct Summary { @@ -16,6 +18,13 @@ pub struct Summary { pub quit: bool, } +#[derive(Clone, PartialEq, Eq)] +enum Decision { + Accept, + Skip, + Quit, +} + pub fn run( report: &SystemReport, suggestions: Vec, @@ -23,7 +32,6 @@ pub fn run( dry_run: bool, yes: bool, ) -> Result { - let term = Term::stdout(); let total = suggestions.len(); let mut applied = 0usize; let mut skipped = 0usize; @@ -35,22 +43,25 @@ pub fn run( Action::Toml(c) => Some(toml_ops::plan(report, c)?), _ => None, }; - print_card(i + 1, total, sug, plan.as_ref(), runner); - let decision = if yes { 'a' } else { read_decision(&term)? }; + ui::set_category(sug.tag); + show_card(i + 1, total, sug, plan.as_ref(), runner)?; + + let decision = if yes { Decision::Accept } else { ask()? }; match decision { - 'a' => { - println!(); - match execute(sug, plan.as_ref(), runner, dry_run) { - Ok(()) => applied += 1, - Err(e) => { - println!("{}", style(format!(" failed: {e:#}")).red()); - failed += 1; - } + Decision::Accept => match execute(sug, plan.as_ref(), runner, dry_run) { + Ok(()) => applied += 1, + Err(e) => { + let _ = log::error(format!("{e:#}")); + failed += 1; } + }, + Decision::Skip => { + let _ = log::info("skipped"); + skipped += 1; } - 'q' => { - println!(); + Decision::Quit => { + ui::reset(); return Ok(Summary { applied, skipped, @@ -58,14 +69,10 @@ pub fn run( quit: true, }); } - _ => { - println!("{}", style(" skipped").dim()); - skipped += 1; - } } - println!(); } + ui::reset(); Ok(Summary { applied, skipped, @@ -74,54 +81,86 @@ pub fn run( }) } -fn read_decision(term: &Term) -> Result { - loop { - match term.read_char() { - Ok('a' | 'A') => return Ok('a'), - Ok('s' | 'S') => return Ok('s'), - Ok('q' | 'Q') => return Ok('q'), - Ok(_) => continue, - Err(e) => { - return Err(anyhow!( - "input error (need a TTY; use --yes for non-interactive): {e}" - )); - } - } - } +/// Ask the accept/skip/quit decision. Errors with a clear hint when there is no +/// TTY, pointing at `--yes` for non-interactive runs. +fn ask() -> Result { + select("Apply this change?") + .item(Decision::Accept, "Accept", "write it") + .item(Decision::Skip, "Skip", "leave as is") + .item(Decision::Quit, "Quit", "stop here") + .initial_value(Decision::Accept) + .interact() + .map_err(|e| { + anyhow!("interactive prompt needs a TTY; use --yes for non-interactive runs: {e}") + }) } -fn print_card(n: usize, total: usize, sug: &Suggestion, plan: Option<&TomlPlan>, runner: &Runner) { - println!( - "{} {} {} {}", - style(format!("[{n}/{total}]")).dim(), - style(&sug.title).bold(), - tag_style(sug.tag), - style(sug.id).dim(), - ); - for line in wrap(&sug.why, 76) { - println!(" {line}"); - } +/// Render one suggestion as a titled note box: why on top, then the file and a +/// clean additive diff (or the install/run command). +fn show_card( + n: usize, + total: usize, + sug: &Suggestion, + plan: Option<&TomlPlan>, + runner: &Runner, +) -> Result<()> { + // The title is plain text: cliclack wraps it, so styling here would corrupt + // the box border. The category color comes from the themed gutter instead. + let title = format!("{} [{}] {n}/{total}", sug.title, sug.tag.label()); + + let mut body = sug.why.clone(); match &sug.action { Action::Toml(c) => { - println!(" {} {}", style("target:").dim(), c.scope.label()); + body.push_str("\n\n"); + body.push_str(&style(c.scope.label()).underlined().to_string()); if let Some(p) = plan { - print_diff(&toml_ops::unified(&p.before, &p.after, &filename(&p.path))); + let diff = render_diff(p); + if !diff.is_empty() { + body.push('\n'); + body.push_str(&diff); + } } } Action::Install(s) => { - println!( - " {} {} (via {})", - style("install:").dim(), + body.push_str(&format!( + "\n\n{} {} {}", + style("install").bold(), s.crate_name, - runner.install_method_label() - ); + style(format!("· via {}", runner.install_method_label())).dim() + )); } Action::Run(s) => { - println!(" {} {}", style("run:").dim(), s.display()); + body.push_str(&format!("\n\n{} {}", style("run").bold(), s.display())); + } + } + + note(title, body)?; + Ok(()) +} + +/// The added/removed lines of the change, color-coded. Git-diff scaffolding +/// (---, +++, @@) and unchanged context are dropped: the file is named just +/// above and the TOML keys are fully qualified, so context only adds noise. +fn render_diff(plan: &TomlPlan) -> String { + let diff = toml_ops::unified(&plan.before, &plan.after, &filename(&plan.path)); + let mut out: Vec = Vec::new(); + for line in diff.lines() { + if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") { + continue; + } + if let Some(rest) = line.strip_prefix('+') { + if rest.trim().is_empty() { + continue; + } + out.push(style(format!("+ {rest}")).green().to_string()); + } else if let Some(rest) = line.strip_prefix('-') { + if rest.trim().is_empty() { + continue; + } + out.push(style(format!("- {rest}")).red().to_string()); } } - print!(" {} ", style("[a]ccept [s]kip [q]uit").dim()); - let _ = std::io::stdout().flush(); + out.join("\n") } fn execute( @@ -135,66 +174,24 @@ fn execute( let p = plan.ok_or_else(|| anyhow!("missing toml plan"))?; let backup = toml_ops::apply(p, dry_run)?; if dry_run { - println!( - "{}", - style(format!(" [dry-run] would write {}", p.path.display())).yellow() - ); + log::info(format!("would write {}", ui::tildify(&p.path)))?; } else { - let msg = match backup { - Some(b) => format!(" wrote {} (backup {})", p.path.display(), b.display()), - None => format!(" wrote {}", p.path.display()), - }; - println!("{}", style(msg).green()); + match backup { + Some(b) => log::success(format!( + "wrote {} {}", + ui::tildify(&p.path), + style(format!("(backup {})", ui::tildify(&b))).dim() + ))?, + None => log::success(format!("wrote {}", ui::tildify(&p.path)))?, + } } } - Action::Install(s) => runner.install(s, dry_run)?, - Action::Run(s) => runner.run(s, dry_run)?, + Action::Install(s) => runner.install(s, sug.tag, dry_run)?, + Action::Run(s) => runner.run(s, sug.tag, dry_run)?, } Ok(()) } -fn tag_style(tag: Tag) -> StyledObject<&'static str> { - let l = tag.label(); - match tag { - Tag::Disk => style(l).yellow(), - Tag::Speed => style(l).cyan(), - Tag::Both => style(l).green(), - } -} - -fn print_diff(diff: &str) { - for line in diff.lines() { - let styled = if line.starts_with('+') { - style(line).green() - } else if line.starts_with('-') { - style(line).red() - } else if line.starts_with('@') { - style(line).cyan() - } else { - style(line).dim() - }; - println!(" {styled}"); - } -} - -fn wrap(text: &str, width: usize) -> Vec { - let mut lines = Vec::new(); - let mut cur = String::new(); - for word in text.split_whitespace() { - if !cur.is_empty() && cur.len() + 1 + word.len() > width { - lines.push(std::mem::take(&mut cur)); - } - if !cur.is_empty() { - cur.push(' '); - } - cur.push_str(word); - } - if !cur.is_empty() { - lines.push(cur); - } - lines -} - fn filename(path: &Path) -> String { path.file_name() .map(|s| s.to_string_lossy().into_owned())