From f7c3165a3d6819406c768e5c5d5b15c4fdd55106 Mon Sep 17 00:00:00 2001
From: "Victor A." <52110451+cs50victor@users.noreply.github.com>
Date: Sat, 30 May 2026 12:46:54 -0700
Subject: [PATCH] refactor: render CLI through cliclack with themed cards and
live job tails
---
Cargo.lock | 204 ++++++++++++++++++++++++++++++++++++
Cargo.toml | 1 +
README.md | 23 +++--
src/catalog.rs | 35 +------
src/main.rs | 167 +++++++++++++++++-------------
src/runner.rs | 258 +++++++++++++++++++++++++++++++++++++---------
src/suggestion.rs | 1 -
src/ui.rs | 80 ++++++++++++++
src/wizard.rs | 217 +++++++++++++++++++-------------------
9 files changed, 709 insertions(+), 277 deletions(-)
create mode 100644 src/ui.rs
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())