Merge nucleic/sleek-thistle-egret-fyej into dev
This commit is contained in:
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user