diff options
Diffstat (limited to 'mingling_ci/src/task')
| -rw-r--r-- | mingling_ci/src/task/cmd_build_check.rs | 47 | ||||
| -rw-r--r-- | mingling_ci/src/task/cmd_clippy_check.rs | 50 | ||||
| -rw-r--r-- | mingling_ci/src/task/cmd_docs_check.rs | 44 | ||||
| -rw-r--r-- | mingling_ci/src/task/cmd_example_check.rs | 69 | ||||
| -rw-r--r-- | mingling_ci/src/task/cmd_markdown_check.rs | 192 | ||||
| -rw-r--r-- | mingling_ci/src/task/cmd_markdown_compare.rs | 221 | ||||
| -rw-r--r-- | mingling_ci/src/task/cmd_test.rs | 54 | ||||
| -rw-r--r-- | mingling_ci/src/task/run.rs | 114 |
8 files changed, 791 insertions, 0 deletions
diff --git a/mingling_ci/src/task/cmd_build_check.rs b/mingling_ci/src/task/cmd_build_check.rs new file mode 100644 index 0000000..f67fe2e --- /dev/null +++ b/mingling_ci/src/task/cmd_build_check.rs @@ -0,0 +1,47 @@ +use std::ffi::OsString; +use std::path::Path; + +use mingling::{ + Grouped, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::res::Manifests; +use crate::task::run::{location, run_parallel_checks}; + +#[command(node = "build-check")] +pub async fn build_check(manifests: &Manifests) -> Next { + let tasks = manifests + .package_dirs + .iter() + .map(|(name, path)| (name.clone(), location(path), build_args(path))) + .collect(); + let fail_count = run_parallel_checks("Build-Check", "Building", tasks).await; + ResultBuildCheck { fail_count }.to_chain() +} + +/// `cargo build --manifest-path <path>` +fn build_args(path: &Path) -> Vec<OsString> { + vec![ + "cargo".into(), + "build".into(), + "--manifest-path".into(), + path.as_os_str().to_os_string(), + ] +} + +/// Number of packages that failed to build. +#[derive(Grouped)] +pub struct ResultBuildCheck { + pub fail_count: usize, +} + +/// Silently sets a non-zero exit code when any build failed. +#[renderer(buffer)] +pub fn render_build_check(r: ResultBuildCheck, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} diff --git a/mingling_ci/src/task/cmd_clippy_check.rs b/mingling_ci/src/task/cmd_clippy_check.rs new file mode 100644 index 0000000..a0dd46e --- /dev/null +++ b/mingling_ci/src/task/cmd_clippy_check.rs @@ -0,0 +1,50 @@ +use std::ffi::OsString; +use std::path::Path; + +use mingling::{ + Grouped, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::res::Manifests; +use crate::task::run::{location, run_parallel_checks}; + +#[command(node = "clippy-check")] +pub async fn clippy_check(manifests: &Manifests) -> Next { + let tasks = manifests + .package_dirs + .iter() + .map(|(name, path)| (name.clone(), location(path), clippy_args(path))) + .collect(); + let fail_count = run_parallel_checks("Clippy-Check", "Clippy", tasks).await; + ResultClippyCheck { fail_count }.to_chain() +} + +/// `cargo clippy --manifest-path <path> -- -D warnings` +fn clippy_args(path: &Path) -> Vec<OsString> { + vec![ + "cargo".into(), + "clippy".into(), + "--manifest-path".into(), + path.as_os_str().to_os_string(), + "--".into(), + "-D".into(), + "warnings".into(), + ] +} + +/// Number of packages that failed clippy. +#[derive(Grouped)] +pub struct ResultClippyCheck { + pub fail_count: usize, +} + +/// Silently sets a non-zero exit code when any clippy check failed. +#[renderer(buffer)] +pub fn render_clippy_check(r: ResultClippyCheck, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} diff --git a/mingling_ci/src/task/cmd_docs_check.rs b/mingling_ci/src/task/cmd_docs_check.rs new file mode 100644 index 0000000..3a77d4d --- /dev/null +++ b/mingling_ci/src/task/cmd_docs_check.rs @@ -0,0 +1,44 @@ +use std::ffi::OsString; + +use mingling::{ + Grouped, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::res::ResFeatureList; +use crate::task::run::run_parallel_checks; + +#[command(node = "docs-check")] +pub async fn docs_check(features: &ResFeatureList) -> Next { + let args = vec![ + OsString::from("cargo"), + OsString::from("rustdoc"), + OsString::from("--features"), + OsString::from(features.list.join(",")), + OsString::from("-p"), + OsString::from("mingling"), + OsString::from("--"), + OsString::from("-D"), + OsString::from("warnings"), + ]; + let tasks = vec![("mingling".to_string(), "./mingling".to_string(), args)]; + let fail_count = run_parallel_checks("Docs-Check", "Docs", tasks).await; + + ResultDocsCheck { fail_count }.to_chain() +} + +/// Number of failed doc builds (0 or 1). +#[derive(Grouped)] +pub struct ResultDocsCheck { + pub fail_count: usize, +} + +/// Silently sets a non-zero exit code when the doc build failed. +#[renderer(buffer)] +pub fn render_docs_check(r: ResultDocsCheck, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} diff --git a/mingling_ci/src/task/cmd_example_check.rs b/mingling_ci/src/task/cmd_example_check.rs new file mode 100644 index 0000000..1b9f440 --- /dev/null +++ b/mingling_ci/src/task/cmd_example_check.rs @@ -0,0 +1,69 @@ +use colored::Colorize; +use mingling::{ + Grouped, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::examples::{check_example, load_test_configs}; +use crate::progress::task_progress_bar; +use crate::reporter::{self, ReportResult}; + +#[command(node = "example-check")] +pub async fn example_check() -> Next { + reporter::set_task("Example-Check"); + + let configs = load_test_configs(); + let total = configs.len(); + let pb = task_progress_bar(total, "Testing"); + pb.set_message("examples"); + + // One blocking task per example: build + run its test cases. + let mut handles = Vec::new(); + for example in configs { + handles.push(tokio::task::spawn_blocking(move || check_example(example))); + } + + let mut fail_count = 0; + for handle in handles { + let Ok(outcome) = handle.await else { + continue; + }; + pb.set_message(outcome.name.clone()); + pb.inc(1); + + if outcome.ok { + reporter::export(&outcome.name, &outcome.location, ReportResult::Ok); + } else { + fail_count += 1; + // Plain stderr: `pb.println` is swallowed on non-TTY (CI). + eprintln!(" {} {}", "failed".bright_red(), outcome.name); + eprintln!(" {}", outcome.output); + reporter::export( + &outcome.name, + &outcome.location, + ReportResult::Error(outcome.output), + ); + } + } + + pb.finish_and_clear(); + reporter::flush(); + + ResultExampleCheck { fail_count }.to_chain() +} + +/// Number of examples that failed to build or pass their tests. +#[derive(Grouped)] +pub struct ResultExampleCheck { + pub fail_count: usize, +} + +/// Silently sets a non-zero exit code when any example failed. +#[renderer(buffer)] +pub fn render_example_check(r: ResultExampleCheck, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} diff --git a/mingling_ci/src/task/cmd_markdown_check.rs b/mingling_ci/src/task/cmd_markdown_check.rs new file mode 100644 index 0000000..2408636 --- /dev/null +++ b/mingling_ci/src/task/cmd_markdown_check.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use just_fmt::snake_case; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::markdown::project::parse_markdown; +use crate::markdown::test::{MarkdownBlockOutcome, try_test_markdown_project}; +use crate::reporter::{self, ReportResult}; +use crate::res::{CargoError, MessagePrinter}; + +const VERIFIED_DOCS: &str = ".config/verified-docs.toml"; + +#[command(node = "markdown-check")] +pub async fn markdown_check(args: Vec<String>) -> Next { + let Some(path_str) = args.first() else { + return ErrorMarkdownArgs("missing <path> argument".to_string()).to_chain(); + }; + let path = + std::env::current_dir().map_or_else(|_| PathBuf::from(path_str), |cwd| cwd.join(path_str)); + if !path.is_file() { + return ErrorMarkdownArgs(format!("{} is not a file", path.display())).to_chain(); + } + let Ok(content) = std::fs::read_to_string(&path) else { + return ErrorMarkdownArgs(format!("failed to read {}", path.display())).to_chain(); + }; + + let location = path.to_string_lossy().into_owned(); + let item = format!("doc-{}", snake_case!(&stem_of(&path))); + reporter::set_task("Markdown-Check"); + + let projects = parse_markdown(&content, &location); + let outcomes = try_test_markdown_project(projects).await; + let file_info = HashMap::from([(location.clone(), (item, location))]); + let fail_count = report_files(&outcomes, &file_info); + reporter::flush(); + + ResultMarkdownCheck { fail_count }.to_chain() +} + +#[command(node = "markdown-check-all")] +pub async fn markdown_check_all() -> Next { + let Some(files) = verified_md_files() else { + return ErrorMarkdownConfig.to_chain(); + }; + reporter::set_task("Markdown-Check-All"); + + // Collect all projects; remember each file's report identity + // (`{key}-{snake_case(file_stem)}` -> location). + let mut projects = Vec::new(); + let mut file_info: HashMap<String, (String, String)> = HashMap::new(); + for (label, path) in files { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let file_name = path.file_name().unwrap().to_string_lossy(); + let source_file = format!("{label}/{file_name}"); + let item = format!("{label}-{}", snake_case!(&stem_of(&path))); + let location = path.to_string_lossy().into_owned(); + file_info.insert(source_file.clone(), (item, location)); + projects.extend(parse_markdown(&content, &source_file)); + } + + let outcomes = try_test_markdown_project(projects).await; + let fail_count = report_files(&outcomes, &file_info); + reporter::flush(); + + ResultMarkdownCheck { fail_count }.to_chain() +} + +/// The file name without extension, e.g. `README.md` → `README`. +pub(crate) fn stem_of(path: &Path) -> String { + path.file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() +} + +/// Exports one report entry per source file: `ok` when every block passed, +/// otherwise an error carrying the failed blocks' details. +fn report_files( + outcomes: &[MarkdownBlockOutcome], + file_info: &HashMap<String, (String, String)>, +) -> usize { + let mut by_file: HashMap<&str, (bool, Vec<String>)> = HashMap::new(); + for outcome in outcomes { + let (ok, outputs) = by_file + .entry(outcome.source_file.as_str()) + .or_insert((true, Vec::new())); + if !outcome.ok { + *ok = false; + outputs.push(format!( + "{}:{}:\n{}", + outcome.source_file, outcome.line, outcome.output + )); + } + } + + let mut fail_count = 0; + for (source_file, (ok, outputs)) in by_file { + let Some((item, location)) = file_info.get(source_file) else { + continue; + }; + if ok { + reporter::export(item, location, ReportResult::Ok); + } else { + fail_count += outputs.len(); + reporter::export(item, location, ReportResult::Error(outputs.join("\n\n"))); + } + } + fail_count +} + +/// Reads `verified-docs.toml` and collects all `.md` files: single files, +/// directories, or `**` globs (walked from the base directory). +fn verified_md_files() -> Option<Vec<(String, PathBuf)>> { + let content = std::fs::read_to_string(VERIFIED_DOCS).ok()?; + let table: toml::Table = content.parse().ok()?; + + let mut files: Vec<(String, PathBuf)> = Vec::new(); + for (label, value) in table.get("verified")?.as_table()? { + let value_str = value.as_str()?; + let candidate = PathBuf::from(value_str); + if candidate.is_dir() { + collect_md_files(&candidate, &mut files, label); + } else if candidate.is_file() { + files.push((label.clone(), candidate)); + } else if candidate.extension().is_none() { + // Glob like "docs/pages/**": walk the base directory. + let base = PathBuf::from(value_str.trim_end_matches("/**").trim_end_matches('*')); + if base.is_dir() { + collect_md_files(&base, &mut files, label); + } + } + } + + files.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + Some(files) +} + +/// Recursively collects all `.md` files under a directory. +fn collect_md_files(dir: &Path, files: &mut Vec<(String, PathBuf)>, label: &str) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_files(&path, files, label); + } else if path.extension().is_some_and(|ext| ext == "md") { + files.push((label.to_string(), path)); + } + } + } +} + +/// Number of code blocks that failed to build. +#[derive(Grouped)] +pub struct ResultMarkdownCheck { + pub fail_count: usize, +} + +#[derive(Grouped, Default)] +pub struct ErrorMarkdownArgs(pub String); + +#[derive(Grouped, Default)] +pub struct ErrorMarkdownConfig; + +/// Silently sets a non-zero exit code when any block failed. +#[renderer(buffer)] +pub fn render_markdown_check(r: ResultMarkdownCheck, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} + +#[renderer] +pub fn render_error_markdown_args(e: ErrorMarkdownArgs, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![e.0]); + render_result +} + +#[renderer] +pub fn render_error_markdown_config(_: ErrorMarkdownConfig, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("failed to read {VERIFIED_DOCS}")]); + render_result +} diff --git a/mingling_ci/src/task/cmd_markdown_compare.rs b/mingling_ci/src/task/cmd_markdown_compare.rs new file mode 100644 index 0000000..b014f1e --- /dev/null +++ b/mingling_ci/src/task/cmd_markdown_compare.rs @@ -0,0 +1,221 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use colored::Colorize; +use just_fmt::snake_case; +use mingling::{ + Grouped, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::markdown::compare::{collect_md_files, compare_signature}; +use crate::reporter::{self, ReportResult}; +use crate::task::cmd_markdown_check::{ErrorMarkdownArgs, ErrorMarkdownConfig, stem_of}; + +const DOCS_DIR: &str = "./docs"; +const LANG_CONFIG: &str = ".config/docs-lang.txt"; + +/// One file-pair outcome of a structure comparison. +struct CompareOutcome { + item: String, + location: String, + ok: bool, + output: String, +} + +#[command(node = "markdown-compare")] +// `#[command]` rewrites an owned first param into the entry type, so the args +// must be passed by value even though the body only reads them. +#[allow(clippy::needless_pass_by_value)] +pub fn markdown_compare(args: Vec<String>) -> Next { + let [ref_arg, trans_arg] = args.as_slice() else { + return ErrorMarkdownArgs("missing <reference> and <translation> arguments".to_string()) + .to_chain(); + }; + let ref_path = cwd().join(ref_arg); + let trans_path = cwd().join(trans_arg); + + reporter::set_task("Markdown-Compare"); + let outcomes = if ref_path.is_dir() && trans_path.is_dir() { + compare_dirs(&ref_path, &trans_path, "doc") + } else if ref_path.is_file() && trans_path.is_file() { + compare_files(&ref_path, &trans_path, "doc") + } else { + return ErrorMarkdownArgs( + "both arguments must be files or both must be directories".to_string(), + ) + .to_chain(); + }; + let fail_count = export_outcomes(&outcomes); + reporter::flush(); + + ResultMarkdownCompare { fail_count }.to_chain() +} + +#[command(node = "markdown-compare-all")] +pub fn markdown_compare_all() -> Next { + let Some(langs) = lang_config() else { + return ErrorMarkdownConfig.to_chain(); + }; + let Some(reference) = langs.first() else { + return ErrorMarkdownConfig.to_chain(); + }; + let ref_dir = PathBuf::from(DOCS_DIR).join(reference); + if !ref_dir.is_dir() { + return ErrorMarkdownArgs(format!( + "reference docs directory `{}` does not exist", + ref_dir.display() + )) + .to_chain(); + } + + reporter::set_task("Markdown-Compare-All"); + let mut fail_count = 0; + for lang in &langs[1..] { + let lang_dir = PathBuf::from(DOCS_DIR).join(lang); + if !lang_dir.is_dir() { + eprintln!( + " {}: `{}` does not exist", + "ERROR".bright_red(), + lang_dir.display() + ); + fail_count += 1; + continue; + } + let outcomes = compare_dirs(&ref_dir, &lang_dir, &lang_key(lang)); + fail_count += export_outcomes(&outcomes); + } + reporter::flush(); + + ResultMarkdownCompare { fail_count }.to_chain() +} + +/// Compares one file pair (reference vs translation). +fn compare_files(ref_path: &Path, trans_path: &Path, prefix: &str) -> Vec<CompareOutcome> { + let item = format!("{prefix}-{}", snake_case!(&stem_of(ref_path))); + let location = trans_path.to_string_lossy().into_owned(); + match compare_signature(ref_path, trans_path) { + Ok(()) => vec![CompareOutcome { + item, + location, + ok: true, + output: String::new(), + }], + Err(diffs) => vec![CompareOutcome { + item, + location, + ok: false, + output: diffs.join("\n"), + }], + } +} + +/// Compares two directories: every `.md` file in the reference must exist in +/// the translation with the same structural signature; extra files are errors. +fn compare_dirs(ref_dir: &Path, trans_dir: &Path, prefix: &str) -> Vec<CompareOutcome> { + let ref_files = collect_md_files(ref_dir); + let ref_set: BTreeSet<PathBuf> = ref_files.iter().cloned().collect(); + let trans_set: BTreeSet<PathBuf> = collect_md_files(trans_dir).into_iter().collect(); + + let mut outcomes = Vec::new(); + for file in ref_files { + let item = format!("{prefix}-{}", snake_case!(&stem_of(&file))); + let trans_path = trans_dir.join(&file); + let location = trans_path.to_string_lossy().into_owned(); + if !trans_set.contains(&file) { + outcomes.push(CompareOutcome { + item, + location, + ok: false, + output: "missing in translation".to_string(), + }); + continue; + } + outcomes.push(match compare_signature(&ref_dir.join(&file), &trans_path) { + Ok(()) => CompareOutcome { + item, + location, + ok: true, + output: String::new(), + }, + Err(diffs) => CompareOutcome { + item, + location, + ok: false, + output: diffs.join("\n"), + }, + }); + } + + for file in trans_set.difference(&ref_set) { + let item = format!("{prefix}-{}", snake_case!(&stem_of(file))); + let trans_path = trans_dir.join(file); + outcomes.push(CompareOutcome { + item, + location: trans_path.to_string_lossy().into_owned(), + ok: false, + output: "extra file, not in reference".to_string(), + }); + } + outcomes +} + +/// Exports the outcomes via `reporter`; failures also print to stderr. +fn export_outcomes(outcomes: &[CompareOutcome]) -> usize { + let mut fail_count = 0; + for outcome in outcomes { + if outcome.ok { + reporter::export(&outcome.item, &outcome.location, ReportResult::Ok); + } else { + fail_count += 1; + eprintln!(" {} {}", "failed".bright_red(), outcome.item); + eprintln!(" {}\n{}", outcome.location, outcome.output); + reporter::export( + &outcome.item, + &outcome.location, + ReportResult::Error(outcome.output.clone()), + ); + } + } + fail_count +} + +/// Reads `.config/docs-lang.txt`: the first line is the reference directory +/// (relative to `./docs/`), the rest are translations that must mirror it. +fn lang_config() -> Option<Vec<String>> { + let content = std::fs::read_to_string(LANG_CONFIG).ok()?; + Some( + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|l| l.trim_start_matches("./").to_string()) + .collect(), + ) +} + +/// Turns a lang directory path into a report-item key, e.g. +/// `./_zh_CN/pages/` → `_zh_CN_pages`. +fn lang_key(lang: &str) -> String { + lang.trim_matches('/').replace('/', "_") +} + +fn cwd() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Number of files that failed the structure comparison. +#[derive(Grouped)] +pub struct ResultMarkdownCompare { + pub fail_count: usize, +} + +/// Silently sets a non-zero exit code when any comparison failed. +#[renderer(buffer)] +pub fn render_markdown_compare(r: ResultMarkdownCompare, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} diff --git a/mingling_ci/src/task/cmd_test.rs b/mingling_ci/src/task/cmd_test.rs new file mode 100644 index 0000000..5b9f55a --- /dev/null +++ b/mingling_ci/src/task/cmd_test.rs @@ -0,0 +1,54 @@ +use std::ffi::OsString; +use std::path::Path; + +use mingling::{ + Grouped, Routable, + macros::{buffer, command, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::res::{Manifests, ResCrateConfig}; +use crate::task::run::{location, run_parallel_checks}; + +#[command(node = "test-all")] +pub async fn test_all(manifests: &Manifests, config: &ResCrateConfig) -> Next { + let tasks = manifests + .package_dirs + .iter() + .map(|(name, path)| { + let args = config.test_command(name).map_or_else( + || test_args(path), + |cmd| cmd.iter().map(|s| OsString::from(s.as_str())).collect(), + ); + (name.clone(), location(path), args) + }) + .collect(); + let fail_count = run_parallel_checks("Test-All", "Testing", tasks).await; + ResultTestAll { fail_count }.to_chain() +} + +/// Default: `cargo test --manifest-path <path>` (crates without a +/// `mingling-ci.toml` override). +fn test_args(path: &Path) -> Vec<OsString> { + vec![ + "cargo".into(), + "test".into(), + "--manifest-path".into(), + path.as_os_str().to_os_string(), + ] +} + +/// Number of packages that failed tests. +#[derive(Grouped)] +pub struct ResultTestAll { + pub fail_count: usize, +} + +/// Silently sets a non-zero exit code when any test failed. +#[renderer(buffer)] +pub fn render_test_all(r: ResultTestAll, exit_code: &mut ResExitCode) { + if r.fail_count > 0 { + exit_code.exit_code = 1; + } +} diff --git a/mingling_ci/src/task/run.rs b/mingling_ci/src/task/run.rs new file mode 100644 index 0000000..ba752dd --- /dev/null +++ b/mingling_ci/src/task/run.rs @@ -0,0 +1,114 @@ +use std::ffi::OsString; +use std::path::Path; + +use colored::Colorize; + +use crate::progress::task_progress_bar; +use crate::reporter::{self, ReportResult}; + +/// The manifest's parent directory, e.g. `./mingling` — the report location +/// for a crate-based item. +pub(crate) fn location(path: &Path) -> String { + path.parent() + .map_or_else(|| ".".to_string(), |d| d.to_string_lossy().into_owned()) +} + +/// Outcome of a `cargo` subcommand. +struct CargoResult { + ok: bool, + exit_code: Option<i32>, + output: String, +} + +/// Runs the given cargo task list in parallel. +/// +/// Each task is an `(item, location, argv)` triple; progress and failures go +/// to stderr: a failing task prints its output immediately and writes its +/// report entry at the same time. Returns the number of failing tasks. +pub(crate) async fn run_parallel_checks( + task: &str, + phase: &str, + tasks: Vec<(String, String, Vec<OsString>)>, +) -> usize { + reporter::set_task(task); + + let n = tasks.len(); + let pb = task_progress_bar(n, phase); + pb.set_message("tasks"); + + // Run each task in parallel. + let mut set = tokio::task::JoinSet::new(); + for (item, location, args) in tasks { + set.spawn(async move { (item, location, run_cargo(args).await) }); + } + + let mut fail_count = 0; + while let Some(joined) = set.join_next().await { + let Ok((item, location, result)) = joined else { + continue; + }; + pb.inc(1); + pb.set_message(item.clone()); + + if result.ok { + reporter::export(&item, &location, ReportResult::Ok); + } else { + fail_count += 1; + // Failures print to stderr immediately (bar suspended to avoid + // interleaving) and write their report entry at the same time. + pb.suspend(|| { + eprintln!( + "{}: {} failed{}", + phase.bold().bright_cyan(), + item, + result + .exit_code + .map_or_else(String::new, |c| format!(" (exit code {c})")) + ); + for line in result.output.lines() { + eprintln!(" {line}"); + } + }); + reporter::export(&item, &location, ReportResult::Error(result.output)); + } + } + + pb.finish_and_clear(); + reporter::flush(); + fail_count +} + +/// Runs a `cargo` subcommand, capturing its output. +/// Runs a cargo subcommand (`argv[0]` is the program), capturing its output. +async fn run_cargo(argv: Vec<OsString>) -> CargoResult { + let mut argv = argv.into_iter(); + let Some(program) = argv.next() else { + return CargoResult { + ok: false, + exit_code: None, + output: "empty command".to_string(), + }; + }; + + let output = tokio::process::Command::new(program) + .args(argv) + .output() + .await; + + match output { + Ok(output) => { + let mut log = String::from_utf8_lossy(&output.stdout).into_owned(); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + CargoResult { + ok: output.status.success(), + exit_code: output.status.code(), + output: log, + } + } + Err(e) => CargoResult { + ok: false, + exit_code: None, + output: format!("failed to run cargo: {e}"), + }, + } +} |
