From 746c71cd4fca46d75e1081d4d503ca8926b74c0b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 5 Aug 2026 18:25:58 -0600 Subject: [PATCH] Detect ripgrep and cmake in dev setup and never install without asking --- Justfile | 11 +++++ crates/pecos-build/src/cmake.rs | 25 +++------- crates/pecos-build/src/executable.rs | 32 +++++++++++++ crates/pecos-build/src/lib.rs | 2 + crates/pecos-build/src/llvm.rs | 11 +---- crates/pecos-build/src/prompt.rs | 37 +++++++++++++-- crates/pecos-build/src/ripgrep.rs | 58 +++++++++++++++++++++++ crates/pecos-cli/src/cli/install_cmd.rs | 17 ++++++- crates/pecos-cli/src/cli/rust_cmd.rs | 18 +++++++ crates/pecos-cli/src/cli/setup_cmd.rs | 62 +++++++++++++++++++++++-- crates/pecos-cli/src/main.rs | 18 +++++-- docs/development/DEVELOPMENT.md | 1 + docs/development/dev-tools.md | 6 +++ scripts/dependency-integrity-check.sh | 12 ++++- 14 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 crates/pecos-build/src/executable.rs create mode 100644 crates/pecos-build/src/ripgrep.rs diff --git a/Justfile b/Justfile index 9d38a0c46..eefee2235 100644 --- a/Justfile +++ b/Justfile @@ -171,6 +171,17 @@ doctor: _msvc-bootstrap fi echo "" + echo "Developer tooling:" + if RG_VER=$(rg --version 2>/dev/null | head -1); then + ok "ripgrep" "${RG_VER:-installed}" + else + fail "ripgrep" "not found (run: pecos install ripgrep)" + echo " Manual install options: cargo install ripgrep --locked, brew install ripgrep," + echo " apt install ripgrep, or winget install BurntSushi.ripgrep" + echo " https://github.com/BurntSushi/ripgrep#installation" + fi + echo "" + if [ "$PROBLEMS" -eq 0 ]; then echo "No problems found." else diff --git a/crates/pecos-build/src/cmake.rs b/crates/pecos-build/src/cmake.rs index f5c03fe8c..d81c07094 100644 --- a/crates/pecos-build/src/cmake.rs +++ b/crates/pecos-build/src/cmake.rs @@ -40,7 +40,12 @@ pub fn find_system_cmake() -> Option { if !output.status.success() { return None; } - which_in_path("cmake") + let extensions: &[&str] = if cfg!(windows) { + &[".exe", ".bat", ""] + } else { + &[""] + }; + crate::executable::which_in_path("cmake", extensions) } /// Directory containing the cmake binary for a given installation root. @@ -64,24 +69,6 @@ pub fn cmake_binary_in(root: &Path) -> Option { candidate.is_file().then_some(candidate) } -fn which_in_path(name: &str) -> Option { - let path_var = std::env::var_os("PATH")?; - let exts: &[&str] = if cfg!(windows) { - &[".exe", ".bat", ""] - } else { - &[""] - }; - for dir in std::env::split_paths(&path_var) { - for ext in exts { - let candidate = dir.join(format!("{name}{ext}")); - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - /// The docs URL we point users at for manual install instructions. pub const DOCS_URL: &str = "https://github.com/PECOS-packages/PECOS/blob/dev/docs/user-guide/cmake-setup.md"; diff --git a/crates/pecos-build/src/executable.rs b/crates/pecos-build/src/executable.rs new file mode 100644 index 000000000..53533f141 --- /dev/null +++ b/crates/pecos-build/src/executable.rs @@ -0,0 +1,32 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Executable lookup shared by external-tool detectors. + +use std::path::PathBuf; + +/// Resolve an executable via `PATH` using the caller's platform suffix policy. +#[must_use] +pub(crate) fn which_in_path(name: &str, extensions: &[&str]) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + for ext in extensions { + let candidate = dir.join(format!("{name}{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} diff --git a/crates/pecos-build/src/lib.rs b/crates/pecos-build/src/lib.rs index 09f21d22d..d5f9ae637 100644 --- a/crates/pecos-build/src/lib.rs +++ b/crates/pecos-build/src/lib.rs @@ -61,11 +61,13 @@ pub mod cutensor; pub mod deps; pub mod download; pub mod errors; +mod executable; pub mod extract; pub mod home; pub mod llvm; pub mod manifest; pub mod prompt; +pub mod ripgrep; // Re-export main types for convenience pub use deps::ensure_dep_ready; diff --git a/crates/pecos-build/src/llvm.rs b/crates/pecos-build/src/llvm.rs index cbaa5e5bd..a09494cf6 100644 --- a/crates/pecos-build/src/llvm.rs +++ b/crates/pecos-build/src/llvm.rs @@ -103,15 +103,8 @@ pub struct PathToolReport { /// (and external tools like Selene) find it. #[must_use] pub fn which_on_path(exe_name: &str) -> Option { - let exe = if cfg!(windows) { - format!("{exe_name}.exe") - } else { - exe_name.to_string() - }; - let paths = std::env::var_os("PATH")?; - std::env::split_paths(&paths) - .map(|dir| dir.join(&exe)) - .find(|candidate| candidate.is_file()) + let extensions: &[&str] = if cfg!(windows) { &[".exe"] } else { &[""] }; + crate::executable::which_in_path(exe_name, extensions) } /// Parse an LLVM `--version` output into a bare `X.Y.Z` version string. diff --git a/crates/pecos-build/src/prompt.rs b/crates/pecos-build/src/prompt.rs index a4850e9b9..bd016dfa4 100644 --- a/crates/pecos-build/src/prompt.rs +++ b/crates/pecos-build/src/prompt.rs @@ -8,7 +8,7 @@ use std::io::{self, BufRead, IsTerminal, Write}; /// How to resolve prompts: interactively, or with a forced answer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PromptMode { - /// Ask the user interactively (falls back to default if not a TTY). + /// Ask the user interactively (declines if stdin is not a TTY). Interactive, /// Accept all prompts without asking. AcceptAll, @@ -22,18 +22,29 @@ pub enum PromptMode { /// - `default_yes`: Whether the default answer is yes (`[Y/n]`) or no (`[y/N]`) /// - `mode`: How to resolve the prompt /// -/// In `Interactive` mode, returns the default if stdin is not a TTY (e.g. piped input, CI). +/// In `Interactive` mode, declines if stdin is not a TTY (e.g. piped input, CI). #[must_use] pub fn confirm(message: &str, default_yes: bool, mode: PromptMode) -> bool { + confirm_with_terminal(message, default_yes, mode, io::stdin().is_terminal()) +} + +fn confirm_with_terminal( + message: &str, + default_yes: bool, + mode: PromptMode, + stdin_is_terminal: bool, +) -> bool { match mode { PromptMode::AcceptAll => return true, PromptMode::DeclineAll => return false, PromptMode::Interactive => {} } - // Non-interactive environment -> use default silently - if !io::stdin().is_terminal() { - return default_yes; + if !stdin_is_terminal { + println!( + "Prompt auto-declined because stdin is non-interactive; pass `--yes` to accept prompts non-interactively." + ); + return false; } let hint = if default_yes { "[Y/n]" } else { "[y/N]" }; @@ -67,4 +78,20 @@ mod tests { assert!(!confirm("test?", false, PromptMode::DeclineAll)); assert!(!confirm("test?", true, PromptMode::DeclineAll)); } + + #[test] + fn non_tty_interactive_declines_regardless_of_default() { + assert!(!confirm_with_terminal( + "test?", + false, + PromptMode::Interactive, + false + )); + assert!(!confirm_with_terminal( + "test?", + true, + PromptMode::Interactive, + false + )); + } } diff --git a/crates/pecos-build/src/ripgrep.rs b/crates/pecos-build/src/ripgrep.rs new file mode 100644 index 000000000..e0de2066d --- /dev/null +++ b/crates/pecos-build/src/ripgrep.rs @@ -0,0 +1,58 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! ripgrep detection and installation. + +use crate::errors::{Error, Result}; +use std::path::PathBuf; +use std::process::Command; + +/// The docs URL we point users at for manual install instructions. +pub const DOCS_URL: &str = "https://github.com/BurntSushi/ripgrep#installation"; + +/// Find a usable `rg` on the system `PATH`. +#[must_use] +pub fn find_ripgrep() -> Option { + let output = Command::new("rg").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + let extensions: &[&str] = if cfg!(windows) { + &[".exe", ".bat", ""] + } else { + &[""] + }; + crate::executable::which_in_path("rg", extensions) +} + +/// Install ripgrep with Cargo, streaming build output to the terminal. +/// +/// # Errors +/// +/// Returns an error if Cargo cannot be started or the installation fails. +pub fn install_ripgrep(force: bool) -> Result<()> { + let mut command = Command::new("cargo"); + command.args(["install", "ripgrep", "--locked"]); + if force { + command.arg("--force"); + } + + let status = command.status()?; + if !status.success() { + return Err(Error::Config(format!( + "`cargo install ripgrep --locked` failed with {status}" + ))); + } + Ok(()) +} diff --git a/crates/pecos-cli/src/cli/install_cmd.rs b/crates/pecos-cli/src/cli/install_cmd.rs index b3119ae44..813f40d62 100644 --- a/crates/pecos-cli/src/cli/install_cmd.rs +++ b/crates/pecos-cli/src/cli/install_cmd.rs @@ -5,7 +5,7 @@ use pecos_build::errors::Error; use pecos_build::prompt::{PromptMode, confirm}; /// Known installable targets -const KNOWN_TARGETS: &[&str] = &["cuda", "llvm", "cuquantum", "cmake"]; +const KNOWN_TARGETS: &[&str] = &["cuda", "llvm", "cuquantum", "cmake", "ripgrep"]; /// Run the install command pub fn run( @@ -47,7 +47,7 @@ pub fn run( .is_some_and(|p| p.to_string_lossy().contains(".pecos/deps/")); if let Some(path) = existing.as_ref().filter(|_| !force) { - if is_local { + if is_local || !has_managed_install(target) { println!( "[{}/{}] {target}: already installed at {}", i + 1, @@ -89,6 +89,15 @@ pub fn run( Ok(()) } +/// Whether PECOS can install a private copy under `~/.pecos/deps/`. +fn has_managed_install(target: &str) -> bool { + match target { + "cuda" | "llvm" | "cuquantum" | "cmake" => true, + "ripgrep" => false, + _ => unreachable!("target was validated above"), + } +} + /// Find where a target is currently installed (if at all) fn find_existing(target: &str) -> Option { match target { @@ -96,6 +105,7 @@ fn find_existing(target: &str) -> Option { "llvm" => pecos_build::llvm::find_llvm(None), "cuquantum" => pecos_build::cuquantum::find_cuquantum(), "cmake" => pecos_build::cmake::find_cmake(), + "ripgrep" => pecos_build::ripgrep::find_ripgrep(), _ => None, } } @@ -116,6 +126,9 @@ fn install_target(target: &str, force: bool, no_configure: bool, yes: bool) -> R "cmake" => { pecos_build::cmake::installer::install_cmake(force)?; } + "ripgrep" => { + pecos_build::ripgrep::install_ripgrep(force)?; + } _ => unreachable!("target was validated above"), } Ok(()) diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index 4aed6dcf0..7364bb4d6 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -130,6 +130,18 @@ fn reject_static_llvm_workspace_test() -> Result<()> { ))) } +fn require_cmake_for_mwpf(invocation: &str) -> Result<()> { + if pecos_build::cmake::find_cmake().is_some() { + return Ok(()); + } + + Err(Error::Config(format!( + "{invocation}, which enables the `mwpf` feature and requires cmake. \ + Install cmake with `pecos install cmake`. See {}", + pecos_build::cmake::DOCS_URL + ))) +} + /// Run the rust subcommand pub fn run(command: &super::RustCommands) -> Result<()> { match command { @@ -305,6 +317,8 @@ fn run_cargo_command_with_rustflags(args: &[&str], rustflags: Option<&str>) -> b /// Run cargo check with GPU-aware feature handling #[allow(clippy::too_many_lines)] fn run_check(include_ffi: bool) -> Result<()> { + require_cmake_for_mwpf("`pecos rust check` runs the workspace with `--all-features`")?; + let gpu_probe = probe_gpu_availability(); let include_gpu_sims = should_include_gpu_sims(&gpu_probe); @@ -387,6 +401,8 @@ fn run_check(include_ffi: bool) -> Result<()> { /// Run cargo clippy with GPU-aware feature handling #[allow(clippy::too_many_lines)] fn run_clippy(include_ffi: bool, fix: bool) -> Result<()> { + require_cmake_for_mwpf("`pecos rust clippy` runs the workspace with `--all-features`")?; + let gpu_probe = probe_gpu_availability(); let include_gpu_sims = should_include_gpu_sims(&gpu_probe); @@ -486,6 +502,8 @@ fn run_clippy(include_ffi: bool, fix: bool) -> Result<()> { /// Run cargo test with GPU-aware feature handling fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { + require_cmake_for_mwpf("`pecos rust test` runs `cargo test -p pecos-decoders --all-features`")?; + // Warn about any C++ dependency version differences across crates check_dep_consistency(); diff --git a/crates/pecos-cli/src/cli/setup_cmd.rs b/crates/pecos-cli/src/cli/setup_cmd.rs index 655da42f6..2011d72fa 100644 --- a/crates/pecos-cli/src/cli/setup_cmd.rs +++ b/crates/pecos-cli/src/cli/setup_cmd.rs @@ -17,6 +17,7 @@ pub fn run( skip_llvm: bool, skip_cuda: bool, skip_cmake: bool, + skip_ripgrep: bool, quiet: bool, ) -> Result<()> { // Check for legacy installs that should be migrated @@ -26,11 +27,11 @@ pub fn run( // that fail the workspace hygiene test. Quiet unless something is removed. sweep_stale_selene_plugins(); - let anything_missing = has_missing_deps(skip_llvm, skip_cuda, skip_cmake); + let anything_missing = has_missing_deps(skip_llvm, skip_cuda, skip_cmake, skip_ripgrep); // Show summary: always when not quiet, or when something needs action if !quiet || anything_missing { - print_status_summary(skip_llvm, skip_cuda, skip_cmake); + print_status_summary(skip_llvm, skip_cuda, skip_cmake, skip_ripgrep); println!(); } @@ -59,6 +60,10 @@ pub fn run( setup_cmake(mode)?; } + if !skip_ripgrep { + setup_ripgrep(mode); + } + if !quiet || anything_missing { println!(); println!("Setup complete. Run `just build` to build PECOS."); @@ -66,7 +71,12 @@ pub fn run( Ok(()) } -fn has_missing_deps(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) -> bool { +fn has_missing_deps( + skip_llvm: bool, + skip_cuda: bool, + skip_cmake: bool, + skip_ripgrep: bool, +) -> bool { if !skip_llvm && pecos_build::llvm::find_llvm(None).is_none() { return true; } @@ -88,10 +98,13 @@ fn has_missing_deps(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) -> bool if !skip_cmake && pecos_build::cmake::find_cmake().is_none() { return true; } + if !skip_ripgrep && pecos_build::ripgrep::find_ripgrep().is_none() { + return true; + } false } -fn print_status_summary(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) { +fn print_status_summary(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool, skip_ripgrep: bool) { println!("PECOS dependency status:"); println!(); @@ -147,6 +160,15 @@ fn print_status_summary(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) { } else { println!(" cmake: not found (optional, enables the MWPF decoder)"); } + + // ripgrep (required by dependency integrity checks) + if skip_ripgrep { + println!(" ripgrep: skipped (--skip-ripgrep)"); + } else if let Some(path) = pecos_build::ripgrep::find_ripgrep() { + println!(" ripgrep: {}", path.display()); + } else { + println!(" ripgrep: not found (required by `just lint` and `just security-check`)"); + } } // ── Selene plugin hygiene ─────────────────────────────────────────────────── @@ -451,6 +473,38 @@ fn setup_cmake(mode: PromptMode) -> Result<()> { Ok(()) } +// ── ripgrep (developer tooling) ───────────────────────────────────────────── + +fn setup_ripgrep(mode: PromptMode) { + if pecos_build::ripgrep::find_ripgrep().is_some() { + return; + } + + let prompt = "Install ripgrep? (builds via `cargo install ripgrep --locked`, required by the dependency integrity checks that `just lint` runs)"; + if !confirm(prompt, true, mode) { + println!( + " Skipping ripgrep. `just lint` and `just security-check` will fail the dependency integrity check." + ); + print_ripgrep_install_routes(); + return; + } + + if let Err(e) = pecos_build::ripgrep::install_ripgrep(false) { + eprintln!(" Warning: ripgrep install failed: {e}"); + print_ripgrep_install_routes(); + } +} + +fn print_ripgrep_install_routes() { + println!(" To install later via PECOS: pecos install ripgrep"); + println!(" Manual install options:"); + println!(" cargo install ripgrep --locked"); + println!(" brew install ripgrep"); + println!(" apt install ripgrep"); + println!(" winget install BurntSushi.ripgrep"); + println!(" See {}", pecos_build::ripgrep::DOCS_URL); +} + // ── Helpers ───────────────────────────────────────────────────────────────── fn ensure_llvm_configured() { diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index a0258f3dd..fe0655e72 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -134,7 +134,7 @@ enum Commands { /// Set up build environment (detect and install missing dependencies) /// - /// Interactively checks for LLVM, CUDA, and cuQuantum and offers to + /// Interactively checks for LLVM, CUDA, cuQuantum, cmake, and ripgrep and offers to /// install each one that is missing. Use --yes to accept all prompts /// (for CI) or --no to decline all (for a lite build). /// @@ -161,6 +161,10 @@ enum Commands { #[arg(long)] skip_cmake: bool, + /// Skip ripgrep setup (ripgrep is required by dependency integrity checks) + #[arg(long)] + skip_ripgrep: bool, + /// Suppress output when all dependencies are already found #[arg(short, long)] quiet: bool, @@ -180,7 +184,7 @@ enum Commands { #[arg(long, conflicts_with = "yes")] no: bool, }, - /// Install optional dependencies (cuda, llvm, cuquantum) + /// Install optional dependencies (cuda, llvm, cuquantum, cmake, ripgrep) /// /// Example: pecos install cuda cuquantum Install { @@ -725,6 +729,7 @@ fn main() -> Result<(), Box> { skip_llvm, skip_cuda, skip_cmake, + skip_ripgrep, quiet, } => { let mode = if *yes { @@ -734,7 +739,14 @@ fn main() -> Result<(), Box> { } else { pecos_build::prompt::PromptMode::Interactive }; - cli::setup_cmd::run(mode, *skip_llvm, *skip_cuda, *skip_cmake, *quiet)?; + cli::setup_cmd::run( + mode, + *skip_llvm, + *skip_cuda, + *skip_cmake, + *skip_ripgrep, + *quiet, + )?; } Commands::Migrate { yes, no } => { let mode = if *yes { diff --git a/docs/development/DEVELOPMENT.md b/docs/development/DEVELOPMENT.md index ea99ae1b7..56d862c02 100644 --- a/docs/development/DEVELOPMENT.md +++ b/docs/development/DEVELOPMENT.md @@ -9,6 +9,7 @@ - [uv](https://docs.astral.sh/uv/getting-started/installation/) - Python package manager - [just](https://github.com/casey/just) - Command runner - [pecos](https://crates.io/crates/pecos) - PECOS dev tools CLI +- [ripgrep](https://github.com/BurntSushi/ripgrep#installation) - Required by the dependency integrity checks run by `just lint` and `just security-check`. Install it with `pecos install ripgrep`, or manually with `cargo install ripgrep --locked`, `brew install ripgrep`, `apt install ripgrep`, or `winget install BurntSushi.ripgrep`. - **Windows**: [Git for Windows](https://git-scm.com/download/win) (provides Git Bash, required by Justfile recipes) or WSL **Pure Rust development** (Rust crates only): diff --git a/docs/development/dev-tools.md b/docs/development/dev-tools.md index e6747b24b..1f94e6fc5 100644 --- a/docs/development/dev-tools.md +++ b/docs/development/dev-tools.md @@ -27,6 +27,7 @@ pecos python build --profile native # Release + native-CPU codegen (Rust and C pecos install llvm # Install managed LLVM 21.1 where supported pecos install cuda # Install CUDA Toolkit to ~/.pecos/deps/cuda/ pecos install cuquantum # Install cuQuantum SDK to ~/.pecos/deps/cuquantum/ +pecos install ripgrep # Install ripgrep with cargo install pecos install --all # Install all optional dependencies pecos uninstall llvm # Uninstall LLVM pecos upgrade llvm # Upgrade (force reinstall) LLVM @@ -183,6 +184,11 @@ Syncs crate-level `pecos.toml` manifests from the workspace-level manifest. ## Dependency and Security Policy +The dependency integrity check run by `just lint` and `just security-check` +requires ripgrep. Install it with `pecos install ripgrep`, or manually with +`cargo install ripgrep --locked`, `brew install ripgrep`, `apt install ripgrep`, +or `winget install BurntSushi.ripgrep`. + Run these recipes when changing dependencies, lockfiles, CI workflows, action references, cache behavior, or security policy: ```bash diff --git a/scripts/dependency-integrity-check.sh b/scripts/dependency-integrity-check.sh index 54d9a5aa5..e83c8c219 100755 --- a/scripts/dependency-integrity-check.sh +++ b/scripts/dependency-integrity-check.sh @@ -259,7 +259,17 @@ RG_EXCLUDES=( section "Tooling" tooling_failures_before=$failures -require_tool rg || true +if ! command -v rg >/dev/null 2>&1 || ! rg --version >/dev/null 2>&1; then + fail "ripgrep (rg) is required for dependency integrity checks" + printf '%s\n' \ + ' Install via PECOS: pecos install ripgrep' \ + ' Manual install options:' \ + ' cargo install ripgrep --locked' \ + ' brew install ripgrep' \ + ' apt install ripgrep' \ + ' winget install BurntSushi.ripgrep' \ + ' See https://github.com/BurntSushi/ripgrep#installation' >&2 +fi require_tool cargo || true require_tool uv || true require_tool python3 || true