diff --git a/.prep/prep.toml b/.prep/prep.toml index 8a93c42..d011172 100644 --- a/.prep/prep.toml +++ b/.prep/prep.toml @@ -1,3 +1,8 @@ [project] name = "Prep" license = "Apache-2.0 OR MIT" + +[tools] +rustup = "=1" +rust = "=1.93" +ripgrep = "=14.1.1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dca4a6..4db73db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +* `tools` command for tool management. ([#27] by [@xStrom]) +* `--strict` option to `clippy`, `copyright`, and `format` commands to use locked tool versions. ([#27] by [@xStrom]) + ## [0.2.0] - 2026-02-07 ### Added @@ -35,6 +38,7 @@ [#22]: https://github.com/Nevermore/prep/pull/22 [#23]: https://github.com/Nevermore/prep/pull/23 [#24]: https://github.com/Nevermore/prep/pull/24 +[#27]: https://github.com/Nevermore/prep/pull/27 [Unreleased]: https://github.com/Nevermore/prep/compare/v0.2.0...HEAD [0.2.0]: https://github.com/Nevermore/prep/compare/v0.1.0...v0.2.0 diff --git a/Cargo.lock b/Cargo.lock index ec5f2d3..cadd0d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,6 +227,7 @@ dependencies = [ "cargo_metadata", "clap", "regex", + "semver", "serde", "time", "toml", diff --git a/Cargo.toml b/Cargo.toml index 4266c84..fafe2eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ anyhow = "1.0.101" cargo_metadata = "0.23.1" clap = "4.5.57" regex = "1.12.3" +semver = "1.0.27" serde = "1.0.228" time = "0.3.47" toml = "0.9.11" diff --git a/prep/Cargo.toml b/prep/Cargo.toml index 260f853..c1e68d0 100644 --- a/prep/Cargo.toml +++ b/prep/Cargo.toml @@ -23,6 +23,7 @@ anyhow.workspace = true cargo_metadata.workspace = true clap = { workspace = true, features = ["derive"] } regex.workspace = true +semver.workspace = true serde = { workspace = true, features = ["derive"] } time.workspace = true toml.workspace = true diff --git a/prep/src/cmd/ci.rs b/prep/src/cmd/ci.rs index f7d050a..8066ec6 100644 --- a/prep/src/cmd/ci.rs +++ b/prep/src/cmd/ci.rs @@ -9,9 +9,9 @@ use crate::session::Session; /// Can be ran in `extended` mode for more thorough checks. /// /// Set `fail_fast` to `false` to run the checks to the end regardless of failure. -pub fn run(session: &Session, extended: bool, fail_fast: bool) -> anyhow::Result<()> { +pub fn run(session: &mut Session, extended: bool, fail_fast: bool) -> anyhow::Result<()> { let mut errs: Vec = Vec::new(); - let mut step = |f: &dyn Fn() -> anyhow::Result<()>| -> anyhow::Result<()> { + let mut step = |f: &mut dyn FnMut() -> anyhow::Result<()>| -> anyhow::Result<()> { if let Err(e) = f() { if fail_fast { return Err(e); @@ -22,16 +22,16 @@ pub fn run(session: &Session, extended: bool, fail_fast: bool) -> anyhow::Result }; //step(&|| copyright::run(session))?; - step(&|| format::run(session, true))?; + step(&mut || format::run(session, true, true))?; if extended { // We need to avoid --all-targets because it will unify dev and regular dep features. - step(&|| clippy::run(session, CargoTargets::Main, true))?; - step(&|| clippy::run(session, CargoTargets::Auxiliary, true))?; + step(&mut || clippy::run(session, true, CargoTargets::Main))?; + step(&mut || clippy::run(session, true, CargoTargets::Auxiliary))?; } else { // Slightly faster due to shared build cache, // but will miss unified feature bugs. - step(&|| clippy::run(session, CargoTargets::All, true))?; + step(&mut || clippy::run(session, true, CargoTargets::All))?; } if errs.is_empty() { diff --git a/prep/src/cmd/clippy.rs b/prep/src/cmd/clippy.rs index 786b97c..5e489e1 100644 --- a/prep/src/cmd/clippy.rs +++ b/prep/src/cmd/clippy.rs @@ -5,14 +5,25 @@ use anyhow::{Context, ensure}; use crate::cmd::CargoTargets; use crate::session::Session; -use crate::tools::cargo; +use crate::tools::cargo::{Cargo, CargoDeps}; use crate::ui; /// Runs Clippy analysis on the given `targets`. /// -/// In `strict` mode warnings are treated as errors. -pub fn run(session: &Session, targets: CargoTargets, strict: bool) -> anyhow::Result<()> { - let mut cmd = cargo::new("")?; +/// In `strict` mode warnings are treated as errors and Cargo version is locked. +pub fn run(session: &mut Session, strict: bool, targets: CargoTargets) -> anyhow::Result<()> { + let mut cmd = if strict { + let tools_cfg = session.config().tools(); + let rustup_ver_req = tools_cfg.rustup().clone(); + let ver_req = tools_cfg.rust().clone(); + let toolset = session.toolset(); + let deps = CargoDeps::new(rustup_ver_req); + toolset.get::(&deps, &ver_req)? + } else { + let toolset = session.toolset(); + let deps = CargoDeps::new(None); + toolset.get::(&deps, None)? + }; let mut cmd = cmd .current_dir(session.root_dir()) .arg("clippy") diff --git a/prep/src/cmd/copyright.rs b/prep/src/cmd/copyright.rs index 6681e2f..e9bf13a 100644 --- a/prep/src/cmd/copyright.rs +++ b/prep/src/cmd/copyright.rs @@ -14,11 +14,14 @@ use crate::ui::style::{ERROR, HEADER, LITERAL, NOTE}; // TODO: Allow excluding files from the check /// Verify copyright headers. -pub fn run(session: &Session) -> anyhow::Result<()> { +/// +/// In `strict` mode ripgrep version is locked. +pub fn run(session: &mut Session, _strict: bool) -> anyhow::Result<()> { let config = session.config(); let project = config.project(); let header_regex = header_regex(project.name(), project.license()); + // TODO: Strict mode for ripgrep. let mut cmd = Command::new("rg"); let cmd = cmd .current_dir(session.root_dir()) diff --git a/prep/src/cmd/format.rs b/prep/src/cmd/format.rs index 7018e93..2fae10d 100644 --- a/prep/src/cmd/format.rs +++ b/prep/src/cmd/format.rs @@ -4,12 +4,25 @@ use anyhow::{Context, ensure}; use crate::session::Session; -use crate::tools::cargo; +use crate::tools::cargo::{Cargo, CargoDeps}; use crate::ui; -/// Format the workspace -pub fn run(session: &Session, check: bool) -> anyhow::Result<()> { - let mut cmd = cargo::new("")?; +/// Format the workspace. +/// +/// In `strict` mode Cargo version is locked. +pub fn run(session: &mut Session, strict: bool, check: bool) -> anyhow::Result<()> { + let mut cmd = if strict { + let tools_cfg = session.config().tools(); + let rustup_ver_req = tools_cfg.rustup().clone(); + let ver_req = tools_cfg.rust().clone(); + let toolset = session.toolset(); + let deps = CargoDeps::new(rustup_ver_req); + toolset.get::(&deps, &ver_req)? + } else { + let toolset = session.toolset(); + let deps = CargoDeps::new(None); + toolset.get::(&deps, None)? + }; let mut cmd = cmd.current_dir(session.root_dir()).arg("fmt").arg("--all"); if check { cmd = cmd.arg("--check"); diff --git a/prep/src/cmd/mod.rs b/prep/src/cmd/mod.rs index 0a004b7..5507fa9 100644 --- a/prep/src/cmd/mod.rs +++ b/prep/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub mod clippy; pub mod copyright; pub mod format; pub mod init; +pub mod tools; /// Cargo targets. #[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] diff --git a/prep/src/cmd/tools/list.rs b/prep/src/cmd/tools/list.rs new file mode 100644 index 0000000..bd59bb9 --- /dev/null +++ b/prep/src/cmd/tools/list.rs @@ -0,0 +1,64 @@ +// Copyright 2026 the Prep Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::session::Session; +use crate::tools::cargo::Cargo; +use crate::tools::rustup::Rustup; +use crate::ui::style::TABLE_HEADER; + +const MISSING: &str = "None"; + +/// List information on all the tools in the toolset. +pub fn run(session: &mut Session) -> anyhow::Result<()> { + let tools = session.config().tools(); + + let rustup_locked = format!("{}", tools.rustup()); + let rust_locked = format!("{}", tools.rust()); + let rg_locked = format!("{}", tools.ripgrep()); + + let toolset = session.toolset(); + + let rustup_global = toolset + .default_version::()? + .map(|v| format!("{v}")) + .unwrap_or_else(|| MISSING.into()); + let rust_global = toolset + .default_version::()? + .map(|v| format!("{v}")) + .unwrap_or_else(|| MISSING.into()); + let rg_global = String::from("Who knows"); + + fn cell(s: &str, len: usize) -> String { + let mut s = String::from(s); + s.push_str(&" ".repeat(len.saturating_sub(s.len()))); + s + } + + const NLEN: usize = 7; + const LLEN: usize = 16; + const GLEN: usize = 15; + + let h = TABLE_HEADER; + let info = format!( + "\ +{h}Name{h:#} {h}Required version{h:#} {h}Default version{h:#} +···{}·········· ···{}··················· ···{}·················· +···{}·········· ···{}··················· ···{}·················· +···{}·········· ···{}··················· ···{}·················· +", + cell("Rustup", NLEN), + cell(rustup_locked.trim_start_matches('='), LLEN), + cell(&rustup_global, GLEN), + cell("Rust", NLEN), + cell(rust_locked.trim_start_matches('='), LLEN), + cell(&rust_global, GLEN), + cell("Ripgrep", NLEN), + cell(rg_locked.trim_start_matches('='), LLEN), + cell(&rg_global, GLEN), + ) + .replace("·", ""); + + eprint!("{}", info); + + Ok(()) +} diff --git a/prep/src/cmd/tools/mod.rs b/prep/src/cmd/tools/mod.rs new file mode 100644 index 0000000..b0a8591 --- /dev/null +++ b/prep/src/cmd/tools/mod.rs @@ -0,0 +1,4 @@ +// Copyright 2026 the Prep Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +pub mod list; diff --git a/prep/src/config.rs b/prep/src/config.rs index dcd21bc..c72370c 100644 --- a/prep/src/config.rs +++ b/prep/src/config.rs @@ -1,28 +1,51 @@ // Copyright 2026 the Prep Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use semver::VersionReq; use serde::{Deserialize, Serialize}; -/// Prep project configuration. +/// Prep configuration. #[derive(Serialize, Deserialize)] pub struct Config { - /// The project configuration. + /// Project configuration. + #[serde(default = "Project::new")] project: Project, + /// Tools configuration. + #[serde(default = "Tools::new")] + tools: Tools, } +/// Project configuration. #[derive(Serialize, Deserialize)] pub struct Project { - /// The project name. + /// Project name. + #[serde(default = "name_default")] name: String, - /// The project License SPDX identifier. + /// Project License SPDX identifier. + #[serde(default = "license_default")] license: String, } +/// Tools configuration. +#[derive(Serialize, Deserialize)] +pub struct Tools { + /// Rustup configuration. + #[serde(default = "rustup_default")] + rustup: VersionReq, + /// Stable Rust toolchain configuration. + #[serde(default = "rust_default")] + rust: VersionReq, + /// Ripgrep configuration. + #[serde(default = "ripgrep_default")] + ripgrep: VersionReq, +} + impl Config { /// Creates a new [`Config`] with default values. pub fn new() -> Self { Self { project: Project::new(), + tools: Tools::new(), } } @@ -30,14 +53,19 @@ impl Config { pub fn project(&self) -> &Project { &self.project } + + /// Returns the tools configuration. + pub fn tools(&self) -> &Tools { + &self.tools + } } impl Project { /// Creates a new [`Project`] with default values. pub fn new() -> Self { Self { - name: "Untitled".into(), - license: "Apache-2.0 OR MIT".into(), + name: name_default(), + license: license_default(), } } @@ -51,3 +79,54 @@ impl Project { &self.license } } + +impl Tools { + /// Creates a new [`Tools`] with default values. + pub fn new() -> Self { + Self { + rustup: rustup_default(), + rust: rust_default(), + ripgrep: ripgrep_default(), + } + } + + /// Returns the configured Rustup version. + pub fn rustup(&self) -> &VersionReq { + &self.rustup + } + + /// Returns the configured stable Rust toolchain version. + pub fn rust(&self) -> &VersionReq { + &self.rust + } + + /// Returns the configured ripgrep version. + pub fn ripgrep(&self) -> &VersionReq { + &self.ripgrep + } +} + +/// Returns the default project name. +fn name_default() -> String { + "Untitled".into() +} + +/// Returns the default project license. +fn license_default() -> String { + "Apache-2.0 OR MIT".into() +} + +/// Returns the default Rustup version. +fn rustup_default() -> VersionReq { + VersionReq::parse("=1").expect("default rustup version parsing failed") +} + +/// Returns the default Rust version. +fn rust_default() -> VersionReq { + VersionReq::parse("=1.93").expect("default rust version parsing failed") +} + +/// Returns the default Ripgrep version. +fn ripgrep_default() -> VersionReq { + VersionReq::parse("=14.1.1").expect("default ripgrep version parsing failed") +} diff --git a/prep/src/main.rs b/prep/src/main.rs index ab656d1..a8c4b03 100644 --- a/prep/src/main.rs +++ b/prep/src/main.rs @@ -7,6 +7,7 @@ mod cmd; mod config; mod session; mod tools; +mod toolset; mod ui; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; @@ -34,15 +35,20 @@ enum Commands { }, #[command(alias = "clp")] Clippy { + #[arg(short, long)] + strict: bool, #[arg(name = "crates", short, long, value_enum, default_value_t = CargoTargets::Main)] targets: CargoTargets, + }, + #[command()] + Copyright { #[arg(short, long)] strict: bool, }, - #[command()] - Copyright, #[command(alias = "fmt")] Format { + #[arg(short, long)] + strict: bool, #[arg(short, long)] check: bool, }, @@ -51,6 +57,17 @@ enum Commands { #[arg(short, long, default_value_t = false)] force: bool, }, + #[command()] + Tools { + #[command(subcommand)] + command: Option, + }, +} + +#[derive(Subcommand)] +enum ToolsCommands { + #[command()] + List, } fn main() -> anyhow::Result<()> { @@ -59,20 +76,29 @@ fn main() -> anyhow::Result<()> { let cli = Cli::from_arg_matches(&matches).unwrap(); let Some(command) = cli.command else { - ui::print_help(); + ui::print_help(ui::help::root_msg()); return Ok(()); }; - let session = Session::initialize()?; + let mut session = Session::initialize()?; match command { Commands::Ci { extended, no_fail_fast, - } => cmd::ci::run(&session, extended, !no_fail_fast), - Commands::Clippy { targets, strict } => cmd::clippy::run(&session, targets, strict), - Commands::Copyright => cmd::copyright::run(&session), - Commands::Format { check } => cmd::format::run(&session, check), + } => cmd::ci::run(&mut session, extended, !no_fail_fast), + Commands::Clippy { strict, targets } => cmd::clippy::run(&mut session, strict, targets), + Commands::Copyright { strict } => cmd::copyright::run(&mut session, strict), + Commands::Format { strict, check } => cmd::format::run(&mut session, strict, check), Commands::Init { force } => cmd::init::run(&session, force), + Commands::Tools { command } => { + let Some(command) = command else { + ui::print_help(ui::help::tools_msg()); + return Ok(()); + }; + match command { + ToolsCommands::List => cmd::tools::list::run(&mut session), + } + } } } diff --git a/prep/src/session.rs b/prep/src/session.rs index b8a6f7f..5587529 100644 --- a/prep/src/session.rs +++ b/prep/src/session.rs @@ -10,7 +10,9 @@ use anyhow::{Context, Result, bail}; use cargo_metadata::MetadataCommand; use crate::config::Config; -use crate::tools::cargo; +use crate::tools::Tool; +use crate::tools::cargo::{Cargo, CargoDeps}; +use crate::toolset::Toolset; const PREP_DIR: &str = ".prep"; const CONFIG_FILE: &str = "prep.toml"; @@ -26,6 +28,8 @@ pub struct Session { /// Active configuration. config: Config, + /// Toolset. + toolset: Toolset, } impl Session { @@ -41,11 +45,14 @@ impl Session { let root_dir = find_root_dir(¤t_dir).context("failed to look for Prep config file")?; + let mut toolset = Toolset::new(); + // Fall back to the Cargo workspace root let root_dir = match root_dir { Some(root_dir) => root_dir, None => { - let cmd = cargo::new("")?; + let cargo_deps = CargoDeps::new(None); + let cmd = toolset.get::(&cargo_deps, None)?; let metadata = MetadataCommand::new() .cargo_path(cmd.get_program()) .exec() @@ -72,6 +79,7 @@ impl Session { prep_dir, config_path, config, + toolset, }; Ok(session) @@ -97,6 +105,11 @@ impl Session { &self.config } + /// Returns this session's toolset. + pub fn toolset(&mut self) -> &mut Toolset { + &mut self.toolset + } + /// Ensures that the prep directory exists. pub fn ensure_prep_dir(&self) -> Result<()> { if !self.prep_dir.exists() { diff --git a/prep/src/tools/cargo.rs b/prep/src/tools/cargo.rs index 961f9fa..5428306 100644 --- a/prep/src/tools/cargo.rs +++ b/prep/src/tools/cargo.rs @@ -1,92 +1,97 @@ // Copyright 2026 the Prep Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::collections::HashMap; -use std::io::ErrorKind; -use std::process::Command; -use std::sync::{LazyLock, RwLock}; - use anyhow::{Context, Result, bail, ensure}; +use semver::{Op, Version, VersionReq}; -use crate::tools::rustup; +use crate::tools::Tool; +use crate::tools::rustup::Rustup; +use crate::toolset::Toolset; use crate::ui; -/// Cargo executable name. -const BIN: &str = "cargo"; - -/// Toolchain version -> Cargo path -static PATHS: LazyLock>> = - LazyLock::new(|| RwLock::new(HashMap::new())); +/// Cargo from the Rust toolchain. +pub struct Cargo; -/// Returns the cargo command. -/// -/// Provide an empty `version` to pick the default cargo version. -pub fn new(version: &str) -> Result { - let paths = PATHS.read().expect("cargo setup lock poisoned"); - if let Some(path) = paths.get(version) { - return Ok(Command::new(path)); - } - drop(paths); - let mut paths = PATHS.write().expect("cargo setup lock poisoned"); - if let Some(path) = paths.get(version) { - return Ok(Command::new(path)); - } - let path = set_up(version)?; - let cmd = Command::new(&path); - paths.insert(version.into(), path); - Ok(cmd) +/// Cargo dependencies. +pub struct CargoDeps { + /// Rustup version requirement. + rustup_ver_req: Option, } -/// Ensures that Cargo is installed and ready to use. -pub fn set_up(version: &str) -> Result { - // TODO: Call rustup toolchain install with correct components etc first - - let mut cmd = rustup::new()?; - let mut cmd = cmd.arg("which").arg(BIN); - if !version.is_empty() { - cmd = cmd.args(["--toolchain", version]); +impl CargoDeps { + /// Creates new Cargo dependency requirements. + /// + /// `None` means that the default version will be used. + pub fn new(rustup_ver_req: impl Into>) -> Self { + Self { + rustup_ver_req: rustup_ver_req.into(), + } } - - ui::print_cmd(cmd); - - let output = cmd.output().context("failed to run rustup")?; - ensure!(output.status.success(), "rustup failed: {}", output.status); - - let path = String::from_utf8(output.stdout).context("rustup output not valid UTF-8")?; - let path = path.trim(); - - if !verify(path, version)? { - bail!("cargo not found"); - } - - Ok(path.into()) } -/// Returns `true` if Cargo was found, `false` if no Cargo was found. -/// -/// Other versions will return an error. -pub fn verify(path: &str, version: &str) -> Result { - let mut cmd = Command::new(path); - let cmd = cmd.arg("-V"); - - ui::print_cmd(cmd); - - let output = cmd.output(); - if output - .as_ref() - .is_err_and(|e| e.kind() == ErrorKind::NotFound) - { - return Ok(false); +impl Tool for Cargo { + type Deps = CargoDeps; + + const NAME: &str = "Cargo"; + const BIN: &str = "cargo"; + + fn set_up( + toolset: &mut Toolset, + deps: &Self::Deps, + ver_req: &VersionReq, + ) -> Result<(Version, String)> { + if ver_req.comparators.len() != 1 { + bail!( + "Only simple `=MAJOR.MINOR` version requirements are supported for the Rust toolchain" + ); + } + let ver_req_comp = ver_req.comparators.first().unwrap(); + if ver_req_comp.op != Op::Exact + || ver_req_comp.patch.is_some() + || ver_req_comp.minor.is_none() + || !ver_req_comp.pre.is_empty() + { + bail!( + "Only simple `=MAJOR.MINOR` version requirements are supported for the Rust toolchain" + ); + } + let toolchain_ver = format!("{}.{}", ver_req_comp.major, ver_req_comp.minor.unwrap()); + + // TODO: Call rustup toolchain install with correct components etc first + + let mut cmd = toolset.get::(&(), deps.rustup_ver_req.as_ref())?; + let cmd = cmd + .arg("which") + .arg(Self::BIN) + .args(["--toolchain", &toolchain_ver]); + + ui::print_cmd(cmd); + + let output = cmd + .output() + .context(format!("failed to run {}", Rustup::NAME))?; + ensure!( + output.status.success(), + "{} failed: {}", + Rustup::NAME, + output.status + ); + + let path = String::from_utf8(output.stdout) + .context(format!("{} output not valid UTF-8", Rustup::NAME))?; + let path = path.trim(); + + let Some(version) = toolset + .verify::(path, ver_req) + .context(format!("failed to verify {}", Self::NAME))? + else { + bail!( + "{} was reported by {} but it doesn't seem to exist", + path, + Rustup::NAME + ); + }; + + Ok((version, path.into())) } - let output = output.context("failed to run cargo")?; - ensure!(output.status.success(), "cargo failed: {}", output.status); - - let cmd_version = String::from_utf8(output.stdout).context("cargo output not valid UTF-8")?; - - let expected = format!("cargo {version}"); - if !cmd_version.starts_with(&expected) { - bail!("expected {expected}, got: {cmd_version}"); - } - - Ok(true) } diff --git a/prep/src/tools/mod.rs b/prep/src/tools/mod.rs index a98f88e..96473f5 100644 --- a/prep/src/tools/mod.rs +++ b/prep/src/tools/mod.rs @@ -3,3 +3,73 @@ pub mod cargo; pub mod rustup; + +use std::io::ErrorKind; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; +use regex::Regex; +use semver::{Version, VersionReq}; + +use crate::toolset::Toolset; +use crate::ui; + +/// Generic Prep tool code. +pub trait Tool: Sized + 'static { + type Deps; + + const NAME: &str; + const BIN: &str; + + /// Sets up a version of the tool that meets the given `ver_req`. + /// + /// Returns the specific version and the path to the binary. + fn set_up( + toolset: &mut Toolset, + deps: &Self::Deps, + ver_req: &VersionReq, + ) -> Result<(Version, String)>; + + /// Returns the [`Version`] of the binary at the given `path`. + /// + /// Returns `None` if the given `path` doesn't exist. + fn extract_version(path: &str) -> Result> { + let mut cmd = Command::new(path); + let cmd = cmd.arg("-V"); + + ui::print_cmd(cmd); + + let output = cmd.output(); + if output + .as_ref() + .is_err_and(|e| e.kind() == ErrorKind::NotFound) + { + return Ok(None); + } + let output = output.context(format!("failed to run '{path}'"))?; + ensure!( + output.status.success(), + "'{path}' failed: {}", + output.status + ); + + let version = + String::from_utf8(output.stdout).context(format!("'{path}' output not valid UTF-8"))?; + let version = version + .lines() + .next() + .context(format!("'{path}' output was empty"))?; + + let re = Regex::new(r"^\S+\s+(\d+\.\d+\.\d+[^\s]*)") + .expect("Version extraction regex was incorrect"); + let version = re + .captures(version) + .and_then(|c| c.get(1).map(|m| m.as_str())) + .context(format!("'{path}' output didn't contain version"))?; + + let version = Version::parse(version) + .context(format!("failed to parse '{path}' version '{version}'"))?; + + Ok(Some(version)) + } +} diff --git a/prep/src/tools/rustup.rs b/prep/src/tools/rustup.rs index 5033447..f755d29 100644 --- a/prep/src/tools/rustup.rs +++ b/prep/src/tools/rustup.rs @@ -1,77 +1,44 @@ // Copyright 2026 the Prep Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::io::ErrorKind; -use std::process::Command; -use std::sync::RwLock; - -use anyhow::{Context, Result, bail, ensure}; +use anyhow::{Context, Result, bail}; +use semver::{Version, VersionReq}; +use crate::tools::Tool; +use crate::toolset::Toolset; use crate::ui; -/// Rustup executable name. -const BIN: &str = "rustup"; - -/// Whether rustup is ready to use. -static READY: RwLock = RwLock::new(false); - -/// Returns the rustup command. -pub fn new() -> Result { - if !*READY.read().expect("rustup setup lock poisoned") { - let mut ready = READY.write().expect("rustup setup lock poisoned"); - if !*ready { - set_up().context("failed to set up rustup")?; - *ready = true; - } - } - Ok(Command::new(BIN)) -} - -/// Ensures that rustup is installed and ready to use. -pub fn set_up() -> Result<()> { - // Check if rustup is already available - let found = verify()?; - if !found { - ui::print_err( - "\ - Prep requires rustup v1 to function.\n\ - \n\ - There is no automatic setup implemented for it, sorry.\n\ - Please go to https://rustup.rs/ and install it manually.\n\ - \n\ - If you already have rustup installed then this error here is probably a bug.\n\ - Please report it at https://github.com/Nevermore/prep\n\ - ", - ); - bail!("rustup not found"); +pub struct Rustup; + +impl Tool for Rustup { + type Deps = (); + + const NAME: &str = "Rustup"; + const BIN: &str = "rustup"; + + fn set_up( + toolset: &mut Toolset, + _deps: &Self::Deps, + ver_req: &VersionReq, + ) -> Result<(Version, String)> { + // Check if the default Rustup installation already meets the requirement. + let version = toolset + .verify::(Self::BIN, ver_req) + .context(format!("failed to verify {}", Self::NAME))?; + let Some(version) = version else { + ui::print_err( + "\ + Prep requires rustup to function.\n\ + \n\ + There is no automatic setup implemented for it, sorry.\n\ + Please go to https://rustup.rs/ and install it manually.\n\ + \n\ + If you already have rustup installed then this error here is probably a bug.\n\ + Please report it at https://github.com/Nevermore/prep\n\ + ", + ); + bail!("{} not found", Self::NAME); + }; + Ok((version, Self::BIN.into())) } - Ok(()) -} - -/// Returns `true` if rustup v1 was found, `false` if no rustup was found. -/// -/// Other versions will return an error. -pub fn verify() -> Result { - let mut cmd = Command::new(BIN); - let cmd = cmd.arg("-V"); - - ui::print_cmd(cmd); - - let output = cmd.output(); - if output - .as_ref() - .is_err_and(|e| e.kind() == ErrorKind::NotFound) - { - return Ok(false); - } - let output = output.context("failed to run rustup")?; - ensure!(output.status.success(), "rustup failed: {}", output.status); - - let version = String::from_utf8(output.stdout).context("rustup output not valid UTF-8")?; - - if !version.starts_with("rustup 1.") { - bail!("expected rustup v1, got: {version}"); - } - - Ok(true) } diff --git a/prep/src/toolset.rs b/prep/src/toolset.rs new file mode 100644 index 0000000..0454d45 --- /dev/null +++ b/prep/src/toolset.rs @@ -0,0 +1,116 @@ +// Copyright 2026 the Prep Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::any::TypeId; +use std::collections::{BTreeMap, HashMap}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use semver::{Version, VersionReq}; + +use crate::tools::Tool; + +/// Collection of tools. +pub struct Toolset { + /// Per-tool, per-version, binary executable paths. + path_registry: HashMap>, +} + +impl Toolset { + /// Creates a new toolset. + pub fn new() -> Self { + Self { + path_registry: HashMap::new(), + } + } + + /// Returns the tool-specific version->path map. + fn tool_paths(&mut self) -> &mut BTreeMap { + let type_id = TypeId::of::(); + self.path_registry.entry(type_id).or_default() + } + + /// Get a specific tool that meets the given version requirement + /// and uses the specified dependencies. + /// + /// `None` as the version requirement means that the default version will be used. + pub fn get<'a, T: Tool>( + &mut self, + deps: &T::Deps, + ver_req: impl Into>, + ) -> Result { + // Check if we can just return the default version, + // i.e. just the binary name with no detailed path. + let Some(ver_req) = ver_req.into() else { + return Ok(Command::new(T::BIN)); + }; + // A specific version requirement was provided, + // so we need to see if we can already fulfill it. + let paths = self.tool_paths::(); + // Iterate in reverse because we want to match with the highest possible version. + for (version, path) in paths.iter().rev() { + if ver_req.matches(version) { + return Ok(Command::new(path)); + } + } + // No satisfactory version in the registry yet, so set it up. + let (version, path) = T::set_up(self, deps, ver_req)?; + // Prep the command before we move out 'path'. + let cmd = Command::new(&path); + // Save the setup path, which will probably overwrite the same path already saved by setup, + // via Self::version(). That should be fine and idempotent and ensures cache when a setup + // implementation doesn't call Self::version(). + let paths = self.tool_paths::(); + paths.insert(version, path); + // Return the result + Ok(cmd) + } + + /// Verifies that the given `path` is a binary for the given `ver_req` of the tool. + /// + /// Returns the specific `Version` of the tool, or `None` if the path doesn't exist. + /// Errors if the `path` exists but is an unexpected version. + pub fn verify(&mut self, path: &str, ver_req: &VersionReq) -> Result> { + let version = self.version::(path)?; + let Some(version) = version else { + return Ok(None); + }; + if !ver_req.matches(&version) { + bail!("expected {path} to satisfy {ver_req}, got: {version}"); + } + Ok(Some(version)) + } + + /// Returns the default version information. + /// + /// Returns `None` if no default version is found. + #[inline(always)] + pub fn default_version(&mut self) -> Result> { + self.version::(T::BIN) + } + + /// Returns the [`Version`] of the binary at the given `path`. + /// + /// Returns `None` if the given `path` doesn't exist. + pub fn version(&mut self, path: &str) -> Result> { + // First check if we already know this path's version. + let paths = self.tool_paths::(); + for (version, ver_path) in paths.iter() { + if path == ver_path { + return Ok(Some(version.clone())); + } + } + + // Brand new path, so we need to figure out the version. + let Some(version) = + T::extract_version(path).context(format!("failed to extract {} version", T::NAME))? + else { + return Ok(None); + }; + + // Cache the result in the registry + paths.insert(version.clone(), path.into()); + + Ok(Some(version)) + } +} diff --git a/prep/src/ui/help.rs b/prep/src/ui/help.rs index ad01347..a797655 100644 --- a/prep/src/ui/help.rs +++ b/prep/src/ui/help.rs @@ -22,6 +22,8 @@ pub fn set(cmd: Command) -> Command { scmd.override_help(format_msg()) } else if name == "init" { scmd.override_help(init_msg()) + } else if name == "tools" { + scmd.override_help(tools_msg()) } else { panic!("Sub-command '{name}' help message is not implemented"); } @@ -86,11 +88,11 @@ Analyze the Rust workspace with Clippy. ··· ····· {l}prep clippy{l:#} {p}[options]{p:#} {h}Options:{h:#} + {l}-s --strict {l:#}Use locked Rust toolchain version and treat warnings as errors. {l}-c --crates {l:#}Target specified crates. Possible values: ··· ·····{p}main{p:#} -> Binaries and the main library. (default) ··· ·····{p}aux{p:#} -> Examples, tests, and benches. ··· ·····{p}all{p:#} -> All of the above. - {l}-s --strict {l:#}Treat warnings as errors. {l}-h --help {l:#}Print this help message. " ) @@ -109,6 +111,7 @@ Verify that all Rust source files have the correct copyright header. {h}Usage:{h:#} {l}prep copyright{l:#} {h}Options:{h:#} + {l}-s --strict {l:#}Use locked ripgrep version. {l}-h --help {l:#}Print this help message. " ) @@ -128,6 +131,7 @@ Format the Rust workspace with rustfmt. ··· ····· {l}prep format{l:#} {p}[options]{p:#} {h}Options:{h:#} + {l}-s --strict {l:#}Use locked Rust toolchain version. {l}-c --check {l:#}Verify that the workspace is already formatted. {l}-h --help {l:#}Print this help message. " @@ -155,3 +159,24 @@ Initialize Prep configuration for this Rust workspace. StyledStr::from(help) } + +/// Returns the tools help message. +pub fn tools_msg() -> StyledStr { + let (h, l, p) = (HEADER, LITERAL, PLACEHOLDER); + let help = format!( + "\ +Manage all the tools that Prep uses. + +{h}Usage:{h:#} {l}prep tools{l:#} {p}[command] [options]{p:#} + +{h}Commands:{h:#} + {l} list {l:#}List information about all the tools. + {l} help {l:#}Print help for the provided command. + +{h}Options:{h:#} + {l}-h --help {l:#}Print help for the provided command. +" + ); + + StyledStr::from(help) +} diff --git a/prep/src/ui/mod.rs b/prep/src/ui/mod.rs index efa71c6..8d7676e 100644 --- a/prep/src/ui/mod.rs +++ b/prep/src/ui/mod.rs @@ -7,6 +7,8 @@ pub mod style; use std::ffi::OsStr; use std::process::Command; +use clap::builder::StyledStr; + /// Prints lines aligned lines with only the first line getting the header. pub fn print_lines(header: &str, lines: &str) { for (idx, line) in lines.split("\n").enumerate() { @@ -47,7 +49,7 @@ pub fn print_warn(warn: &str) { } /// Prints the main help message. -pub fn print_help() { +pub fn print_help(msg: StyledStr) { // TODO: Don't print ANSI codes when not supported by the environment. - eprint!("{}", help::root_msg().ansi()); + eprint!("{}", msg.ansi()); } diff --git a/prep/src/ui/style.rs b/prep/src/ui/style.rs index 3e1bb6b..584b821 100644 --- a/prep/src/ui/style.rs +++ b/prep/src/ui/style.rs @@ -54,3 +54,5 @@ pub const DEP_NORMAL: Style = Style::new().dimmed(); pub const DEP_BUILD: Style = AnsiColor::Blue.on_default().bold(); pub const DEP_DEV: Style = AnsiColor::Cyan.on_default().bold(); pub const DEP_FEATURE: Style = AnsiColor::Magenta.on_default().dimmed(); + +pub const TABLE_HEADER: Style = Style::new().bold().underline();