Merge nucleic/sleek-thistle-egret-fyej into dev

This commit is contained in:
2026-07-18 05:19:31 -07:00
commit b6be87b72d
677 changed files with 102939 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "xtask"
publish = false
version = "0.1.0"
authors.workspace = true
categories.workspace = true
edition.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
[[bin]]
name = "xtask"
path = "src/main.rs"
bench = false
[lints]
workspace = true
[dependencies]
anyhow = "1.0.102"
num_cpus = "1.17.0"
serde = { version = "1.0.228", features = ["derive"] }
brush-shell = { version = "^0.4.0", path = "../brush-shell", features = [
"schema",
] }
clap = { version = "4.6.0", features = ["derive"] }
clap_complete = "4.6.0"
clap_mangen = "0.3.0"
clap-markdown = "0.1.5"
schemars = "1.2.1"
serde_json = "1.0.149"
xshell = "0.2.7"
tempfile = "3.27.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2.184"
pty-process = "0.5.3"
+208
View File
@@ -0,0 +1,208 @@
//! Analysis commands for benchmarks and API diffing.
//!
//! This module provides tools for:
//! - Running performance benchmarks with optional output capture
//! - Comparing public API changes between branches using `cargo-public-api`
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use xshell::{Shell, cmd};
/// Run analysis and comparison tools.
#[derive(Parser)]
pub enum AnalyzeCommand {
/// Run benchmarks and output results.
Bench(BenchArgs),
/// Compare public API against a base branch.
PublicApi(PublicApiArgs),
}
/// Arguments for benchmark analysis.
#[derive(Parser)]
pub struct BenchArgs {
/// Output file for benchmark results (bencher format).
#[clap(long, short = 'o')]
output: Option<PathBuf>,
}
/// Arguments for public API analysis.
#[derive(Parser)]
pub struct PublicApiArgs {
/// Base branch to compare against (e.g., 'main' or 'origin/main').
#[clap(long, short = 'b', default_value = "origin/main")]
base: String,
/// Output directory for API diff reports.
#[clap(long, short = 'o', default_value = "reports")]
output_dir: PathBuf,
}
/// Run an analysis command.
pub fn run(cmd: &AnalyzeCommand, verbose: bool) -> Result<()> {
let sh = Shell::new()?;
match cmd {
AnalyzeCommand::Bench(args) => run_bench(&sh, args, verbose),
AnalyzeCommand::PublicApi(args) => run_public_api(&sh, args, verbose),
}
}
/// Run benchmarks using `cargo bench`.
///
/// When an output file is specified, benchmarks are run with `--output-format bencher`
/// to produce machine-readable output suitable for CI comparison tools.
fn run_bench(sh: &Shell, args: &BenchArgs, verbose: bool) -> Result<()> {
eprintln!("Running benchmarks...");
if let Some(output) = &args.output {
// Run with output capture to file using tee
if verbose {
eprintln!("Running: cargo bench --workspace --benches -- --output-format bencher");
}
let bench_output = cmd!(
sh,
"cargo bench --workspace --benches -- --output-format bencher"
)
.read()
.context("Benchmarks failed")?;
// Write to file
sh.write_file(output, &bench_output)?;
// Also print to stdout
println!("{bench_output}");
} else {
// Run without file output
if verbose {
eprintln!("Running: cargo bench --workspace --benches");
}
cmd!(sh, "cargo bench --workspace --benches")
.run()
.context("Benchmarks failed")?;
}
eprintln!("Benchmarks completed.");
Ok(())
}
/// Analyze public API changes between the current branch and a base branch.
///
/// This uses `cargo-public-api` to generate diffs for each library crate,
/// then formats them into markdown reports using a Python script.
/// Requires nightly Rust and `cargo-public-api` to be installed.
fn run_public_api(sh: &Shell, args: &PublicApiArgs, verbose: bool) -> Result<()> {
eprintln!("Analyzing public API against {}...", args.base);
// Ensure cargo-public-api is available
if verbose {
eprintln!("Running: cargo +nightly public-api --version");
}
cmd!(sh, "cargo +nightly public-api --version")
.run()
.context("cargo-public-api not installed. Install with: cargo install cargo-public-api")?;
// Create output directories
let diffs_dir = args.output_dir.join("diffs");
let reports_dir = args.output_dir.clone();
sh.create_dir(&diffs_dir)?;
sh.create_dir(&reports_dir)?;
// Get list of library crates
if verbose {
eprintln!("Running: ./scripts/enum-lib-crates.sh");
}
let crates_output = cmd!(sh, "./scripts/enum-lib-crates.sh")
.read()
.context("Failed to enumerate library crates")?;
let crates: Vec<&str> = crates_output.lines().collect();
if crates.is_empty() {
eprintln!("No library crates found.");
return Ok(());
}
let base = &args.base;
// Track failures to report at the end
let mut failed_crates: Vec<String> = Vec::new();
for crate_name in &crates {
eprintln!("Analyzing crate: {crate_name}");
let diff_file = diffs_dir.join(format!("{crate_name}.txt"));
let report_file = reports_dir.join(format!("api-diff-{crate_name}.md"));
let diff_path = diff_file.display().to_string();
let report_path = report_file.display().to_string();
// Run public-api diff.
// The -sss flags are shorthand for three levels of --simplified output,
// which suppresses less relevant API details (blanket impls, auto traits, etc.)
// to focus on the most important public API changes.
// We use ignore_status() because diff returns non-zero if there are differences.
if verbose {
eprintln!("Running: cargo +nightly public-api diff -sss -p {crate_name} {base}..HEAD");
}
let diff_result = cmd!(
sh,
"cargo +nightly public-api diff -sss -p {crate_name} {base}..HEAD"
)
.ignore_status()
.read();
match diff_result {
Ok(diff_output) => {
sh.write_file(&diff_file, &diff_output)?;
// Format the report using the Python script
if verbose {
eprintln!(
"Running: ./scripts/format-api-diff-report.py {diff_path} -p {crate_name}"
);
}
let format_result = cmd!(
sh,
"./scripts/format-api-diff-report.py {diff_path} -p {crate_name}"
)
.read();
match format_result {
Ok(report) => {
if report.trim().is_empty() {
eprintln!(" No API changes for {crate_name}");
} else {
sh.write_file(&report_file, &report)?;
eprintln!(" Report written to {report_path}");
}
}
Err(e) => {
eprintln!(" Error: Failed to format report for {crate_name}: {e}");
failed_crates.push((*crate_name).to_string());
}
}
}
Err(e) => {
eprintln!(" Error: Failed to analyze {crate_name}: {e}");
failed_crates.push((*crate_name).to_string());
}
}
}
eprintln!(
"Public API analysis complete. Reports in: {}",
reports_dir.display()
);
// Return error if any crates failed
if !failed_crates.is_empty() {
anyhow::bail!(
"Public API analysis failed for: {}",
failed_crates.join(", ")
);
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
//! Check commands for code quality validation.
//!
//! This module provides various code quality checks that can be run individually
//! or as part of a CI workflow. Each check wraps an external tool and provides
//! consistent error handling and verbose output.
//!
//! Some checks require additional tools to be installed:
//! - `cargo-deny`: Security/license auditing (`cargo install cargo-deny`)
//! - `cargo-udeps`: Unused dependency detection (`cargo install cargo-udeps`, requires nightly)
//! - `cargo-public-api`: Public API analysis (`cargo install cargo-public-api`, requires nightly)
//! - `typos`: Spelling checker (`cargo install typos-cli`)
//! - `zizmor`: GitHub workflow security scanner (`pip install zizmor`)
//! - `lychee`: Link checker (`cargo install lychee`)
use anyhow::{Context, Result};
use clap::Parser;
use xshell::{Shell, cmd};
/// Run code quality checks.
#[derive(Parser)]
pub enum CheckCommand {
/// Check that the code compiles.
Build,
/// Check dependencies for security vulnerabilities and license compliance.
Deps,
/// Check code formatting.
Fmt,
/// Check for broken links in documentation.
Links,
/// Run clippy lints.
Lint,
/// Analyze public API for breaking changes (requires nightly).
PublicApi,
/// Check that generated schemas are up-to-date.
Schemas,
/// Check for spelling errors.
Spelling,
/// Check for unused dependencies (requires nightly).
UnusedDeps,
/// Check GitHub workflow files for security issues.
Workflows,
}
/// Run a check command.
pub fn run(cmd: &CheckCommand, verbose: bool) -> Result<()> {
let sh = Shell::new()?;
match cmd {
CheckCommand::Fmt => check_fmt(&sh, verbose),
CheckCommand::Lint => check_lint(&sh, verbose),
CheckCommand::Deps => check_deps(&sh, verbose),
CheckCommand::UnusedDeps => check_unused_deps(&sh, verbose),
CheckCommand::Build => check_build(&sh, verbose),
CheckCommand::Schemas => check_schemas(&sh, verbose),
CheckCommand::PublicApi => check_public_api(&sh, verbose),
CheckCommand::Spelling => check_spelling(&sh, verbose),
CheckCommand::Workflows => check_workflows(&sh, verbose),
CheckCommand::Links => check_links(&sh, verbose),
}
}
fn check_fmt(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking code formatting...");
if verbose {
eprintln!("Running: cargo fmt --check --all");
}
cmd!(sh, "cargo fmt --check --all")
.run()
.context("Format check failed")?;
eprintln!("Format check passed.");
Ok(())
}
fn check_lint(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Running clippy...");
let mut args = vec!["clippy", "--workspace", "--all-features", "--all-targets"];
if verbose {
args.push("--verbose");
eprintln!("Running: cargo {}", args.join(" "));
}
cmd!(sh, "cargo {args...}")
.run()
.context("Clippy check failed")?;
eprintln!("Clippy check passed.");
Ok(())
}
fn check_deps(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking dependencies...");
if verbose {
eprintln!("Running: cargo deny --all-features check all");
}
cmd!(sh, "cargo deny --all-features check all")
.run()
.context("Dependency check failed")?;
eprintln!("Dependency check passed.");
Ok(())
}
fn check_unused_deps(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking for unused dependencies (requires nightly)...");
if verbose {
eprintln!("Running: cargo +nightly udeps --workspace --all-targets --all-features");
}
cmd!(
sh,
"cargo +nightly udeps --workspace --all-targets --all-features"
)
.run()
.context("Unused dependency check failed")?;
eprintln!("Unused dependency check passed.");
Ok(())
}
fn check_build(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking that code compiles...");
let mut args = vec!["check", "--all-features", "--all-targets", "--workspace"];
if verbose {
args.push("--verbose");
eprintln!("Running: cargo {}", args.join(" "));
}
cmd!(sh, "cargo {args...}")
.run()
.context("Build check failed")?;
eprintln!("Build check passed.");
Ok(())
}
fn check_schemas(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking generated schemas...");
// Regenerate schemas to a temporary state to compare against committed versions.
if verbose {
eprintln!(
"Running: cargo run --package xtask -- gen schema config --out schemas/config.schema.json"
);
}
cmd!(
sh,
"cargo run --package xtask -- gen schema config --out schemas/config.schema.json"
)
.run()
.context("Failed to regenerate schemas")?;
// Check for drift by capturing the diff output.
// We don't use --exit-code here because we want to capture and display the
// actual differences to help the user understand what changed.
if verbose {
eprintln!("Running: git diff schemas/");
}
let diff_output = cmd!(sh, "git diff schemas/")
.read()
.context("Failed to run git diff on schemas directory")?;
if !diff_output.is_empty() {
// Show the user exactly what changed so they can understand the drift.
eprintln!("\nSchema drift detected. The following changes were found:\n");
eprintln!("{diff_output}");
anyhow::bail!(
"Generated schemas are out of date. Please run 'cargo xtask gen schema config --out schemas/config.schema.json' and commit the changes."
);
}
eprintln!("Schema check passed.");
Ok(())
}
fn check_public_api(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Analyzing public API (requires nightly and cargo-public-api)...");
// This is typically only useful for PRs comparing against main
if verbose {
eprintln!("Running: cargo +nightly public-api --version");
}
cmd!(sh, "cargo +nightly public-api --version")
.run()
.context("cargo-public-api not installed. Install with: cargo install cargo-public-api")?;
eprintln!("Public API analysis complete. For PR diffs, compare against main branch.");
Ok(())
}
fn check_spelling(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking spelling...");
if verbose {
eprintln!("Running: typos");
}
cmd!(sh, "typos")
.run()
.context("Spelling check failed. Install typos with: cargo install typos-cli")?;
eprintln!("Spelling check passed.");
Ok(())
}
fn check_workflows(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking GitHub workflows for security issues...");
if verbose {
eprintln!("Running: zizmor .github/workflows/");
}
cmd!(sh, "zizmor .github/workflows/")
.run()
.context("Workflow check failed. Install zizmor with: pip install zizmor")?;
eprintln!("Workflow check passed.");
Ok(())
}
fn check_links(sh: &Shell, verbose: bool) -> Result<()> {
eprintln!("Checking for broken links...");
if verbose {
eprintln!("Running: lychee --offline docs/");
}
cmd!(sh, "lychee --offline docs/")
.run()
.context("Link check failed. Install lychee with: cargo install lychee")?;
eprintln!("Link check passed.");
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
//! CI workflow commands that aggregate multiple checks and tests.
//!
//! This module provides composite workflows that run multiple checks in sequence:
//!
//! ## Quick workflow (`cargo xtask ci quick`)
//!
//! Fast inner-loop checks (~7s warm cache) for rapid iteration:
//! 1. **Format check** - Fast, catches formatting issues early
//! 2. **Build check** - Ensures code compiles with all features
//! 3. **Lint check** - Clippy warnings that should be addressed
//! 4. **Unit tests** - Fast tests excluding integration test binaries
//!
//! ## Pre-commit workflow (`cargo xtask ci pre-commit`)
//!
//! Comprehensive validation (~45s warm cache) before committing:
//! 1. All quick workflow checks
//! 2. **Dependency check** - Security vulnerabilities and license compliance
//! 3. **Schema check** - Verifies generated schemas are up-to-date
//! 4. **Integration tests** - Full workspace tests including compat tests
//!
//! The ordering is intentional: fast checks run first to provide quick feedback,
//! with slower comprehensive tests running last.
use anyhow::Result;
use clap::Parser;
use crate::check::{self, CheckCommand};
use crate::test::{
self, BinaryArgs, IntegrationTestArgs, TestCommand, TestSubcommand, UnitTestArgs,
};
/// Type alias for a named step in a CI workflow.
type Step<'a> = (&'a str, Box<dyn Fn() -> Result<()> + 'a>);
/// Run CI workflows.
#[derive(Parser)]
pub enum CiCommand {
/// Run quick inner-loop checks: fmt, build, lint, unit tests (~7s warm).
///
/// Use this for rapid iteration during development.
Quick(QuickArgs),
/// Run full pre-commit workflow: quick + deps, schemas, integration tests (~45s warm).
///
/// This runs all essential checks that should pass before every commit.
/// Does not include: bench, links, public-api, spelling, unused-deps, workflows.
PreCommit(PreCommitArgs),
}
/// Arguments for quick workflow.
#[derive(Parser)]
pub struct QuickArgs {
/// Continue running checks even if one fails.
#[clap(short = 'k', long)]
continue_on_error: bool,
}
/// Arguments for pre-commit workflow.
#[derive(Parser)]
pub struct PreCommitArgs {
/// Continue running checks even if one fails.
#[clap(short = 'k', long)]
continue_on_error: bool,
}
/// Run a CI workflow command.
pub fn run(cmd: &CiCommand, verbose: bool) -> Result<()> {
match cmd {
CiCommand::Quick(args) => run_quick(args, verbose),
CiCommand::PreCommit(args) => run_pre_commit(args, verbose),
}
}
/// Create a `TestCommand` for unit tests.
fn make_unit_test_command() -> TestCommand {
TestCommand {
binary_args: BinaryArgs {
brush_path: None,
profile: crate::common::BuildProfile::Debug,
debug: false,
release: false,
},
subcommand: TestSubcommand::Unit(UnitTestArgs::default()),
}
}
/// Create a `TestCommand` for integration tests.
fn make_integration_test_command() -> TestCommand {
TestCommand {
binary_args: BinaryArgs {
brush_path: None,
profile: crate::common::BuildProfile::Debug,
debug: false,
release: false,
},
subcommand: TestSubcommand::Integration(IntegrationTestArgs::default()),
}
}
/// Run quick inner-loop checks (~7s warm cache).
fn run_quick(args: &QuickArgs, verbose: bool) -> Result<()> {
eprintln!("Running quick checks...\n");
let steps: Vec<Step<'_>> = vec![
(
"Format check",
Box::new(|| check::run(&CheckCommand::Fmt, verbose)),
),
(
"Build check",
Box::new(|| check::run(&CheckCommand::Build, verbose)),
),
(
"Lint check",
Box::new(|| check::run(&CheckCommand::Lint, verbose)),
),
(
"Unit tests",
Box::new(|| test::run(&make_unit_test_command(), verbose)),
),
];
run_steps(&steps, args.continue_on_error, "Quick checks")
}
fn run_pre_commit(args: &PreCommitArgs, verbose: bool) -> Result<()> {
eprintln!("Running pre-commit checks...\n");
let steps: Vec<Step<'_>> = vec![
// Quick checks first
(
"Format check",
Box::new(|| check::run(&CheckCommand::Fmt, verbose)),
),
(
"Build check",
Box::new(|| check::run(&CheckCommand::Build, verbose)),
),
(
"Lint check",
Box::new(|| check::run(&CheckCommand::Lint, verbose)),
),
(
"Unit tests",
Box::new(|| test::run(&make_unit_test_command(), verbose)),
),
// Additional pre-commit checks
(
"Dependency check",
Box::new(|| check::run(&CheckCommand::Deps, verbose)),
),
(
"Schema check",
Box::new(|| check::run(&CheckCommand::Schemas, verbose)),
),
(
"Integration tests",
Box::new(|| test::run(&make_integration_test_command(), verbose)),
),
];
run_steps(&steps, args.continue_on_error, "Pre-commit checks")
}
/// Run a series of steps, optionally continuing on error.
fn run_steps(steps: &[Step<'_>], continue_on_error: bool, workflow_name: &str) -> Result<()> {
let mut failures: Vec<&str> = Vec::new();
for (name, step) in steps {
eprintln!("\n{}", "=".repeat(60));
eprintln!("Running: {name}");
eprintln!("{}\n", "=".repeat(60));
if let Err(e) = step() {
eprintln!("\n❌ {name} failed: {e}");
if continue_on_error {
failures.push(name);
} else {
return Err(e);
}
} else {
eprintln!("\n✅ {name} passed");
}
}
if !failures.is_empty() {
eprintln!("\n{}", "=".repeat(60));
eprintln!("{workflow_name} completed with failures:");
for name in &failures {
eprintln!(" ❌ {name}");
}
eprintln!("{}", "=".repeat(60));
anyhow::bail!("{} check(s) failed", failures.len());
}
eprintln!("\n{}", "=".repeat(60));
eprintln!("✅ All {workflow_name} passed!");
eprintln!("{}", "=".repeat(60));
Ok(())
}
+107
View File
@@ -0,0 +1,107 @@
//! Common utilities shared across xtask commands.
//!
//! This module provides shared functionality for:
//! - Build profile selection (debug vs release)
//! - Workspace root discovery
//! - Brush binary location for test commands
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::ValueEnum;
/// Build profile for selecting which binary to use.
#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq, Eq)]
pub enum BuildProfile {
/// Debug build (target/debug/).
#[default]
Debug,
/// Release build (target/release/).
Release,
}
impl BuildProfile {
/// Returns the target subdirectory name for this profile.
#[must_use]
pub const fn target_dir_name(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Release => "release",
}
}
}
/// Find the workspace root directory.
///
/// This walks up from the xtask crate directory to find the workspace root
/// (the directory containing the top-level Cargo.toml with [workspace]).
pub fn find_workspace_root() -> Result<PathBuf> {
// Start from the xtask crate directory
let xtask_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// The workspace root is the parent of xtask/
let workspace_root = xtask_dir
.parent()
.context("Failed to find workspace root (parent of xtask)")?;
Ok(workspace_root.to_path_buf())
}
/// Find the brush binary path for the given build profile.
///
/// If `override_path` is provided, it is used directly (after validation).
/// Otherwise, the binary is located in the workspace's target directory
/// based on the specified profile.
pub fn find_brush_binary(
override_path: Option<&PathBuf>,
profile: BuildProfile,
) -> Result<PathBuf> {
let binary_path = if let Some(path) = override_path {
path.clone()
} else {
let workspace_root = find_workspace_root()?;
let binary_name = if cfg!(windows) { "brush.exe" } else { "brush" };
workspace_root
.join("target")
.join(profile.target_dir_name())
.join(binary_name)
};
// Canonicalize to get absolute path and verify existence
let canonical_path = binary_path.canonicalize().with_context(|| {
format!(
"Brush binary not found at: {} (profile: {:?}). Did you run `cargo build{}`?",
binary_path.display(),
profile,
if profile == BuildProfile::Release {
" --release"
} else {
""
}
)
})?;
Ok(canonical_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profile_dir_names() {
assert_eq!(BuildProfile::Debug.target_dir_name(), "debug");
assert_eq!(BuildProfile::Release.target_dir_name(), "release");
}
#[test]
fn test_find_workspace_root() {
let root = find_workspace_root();
assert!(root.is_ok(), "Should find workspace root");
let root = root.unwrap();
// The workspace root should contain Cargo.toml
assert!(root.join("Cargo.toml").exists());
// And it should contain the xtask directory
assert!(root.join("xtask").exists());
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Generation commands for documentation, completions, and schemas.
//!
//! This module provides commands for generating various artifacts:
//! - **Documentation**: Man pages and markdown help text from clap definitions
//! - **Completions**: Shell completion scripts for bash, zsh, fish, etc.
//! - **Schemas**: JSON schemas for configuration files
//! - **Distribution archives**: Reproducible documentation bundles with checksums
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser};
use xshell::{Shell, cmd};
/// Generate various artifacts.
#[derive(Parser)]
pub enum GenCommand {
/// Generate completion scripts.
#[clap(subcommand)]
Completion(CompletionCommand),
/// Generate documentation.
#[clap(subcommand)]
Docs(DocsCommand),
/// Generate JSON schemas.
#[clap(subcommand)]
Schema(SchemaCommand),
}
/// Documentation generation commands.
#[derive(Parser)]
pub enum DocsCommand {
/// Generate man content.
Man(GenerateManArgs),
/// Generate help content in markdown format.
Markdown(GenerateMarkdownArgs),
/// Generate a reproducible documentation distribution archive with checksums.
Dist(GenerateDistArgs),
}
/// Completion script generation commands.
#[derive(Parser)]
pub enum CompletionCommand {
/// Generate completion script for `bash`.
Bash,
/// Generate completion script for `elvish`.
Elvish,
/// Generate completion script for `fish`.
Fish,
/// Generate completion script for `PowerShell`.
PowerShell,
/// Generate completion script for `zsh`.
Zsh,
}
/// Arguments for man page generation.
#[derive(Parser)]
pub struct GenerateManArgs {
/// Output directory.
#[clap(long = "output-dir", short = 'o')]
output_dir: PathBuf,
}
/// Arguments for markdown documentation generation.
#[derive(Parser)]
pub struct GenerateMarkdownArgs {
/// Output file path.
#[clap(long = "out", short = 'o')]
output_path: PathBuf,
}
/// Arguments for documentation distribution generation.
#[derive(Parser)]
pub struct GenerateDistArgs {
/// Output file path for the distribution archive (defaults to brush-docs.tar.gz).
#[clap(long = "out", short = 'o', default_value = "brush-docs.tar.gz")]
output_path: PathBuf,
/// Generate SHA-256 checksum file alongside the distribution archive.
#[clap(long, default_value_t = true)]
sha256: bool,
/// Generate SHA-512 checksum file alongside the distribution archive.
#[clap(long, default_value_t = true)]
sha512: bool,
}
/// Schema generation commands.
#[derive(Parser)]
pub enum SchemaCommand {
/// Generate JSON schema for the configuration file.
Config(GenerateSchemaArgs),
}
/// Arguments for schema generation.
#[derive(Parser)]
pub struct GenerateSchemaArgs {
/// Output file path.
#[clap(long = "out", short = 'o')]
output_path: PathBuf,
}
/// Run a generation command.
pub fn run(cmd: &GenCommand, verbose: bool) -> Result<()> {
match cmd {
GenCommand::Docs(docs_cmd) => match docs_cmd {
DocsCommand::Man(args) => gen_man(args, verbose),
DocsCommand::Markdown(args) => gen_markdown_docs(args, verbose),
DocsCommand::Dist(args) => gen_docs_dist(args, verbose),
},
GenCommand::Completion(completion_cmd) => {
let shell = match completion_cmd {
CompletionCommand::Bash => clap_complete::Shell::Bash,
CompletionCommand::Elvish => clap_complete::Shell::Elvish,
CompletionCommand::Fish => clap_complete::Shell::Fish,
CompletionCommand::PowerShell => clap_complete::Shell::PowerShell,
CompletionCommand::Zsh => clap_complete::Shell::Zsh,
};
gen_completion_script(shell, verbose);
Ok(())
}
GenCommand::Schema(schema_cmd) => match schema_cmd {
SchemaCommand::Config(args) => gen_config_schema(args, verbose),
},
}
}
fn gen_man(args: &GenerateManArgs, verbose: bool) -> Result<()> {
if verbose {
eprintln!("Generating man pages to: {}", args.output_dir.display());
}
// Create the output dir if it doesn't exist. If it already does, we proceed
// onward and hope for the best.
if !args.output_dir.exists() {
std::fs::create_dir_all(&args.output_dir)?;
}
// Generate!
let cmd = brush_shell::args::CommandLineArgs::command();
clap_mangen::generate_to(cmd, &args.output_dir)?;
Ok(())
}
fn gen_markdown_docs(args: &GenerateMarkdownArgs, verbose: bool) -> Result<()> {
if verbose {
eprintln!(
"Generating markdown docs to: {}",
args.output_path.display()
);
}
let options = clap_markdown::MarkdownOptions::new()
.show_footer(false)
.show_table_of_contents(true);
// Generate!
let markdown =
clap_markdown::help_markdown_custom::<brush_shell::args::CommandLineArgs>(&options);
std::fs::write(&args.output_path, markdown)?;
Ok(())
}
/// Generate a shell completion script to stdout.
///
/// The completion script is written directly to stdout so it can be piped
/// to a file or sourced directly by the shell.
fn gen_completion_script(shell: clap_complete::Shell, verbose: bool) {
if verbose {
eprintln!("Generating {shell} completion script...");
}
let mut cmd = brush_shell::args::CommandLineArgs::command();
clap_complete::generate(shell, &mut cmd, "brush", &mut std::io::stdout());
}
fn gen_config_schema(args: &GenerateSchemaArgs, verbose: bool) -> Result<()> {
if verbose {
eprintln!(
"Generating config schema to: {}",
args.output_path.display()
);
}
// Generate JSON schema for the configuration file.
let schema = schemars::schema_for!(brush_shell::config::Config);
let json = serde_json::to_string_pretty(&schema)?;
std::fs::write(&args.output_path, format!("{json}\n"))?;
Ok(())
}
fn gen_docs_dist(args: &GenerateDistArgs, verbose: bool) -> Result<()> {
let sh = Shell::new()?;
// Create a temporary directory for staging the documentation
let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
let staging_dir = temp_dir.path();
let md_dir = staging_dir.join("md");
let man_dir = staging_dir.join("man");
std::fs::create_dir_all(&md_dir)?;
std::fs::create_dir_all(&man_dir)?;
if verbose {
eprintln!("Staging documentation in: {}", staging_dir.display());
}
// Generate markdown documentation
let md_args = GenerateMarkdownArgs {
output_path: md_dir.join("brush.md"),
};
gen_markdown_docs(&md_args, verbose)?;
// Generate man pages
let man_args = GenerateManArgs {
output_dir: man_dir,
};
gen_man(&man_args, verbose)?;
// Get absolute path for output
let output_path = if args.output_path.is_absolute() {
args.output_path.clone()
} else {
std::env::current_dir()?.join(&args.output_path)
};
if verbose {
eprintln!(
"Creating reproducible distribution archive: {}",
output_path.display()
);
}
// Create reproducible distribution archive using tar with options for reproducibility:
// - --sort=name: Sort files by name for consistent ordering
// - --mtime: Set modification time to epoch for reproducibility
// - --owner=0 --group=0: Remove user/group ownership info
// - --numeric-owner: Use numeric IDs
// - --pax-option: Remove atime/ctime from PAX headers
let output_path_str = output_path.display().to_string();
// Change to staging directory and create archive
let dir_guard = sh.push_dir(staging_dir);
cmd!(
sh,
"tar --sort=name --mtime=1970-01-01T00:00:00Z --owner=0 --group=0 --numeric-owner --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime -czf {output_path_str} ."
)
.run()
.context("Failed to create distribution archive")?;
eprintln!("Created: {}", output_path.display());
// Generate checksums
drop(dir_guard);
if args.sha256 {
let checksum_path = format!("{}.sha256", output_path.display());
let checksum = cmd!(sh, "sha256sum {output_path_str}")
.read()
.context("Failed to generate SHA-256 checksum")?;
std::fs::write(&checksum_path, format!("{checksum}\n"))?;
if verbose {
eprintln!("Created: {checksum_path}");
}
}
if args.sha512 {
let checksum_path = format!("{}.sha512", output_path.display());
let checksum = cmd!(sh, "sha512sum {output_path_str}")
.read()
.context("Failed to generate SHA-512 checksum")?;
std::fs::write(&checksum_path, format!("{checksum}\n"))?;
if verbose {
eprintln!("Created: {checksum_path}");
}
}
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
//! xtask-style command-line tool for building this project.
mod analyze;
#[cfg(unix)]
mod bash_tests;
mod check;
mod ci;
mod common;
mod generate;
mod test;
use anyhow::Result;
use clap::Parser;
/// Global options shared across all commands.
#[derive(Parser, Debug, Clone, Copy)]
pub struct GlobalArgs {
/// Enable verbose output.
#[clap(long, short = 'v', global = true)]
pub verbose: bool,
}
#[derive(Parser)]
#[clap(name = "xtask", about = "Build automation tasks for brush")]
struct CommandLineArgs {
#[clap(flatten)]
global: GlobalArgs,
#[clap(subcommand)]
command: Command,
}
#[derive(Parser)]
enum Command {
/// Run analysis tasks (benchmarks, public API diffing).
#[clap(subcommand)]
Analyze(analyze::AnalyzeCommand),
/// Run code quality checks.
#[clap(subcommand)]
Check(check::CheckCommand),
/// Run CI workflows.
#[clap(subcommand)]
Ci(ci::CiCommand),
/// Generate documentation, completions, and schemas.
#[clap(subcommand)]
Gen(generate::GenCommand),
/// Run tests.
Test(Box<test::TestCommand>),
}
fn main() -> Result<()> {
let args = CommandLineArgs::parse();
let verbose = args.global.verbose;
match &args.command {
Command::Analyze(cmd) => analyze::run(cmd, verbose),
Command::Gen(cmd) => generate::run(cmd, verbose),
Command::Check(cmd) => check::run(cmd, verbose),
Command::Test(cmd) => test::run(cmd, verbose),
Command::Ci(cmd) => ci::run(cmd, verbose),
}
}
+755
View File
@@ -0,0 +1,755 @@
//! Test commands for running various test suites.
//!
//! This module provides commands for running different types of tests:
//!
//! - **Unit tests**: Fast tests that don't execute the brush binary (excludes integration test
//! binaries like brush-compat-tests, brush-interactive-tests, brush-completion-tests)
//! - **Integration tests**: All workspace tests including unit tests and integration tests that
//! execute the brush binary
//! - **External suites**: Third-party test suites like bash-completion
//!
//! Both unit and integration tests support optional coverage collection via
//! `cargo-llvm-cov`.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use xshell::{Shell, cmd};
use crate::common::{BuildProfile, find_brush_binary, find_workspace_root};
/// Integration test binaries that are excluded from unit tests.
/// These tests execute the brush binary and are slower.
const INTEGRATION_TEST_BINARIES: &[&str] = &[
"brush-compat-tests",
"brush-interactive-tests",
"brush-completion-tests",
];
#[cfg(windows)]
const TEST_BINARIES_DISABLED_ON_WINDOWS: &[&str] = &["brush-compat-tests"];
/// Shared arguments for test commands that need a brush binary.
#[derive(Args, Debug, Clone)]
pub struct BinaryArgs {
/// Path to the brush binary to test. If not specified, uses the binary
/// from the workspace's target directory based on --profile/--debug/--release.
#[clap(long, global = true)]
pub brush_path: Option<PathBuf>,
/// Build profile to use when auto-detecting the brush binary.
#[clap(long, short = 'p', value_enum, default_value_t = BuildProfile::Debug, global = true)]
pub profile: BuildProfile,
/// Use debug build profile (shorthand for --profile=debug).
#[clap(long, conflicts_with_all = ["profile", "release"], global = true)]
pub debug: bool,
/// Use release build profile (shorthand for --profile=release).
#[clap(long, conflicts_with_all = ["profile", "debug"], global = true)]
pub release: bool,
}
impl BinaryArgs {
/// Resolve the effective build profile, considering --debug/--release shorthands.
#[must_use]
pub const fn effective_profile(&self) -> BuildProfile {
if self.debug {
BuildProfile::Debug
} else if self.release {
BuildProfile::Release
} else {
self.profile
}
}
/// Find the brush binary using these arguments.
pub fn find_brush_binary(&self) -> Result<PathBuf> {
find_brush_binary(self.brush_path.as_ref(), self.effective_profile())
}
}
/// Run tests.
#[derive(Parser)]
pub struct TestCommand {
/// Shared binary arguments.
#[clap(flatten)]
pub binary_args: BinaryArgs,
/// Test subcommand.
#[clap(subcommand)]
pub subcommand: TestSubcommand,
}
/// Test subcommands.
#[derive(Subcommand, Clone)]
pub enum TestSubcommand {
/// Run unit tests (fast tests that don't execute the brush binary).
///
/// Excludes integration test binaries: brush-compat-tests, brush-interactive-tests,
/// brush-completion-tests.
Unit(UnitTestArgs),
/// Run all workspace tests (unit + integration tests).
///
/// This includes all tests: unit tests plus integration tests that execute
/// the brush binary (compat tests, interactive tests, completion tests).
Integration(IntegrationTestArgs),
/// Run external test suites.
#[clap(subcommand)]
External(ExternalTestCommand),
}
/// Arguments for unit tests.
#[derive(Args, Clone, Default)]
pub struct UnitTestArgs {
/// Coverage options.
#[clap(flatten)]
pub coverage: CoverageArgs,
}
/// Arguments for integration tests.
#[derive(Args, Clone, Default)]
pub struct IntegrationTestArgs {
/// Coverage options.
#[clap(flatten)]
pub coverage: CoverageArgs,
/// Copy the nextest `JUnit` XML results to this path after the test run.
/// The copy is performed even if tests fail, so CI can always upload results.
#[clap(long)]
pub results_output: Option<PathBuf>,
/// Build and test against a wasm32-wasip2 target under a WASI runtime
/// (wasmtime by default). Builds brush for wasm32-wasip2 with minimal
/// features, then runs a subset of integration tests (excluding compat,
/// interactive, and completion suites) under the WASI launcher.
#[clap(long)]
pub wasi: bool,
/// Launcher command for the WASI runtime (only used with --wasi).
/// The first token is resolved against `PATH`; subsequent tokens are
/// passed as leading arguments before the brush binary path.
/// Defaults to `wasmtime run --dir=.::/ --allow-precompiled --`.
#[clap(long)]
pub wasi_launcher: Option<String>,
/// Skip the WASI wasm build step and assume brush.wasm is already
/// present at the expected path (only used with --wasi).
#[clap(long)]
pub skip_wasi_build: bool,
}
/// Arguments for coverage collection.
#[derive(Args, Clone, Default)]
pub struct CoverageArgs {
/// Collect code coverage during test run.
#[clap(long)]
pub coverage: bool,
/// Output file for coverage report (Cobertura XML format).
/// Only used when --coverage is specified.
#[clap(long, short = 'o', default_value = "codecov.xml")]
pub coverage_output: PathBuf,
}
/// External test suite commands.
#[derive(Subcommand, Clone)]
pub enum ExternalTestCommand {
/// Run the bash-completion test suite against brush.
BashCompletion(BashCompletionArgs),
/// Run the upstream bash test suite against brush.
#[cfg(unix)]
BashTests(crate::bash_tests::BashTestsArgs),
}
/// Arguments for bash-completion test suite.
#[derive(Args, Clone)]
pub struct BashCompletionArgs {
/// Path to the bash-completion repository checkout.
#[clap(long)]
bash_completion_path: PathBuf,
/// List available tests without running them.
#[clap(long)]
list: bool,
/// Filter tests by name pattern (passed to pytest -k).
/// Supports pytest expression syntax, e.g., `"test_alias"`, `"test_alias and test_1"`.
#[clap(long, short = 't')]
test_filter: Option<String>,
/// Run only specific test file(s). Can be specified multiple times.
/// Example: `-f test_alias.py -f test_bash.py`
#[clap(long, short = 'f')]
file: Vec<String>,
/// Stop on first test failure.
#[clap(long, short = 'x')]
stop_on_first: bool,
/// Output file for JSON test results.
#[clap(long, short = 'o')]
output: Option<PathBuf>,
/// Output file for markdown summary report.
#[clap(long)]
summary_output: Option<PathBuf>,
/// Path to the summarize-pytest-results.py script (for generating summary).
/// Defaults to ./scripts/summarize-pytest-results.py relative to workspace root.
#[clap(long)]
summary_script: Option<PathBuf>,
/// Number of parallel test workers (requires pytest-xdist).
/// Use -j 1 to disable parallel execution.
#[clap(long, short = 'j', default_value = "128")]
jobs: u32,
}
/// Run a test command.
pub fn run(cmd: &TestCommand, verbose: bool) -> Result<()> {
let sh = Shell::new()?;
match &cmd.subcommand {
TestSubcommand::Unit(args) => run_unit_tests(&sh, &cmd.binary_args, args, verbose),
TestSubcommand::Integration(args) => {
run_integration_tests(&sh, &cmd.binary_args, args, verbose)
}
TestSubcommand::External(ext_cmd) => run_external(ext_cmd, &cmd.binary_args, &sh, verbose),
}
}
fn run_external(
cmd: &ExternalTestCommand,
binary_args: &BinaryArgs,
sh: &Shell,
verbose: bool,
) -> Result<()> {
match cmd {
ExternalTestCommand::BashCompletion(args) => {
run_bash_completion_tests(sh, args, binary_args, verbose)
}
#[cfg(unix)]
ExternalTestCommand::BashTests(args) => {
crate::bash_tests::run_bash_tests(args, binary_args, verbose)
}
}
}
/// Run unit tests (excludes integration test binaries).
///
/// Unit tests are fast tests that don't execute the brush binary.
pub fn run_unit_tests(
sh: &Shell,
binary_args: &BinaryArgs,
args: &UnitTestArgs,
verbose: bool,
) -> Result<()> {
let profile = binary_args.effective_profile();
eprintln!("Running unit tests ({profile:?} profile)...");
// Build the filter expression to exclude integration test binaries
let exclusions: Vec<String> = INTEGRATION_TEST_BINARIES
.iter()
.map(|name| format!("not binary({name})"))
.collect();
let filter_expr = exclusions.join(" and ");
if args.coverage.coverage {
run_tests_with_coverage(
sh,
profile,
Some(&filter_expr),
&args.coverage.coverage_output,
verbose,
)
} else {
run_nextest(sh, profile, Some(&filter_expr), verbose)?;
eprintln!("Unit tests passed.");
Ok(())
}
}
/// Run all workspace tests (unit + integration).
///
/// This runs all tests in the workspace, including integration tests
/// that execute the brush binary. With `--wasi`, builds brush for
/// wasm32-wasip2 and runs the integration tests under a WASI runtime.
pub fn run_integration_tests(
sh: &Shell,
binary_args: &BinaryArgs,
args: &IntegrationTestArgs,
verbose: bool,
) -> Result<()> {
let profile = binary_args.effective_profile();
if args.wasi {
if args.coverage.coverage {
eprintln!("Warning: --coverage is not supported with --wasi and will be ignored.");
}
return run_integration_tests_wasi(sh, profile, args, verbose);
}
eprintln!("Running integration tests ({profile:?} profile)...");
#[cfg(windows)]
let exclusions: Vec<String> = TEST_BINARIES_DISABLED_ON_WINDOWS
.iter()
.map(|name| format!("not binary({name})"))
.collect();
#[cfg(windows)]
let filter_expr = exclusions.join(" and ");
#[cfg(windows)]
let filter = Some(filter_expr.as_str());
#[cfg(not(windows))]
let filter = None;
let test_result = if args.coverage.coverage {
run_tests_with_coverage(sh, profile, filter, &args.coverage.coverage_output, verbose)
} else {
run_nextest(sh, profile, filter, verbose).map(|()| {
eprintln!("Integration tests passed.");
})
};
// Copy nextest results if requested (even on test failure, so CI can upload them).
if let Some(ref output) = args.results_output {
copy_nextest_results(output)?;
}
test_result
}
/// Run the brush integration tests against a wasm32-wasip2 build of brush,
/// executed under a WASI runtime. Builds the wasm module first unless
/// `--skip-wasi-build` is given, then runs the integration tests via nextest
/// with the appropriate environment variables populated for the test harness.
fn run_integration_tests_wasi(
sh: &Shell,
profile: BuildProfile,
args: &IntegrationTestArgs,
verbose: bool,
) -> Result<()> {
let is_release = profile == BuildProfile::Release;
// Build the wasm module unless the caller opts out.
if !args.skip_wasi_build {
eprintln!("Building brush for wasm32-wasip2...");
let mut build_args = vec![
"build",
"--target",
"wasm32-wasip2",
"-p",
"brush-shell",
"--bin",
"brush",
"--no-default-features",
"--features",
"minimal",
];
if is_release {
build_args.push("--release");
}
if verbose {
eprintln!("Running: cargo {}", build_args.join(" "));
}
cmd!(sh, "cargo {build_args...}")
.run()
.context("failed to build brush for wasm32-wasip2")?;
}
// Locate the wasm module that was (or should have been) produced.
let workspace_root = find_workspace_root()?;
let profile_dir = if is_release { "release" } else { "debug" };
let wasm_path = workspace_root
.join("target/wasm32-wasip2")
.join(profile_dir)
.join("brush.wasm");
let wasm_path = wasm_path.canonicalize().with_context(|| {
format!(
"brush.wasm not found at {} — did the build step succeed?",
wasm_path.display()
)
})?;
// The `--dir=.::/` flag maps the host root filesystem into the WASI
// sandbox. This is intentionally permissive for testing — tests create
// temp dirs and need access to fixtures across the filesystem. This is
// NOT a recommended default for production use of brush under WASI.
// Pre-compile the wasm module to avoid JIT compilation overhead during
// parallel test execution. Without this, multiple concurrent wasmtime
// processes each try to JIT-compile brush.wasm simultaneously, which
// can exceed test timeouts on CI.
eprintln!("Pre-compiling brush.wasm...");
let cwasm_path = wasm_path.with_extension("cwasm");
{
let wasm_arg = wasm_path.display().to_string();
let cwasm_arg = cwasm_path.display().to_string();
cmd!(sh, "wasmtime compile {wasm_arg} -o {cwasm_arg}")
.run()
.context("failed to pre-compile brush.wasm with wasmtime")?;
}
// The default launcher includes --allow-precompiled so wasmtime accepts
// the AOT-compiled .cwasm module without re-compilation.
let launcher = args
.wasi_launcher
.as_deref()
.unwrap_or("wasmtime run --dir=.::/ --allow-precompiled --");
eprintln!("Running brush integration tests under WASI...");
eprintln!(" wasm: {}", cwasm_path.display());
eprintln!(" launcher: {launcher}");
let brush_path_str = cwasm_path.display().to_string();
let _brush_path = sh.push_env("BRUSH_PATH", &brush_path_str);
let _brush_launcher = sh.push_env("BRUSH_LAUNCHER", launcher);
let _brush_platform_tags = sh.push_env("BRUSH_PLATFORM_TAGS", "wasi wasm");
// Only run the brush integration tests; compat tests require a native binary.
let filter = "binary(brush-integration-tests)";
let test_result = run_nextest(sh, profile, Some(filter), verbose);
// Copy nextest results if requested (even on test failure, so CI can upload them).
if let Some(ref output) = args.results_output {
copy_nextest_results(output)?;
}
test_result
}
/// Run cargo nextest with optional filter expression.
fn run_nextest(
sh: &Shell,
profile: BuildProfile,
filter_expr: Option<&str>,
verbose: bool,
) -> Result<()> {
let mut args = vec!["nextest", "run", "--workspace", "--no-fail-fast"];
if profile == BuildProfile::Release {
args.push("--release");
}
// Add filter expression if provided
let filter_value = filter_expr.map(str::to_string);
if let Some(ref value) = filter_value {
args.push("-E");
args.push(value);
}
if verbose {
eprintln!("Running: cargo {}", args.join(" "));
}
cmd!(sh, "cargo {args...}").run().context("Tests failed")?;
Ok(())
}
/// Copy the nextest `JUnit` XML results to the given output path.
fn copy_nextest_results(output: &Path) -> Result<()> {
let workspace_root = find_workspace_root()?;
let source = workspace_root.join("target/nextest/default/test-results.xml");
std::fs::copy(&source, output).with_context(|| {
format!(
"Failed to copy nextest results from {} to {}",
source.display(),
output.display()
)
})?;
eprintln!("Nextest results copied to: {}", output.display());
Ok(())
}
/// Run tests with code coverage collection using `cargo-llvm-cov`.
///
/// The coverage workflow:
/// 1. Source environment variables from `cargo llvm-cov show-env`
/// 2. Clean previous coverage data
/// 3. Run tests (continuing even if tests fail to still generate report)
/// 4. Generate Cobertura XML report for CI integration
///
/// Requires `cargo-llvm-cov` to be installed: `cargo install cargo-llvm-cov`
fn run_tests_with_coverage(
sh: &Shell,
profile: BuildProfile,
filter_expr: Option<&str>,
output: &Path,
verbose: bool,
) -> Result<()> {
let output_path = output.display().to_string();
eprintln!("Running tests with coverage ({profile:?} profile)...");
eprintln!("Coverage output: {output_path}");
// Set up llvm-cov environment
eprintln!("Setting up llvm-cov environment...");
if verbose {
eprintln!("Running: cargo llvm-cov show-env --export-prefix");
}
let env_output = cmd!(sh, "cargo llvm-cov show-env --export-prefix")
.read()
.context("Failed to get llvm-cov environment. Is cargo-llvm-cov installed?")?;
// Parse and set environment variables from llvm-cov output
env_output
.lines()
.filter_map(|line| line.strip_prefix("export "))
.filter_map(|rest| rest.split_once('='))
.for_each(|(k, v)| sh.set_var(k, v.trim_matches(['"', '\''])));
// Clean previous coverage data
if verbose {
eprintln!("Running: cargo llvm-cov clean --workspace");
}
cmd!(sh, "cargo llvm-cov clean --workspace")
.run()
.context("Failed to clean coverage data")?;
// Build cargo nextest args
let mut test_args = vec!["nextest", "run", "--workspace", "--no-fail-fast"];
if profile == BuildProfile::Release {
test_args.push("--release");
}
// Add filter expression if provided
let filter_value = filter_expr.map(str::to_string);
if let Some(ref value) = filter_value {
test_args.push("-E");
test_args.push(value);
}
if verbose {
eprintln!("Running: cargo {}", test_args.join(" "));
}
// Run tests - let output pass through naturally, but continue on failure to generate coverage
// report
let test_result = cmd!(sh, "cargo {test_args...}").run();
let test_failed = test_result.is_err();
if test_failed {
eprintln!("Tests failed, but continuing to generate coverage report...");
}
// Generate coverage report (always attempt this)
eprintln!("Generating coverage report...");
if verbose {
eprintln!("Running: cargo llvm-cov report --cobertura --output-path {output_path}");
}
cmd!(
sh,
"cargo llvm-cov report --cobertura --output-path {output_path}"
)
.run()
.context("Failed to generate coverage report")?;
eprintln!("Coverage report written to: {output_path}");
// Now propagate test failure if tests failed
if test_failed {
anyhow::bail!("Tests failed (coverage report was still generated)");
}
eprintln!("Tests with coverage completed successfully.");
Ok(())
}
/// List available bash-completion tests without running them.
fn list_bash_completion_tests(sh: &Shell, args: &BashCompletionArgs, verbose: bool) -> Result<()> {
eprintln!("Collecting bash-completion tests...");
// Determine test targets - specific files or all tests
let test_targets: Vec<String> = if args.file.is_empty() {
vec!["./t".to_string()]
} else {
args.file
.iter()
.map(|f| {
if f.starts_with("./t/") || f.starts_with("t/") {
f.clone()
} else {
format!("./t/{f}")
}
})
.collect()
};
let mut pytest_args = vec!["--collect-only".to_string(), "-q".to_string()];
// Add test filter if specified
if let Some(filter) = &args.test_filter {
pytest_args.push("-k".to_string());
pytest_args.push(filter.clone());
}
// Add test targets
pytest_args.extend(test_targets);
if verbose {
eprintln!("Running: pytest {}", pytest_args.join(" "));
}
// Run pytest --collect-only and display results
cmd!(sh, "pytest").args(&pytest_args).run()?;
Ok(())
}
/// Run the bash-completion project's test suite against brush.
///
/// This runs pytest on the bash-completion test suite with brush as the shell,
/// configured via the `BASH_COMPLETION_TEST_BASH` environment variable.
/// Results are output as JSON and optionally summarized to markdown.
///
/// Requires:
/// - A checkout of the bash-completion repository
/// - Python with pytest, pytest-xdist, and pytest-json-report installed
fn run_bash_completion_tests(
sh: &Shell,
args: &BashCompletionArgs,
binary_args: &BinaryArgs,
verbose: bool,
) -> Result<()> {
// Find the brush binary (use explicit path or auto-detect from target dir)
let brush_path = binary_args.find_brush_binary()?;
let test_dir = args.bash_completion_path.join("test");
if !test_dir.exists() {
anyhow::bail!(
"bash-completion test directory not found at: {}",
test_dir.display()
);
}
// Build the pytest command
let dir_guard = sh.push_dir(&test_dir);
// Set environment variable for the test suite
let brush_path_str = brush_path.display().to_string();
let _env = sh.push_env(
"BASH_COMPLETION_TEST_BASH",
format!("{brush_path_str} --noprofile --no-config --input-backend=basic"),
);
// Handle --list mode: just collect and display tests
if args.list {
return list_bash_completion_tests(sh, args, verbose);
}
eprintln!("Running bash-completion test suite...");
eprintln!("Using brush binary: {}", brush_path.display());
// Determine test targets - specific files or all tests
let test_targets: Vec<String> = if args.file.is_empty() {
vec!["./t".to_string()]
} else {
args.file
.iter()
.map(|f| {
if f.starts_with("./t/") || f.starts_with("t/") {
f.clone()
} else {
format!("./t/{f}")
}
})
.collect()
};
// Build pytest args
let mut pytest_args: Vec<String> = Vec::new();
// Add parallel execution flag if jobs > 1 (requires pytest-xdist)
if args.jobs > 1 {
pytest_args.push("-n".to_string());
pytest_args.push(args.jobs.to_string());
}
// Add JSON report if output is requested (requires pytest-json-report)
let json_output = args.output.as_ref().map(|p| p.display().to_string());
if let Some(ref output) = json_output {
pytest_args.push("--json-report".to_string());
pytest_args.push(format!("--json-report-file={output}"));
}
// Add optional flags
if verbose {
pytest_args.push("-v".to_string());
}
if args.stop_on_first {
pytest_args.push("-x".to_string());
}
if let Some(filter) = &args.test_filter {
pytest_args.push("-k".to_string());
pytest_args.push(filter.clone());
}
// Add test targets at the end
pytest_args.extend(test_targets);
if verbose {
eprintln!("Running: pytest {}", pytest_args.join(" "));
}
// Run pytest - pass stdout/stderr through directly, capture whether it failed.
let pytest_failed = cmd!(sh, "pytest").args(&pytest_args).run().is_err();
if pytest_failed {
eprintln!("Some tests failed, but continuing to generate reports...");
}
// Generate summary report if requested (requires JSON output)
if let (Some(summary_path), Some(output)) = (&args.summary_output, &json_output) {
// Get workspace root for script path resolution
let workspace_root = find_workspace_root()?;
let summary_path_str = summary_path.display().to_string();
// Determine the script path - use provided path or default to workspace root
let script_path = args
.summary_script
.clone()
.unwrap_or_else(|| workspace_root.join("scripts/summarize-pytest-results.py"));
let script_path_str = script_path.display().to_string();
// Go back to original directory for the script (if we were in test dir)
drop(dir_guard);
let title = "Test Summary: bash-completion test suite";
if verbose {
eprintln!("Running: python3 {script_path_str} -r {output} --title \"{title}\"");
}
let summary_result = cmd!(sh, "python3 {script_path_str}")
.args(["-r", output, "--title", title])
.read();
match summary_result {
Ok(summary) => {
sh.write_file(summary_path, &summary)?;
eprintln!("Summary report written to: {summary_path_str}");
}
Err(e) => {
eprintln!("Warning: Failed to generate summary report: {e}");
}
}
} else if args.summary_output.is_some() && json_output.is_none() {
eprintln!("Warning: --summary-output requires --output for JSON results");
}
eprintln!("bash-completion test suite completed.");
if let Some(ref output) = json_output {
eprintln!("Results written to: {output}");
}
// Propagate test failure after reports are generated
if pytest_failed {
anyhow::bail!("bash-completion tests failed (reports were still generated)");
}
Ok(())
}