diff options
Diffstat (limited to 'mingling_ci/src/res')
| -rw-r--r-- | mingling_ci/src/res/collect_logs.rs | 203 | ||||
| -rw-r--r-- | mingling_ci/src/res/crate_config.rs | 79 | ||||
| -rw-r--r-- | mingling_ci/src/res/features.rs | 47 | ||||
| -rw-r--r-- | mingling_ci/src/res/manifests.rs | 103 | ||||
| -rw-r--r-- | mingling_ci/src/res/print.rs | 174 |
5 files changed, 606 insertions, 0 deletions
diff --git a/mingling_ci/src/res/collect_logs.rs b/mingling_ci/src/res/collect_logs.rs new file mode 100644 index 0000000..6017168 --- /dev/null +++ b/mingling_ci/src/res/collect_logs.rs @@ -0,0 +1,203 @@ +//! IO side of the report command: reads the collect directory once and keeps +//! the parsed data in a resource, so chains only do computation. + +use std::collections::BTreeMap; + +use mingling::{Program, macros::program_setup}; + +use crate::ThisProgram; +use crate::reporter::COLLECT_DIR; + +/// Git commit date and short hash for the report. +#[derive(Default, Clone, Debug)] +pub struct GitInfo { + pub date: String, + pub commit_hash: String, +} + +/// Parsed contents of the collect directory. +#[derive(Default, Clone)] +pub struct ResCollectLogs { + /// `(task, item) -> os -> ok` + pub statuses: BTreeMap<(String, String), BTreeMap<String, bool>>, + /// `(task, item) -> location` + pub locations: BTreeMap<(String, String), String>, + /// `(task, os, item) -> stripped error output (location line removed)` + pub err_outputs: BTreeMap<(String, String, String), String>, + pub git: GitInfo, +} + +impl ResCollectLogs { + /// Reads the flat `collect/` directory — aggregate `{task}.{os}.ok` files + /// (`item` or `item = location` per line) and per-item + /// `{task}.{os}.{item}.err` files (first line is the location) — plus the + /// git info. + #[must_use] + pub fn read() -> Self { + let mut logs = Self::default(); + + if let Ok(entries) = std::fs::read_dir(COLLECT_DIR) { + for entry in entries.flatten() { + let file_name = entry.file_name().to_string_lossy().into_owned(); + if let Some((task, os)) = parse_ok_name(&file_name) { + // Aggregate success file: `item` or `item = location` per line. + if let Ok(content) = std::fs::read_to_string(entry.path()) { + for line in content.lines().filter(|l| !l.is_empty()) { + let (item, location) = line + .split_once('=') + .map_or((line, ""), |(name, loc)| (name.trim(), loc.trim())); + logs.statuses + .entry((task.clone(), item.to_string())) + .or_default() + .insert(os.clone(), true); + logs.locations + .insert((task.clone(), item.to_string()), location.to_string()); + } + } + } else if let Some((task, os, item)) = parse_err_name(&file_name) { + let content = std::fs::read_to_string(entry.path()).unwrap_or_default(); + let mut lines = content.splitn(2, '\n'); + let location = lines.next().unwrap_or_default().to_string(); + let output = lines.next().unwrap_or_default().to_string(); + logs.statuses + .entry((task.clone(), item.clone())) + .or_default() + .insert(os.clone(), false); + logs.locations + .insert((task.clone(), item.clone()), location); + logs.err_outputs + .insert((task, os, item), strip_ansi(&output)); + } + } + } + + logs.git = git_info(); + logs + } +} + +/// Parses a `{task}.{os}.ok` file name. +fn parse_ok_name(file_name: &str) -> Option<(String, String)> { + let name = file_name.strip_suffix(".ok")?; + let mut parts = name.rsplitn(2, '.'); + let os = parts.next()?.to_string(); + let task = parts.next()?.to_string(); + Some((task, os)) +} + +/// Parses a `{task}.{os}.{package}.err` file name. +/// +/// Split from the right: package names cannot contain dots (cargo forbids +/// them), while task names may. +fn parse_err_name(file_name: &str) -> Option<(String, String, String)> { + let name = file_name.strip_suffix(".err")?; + let mut parts = name.rsplitn(3, '.'); + let package = parts.next()?.to_string(); + let os = parts.next()?.to_string(); + let task = parts.next()?.to_string(); + Some((task, os, package)) +} + +#[program_setup] +pub fn report_setup(p: &mut Program<ThisProgram>) { + p.with_resource(ResCollectLogs::read()); +} + +/// Strips ANSI escape sequences from `input`. +/// +/// Handles CSI (`ESC [ ...`), OSC (`ESC ] ...` terminated by BEL or `ESC \`) +/// and other single-character escapes, while preserving UTF-8 text. Literal +/// `^[` (caret-bracket, produced by some terminal captures) is normalized to +/// `ESC` first. +fn strip_ansi(input: &str) -> String { + // Normalize literal `^[` (0x5E 0x5B) to a real ESC byte. + let normalized = input.replace("^[", "\u{1b}"); + let mut out = String::with_capacity(normalized.len()); + let mut rest = normalized.as_str(); + while let Some(idx) = rest.find('\u{1b}') { + out.push_str(&rest[..idx]); + rest = &rest[idx..]; + rest = &rest[ansi_len(rest)..]; + } + out.push_str(rest); + out +} + +/// Byte length of the ANSI escape sequence starting at `s[0]` (`s[0]` is `ESC`). +fn ansi_len(s: &str) -> usize { + let b = s.as_bytes(); + match b.get(1) { + Some(b'[') => { + // CSI: `ESC [` params/intermediates (0x20-0x3F) then a final byte (0x40-0x7E). + let mut i = 2; + while i < b.len() { + let byte = b[i]; + i += 1; + if (0x40..=0x7E).contains(&byte) { + break; + } + if !(0x20..=0x3F).contains(&byte) { + break; + } + } + i + } + Some(b']') => { + // OSC: `ESC ]` ... terminated by BEL (0x07) or `ESC \`. + let mut i = 2; + while i < b.len() { + let byte = b[i]; + i += 1; + if byte == 0x07 { + break; + } + if byte == 0x1b { + if b.get(i) == Some(&b'\\') { + i += 1; + } + break; + } + } + i + } + Some(_) => 2.min(b.len()), + None => 1, + } +} + +/// Commit date (`YYYY-MM-DD`) and short commit hash; empty on failure. +fn git_info() -> GitInfo { + let run = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() + }; + GitInfo { + date: run(&["log", "-1", "--format=%cs"]), + commit_hash: run(&["rev-parse", "--short", "HEAD"]), + } +} + +#[cfg(test)] +mod tests { + use super::strip_ansi; + + #[test] + fn strips_csi_and_osc_and_literal_caret() { + let input = + "\u{1b}[1m\u{1b}[92mok\u{1b}[0m \u{1b}]8;;https://x\u{1b}\\done\u{1b}]8;;\u{1b}\\\n"; + assert_eq!(strip_ansi(input), "ok done\n"); + + // Literal `^[` (caret-bracket) captured by some terminals. + assert_eq!(strip_ansi("^[[31mred^[[0m"), "red"); + } + + #[test] + fn preserves_utf8() { + assert_eq!(strip_ansi("你好\u{1b}[1m世界!\u{1b}[0m"), "你好世界!"); + } +} diff --git a/mingling_ci/src/res/crate_config.rs b/mingling_ci/src/res/crate_config.rs new file mode 100644 index 0000000..b20e83d --- /dev/null +++ b/mingling_ci/src/res/crate_config.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use std::path::Path; + +use mingling::{Program, macros::program_setup}; + +use crate::ThisProgram; +use crate::res::{Manifests, ResFeatureList}; + +/// Per-crate CI overrides from `mingling-ci.toml` (optional, crate root). +/// +/// Currently only `[test] command` is read; `clippy.command` / `build.command` +/// will follow the same shape. +#[derive(Default, Clone)] +pub struct ResCrateConfig { + /// Package name -> test command argv (with `<<<features>>>` expanded). + test_commands: HashMap<String, Vec<String>>, +} + +impl ResCrateConfig { + /// The configured `[test] command` for a package, if any. + #[must_use] + pub fn test_command(&self, package: &str) -> Option<&[String]> { + self.test_commands.get(package).map(Vec::as_slice) + } +} + +#[program_setup] +pub fn crate_config_setup(p: &mut Program<ThisProgram>) { + let features = p + .res::<ResFeatureList>() + .map(|f| f.list.clone()) + .unwrap_or_default(); + let joined_features = features.join(","); + + let Some(manifests) = p.res::<Manifests>() else { + return; + }; + + let mut test_commands = HashMap::new(); + for (name, manifest_path) in &manifests.package_dirs { + let config_path = manifest_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("mingling-ci.toml"); + + let Ok(content) = std::fs::read_to_string(&config_path) else { + continue; + }; + + let Ok(table) = content.parse::<toml::Value>() else { + continue; + }; + + let Some(command) = table + .get("test") + .and_then(|t| t.get("command")) + .and_then(|c| c.as_array()) + else { + continue; + }; + + let argv: Vec<String> = command + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + + if argv.is_empty() { + continue; + } + + let argv = argv + .into_iter() + .map(|arg| arg.replace("<<<features>>>", &joined_features)) + .collect(); + test_commands.insert(name.clone(), argv); + } + + p.with_resource(ResCrateConfig { test_commands }); +} diff --git a/mingling_ci/src/res/features.rs b/mingling_ci/src/res/features.rs new file mode 100644 index 0000000..8009514 --- /dev/null +++ b/mingling_ci/src/res/features.rs @@ -0,0 +1,47 @@ +use mingling::{Program, macros::program_setup}; + +use crate::ThisProgram; + +/// Manifest that declares the documented feature list. +/// +/// Path is relative to the repo root (the CI's working directory). +const FEATURES_MANIFEST: &str = "./mingling/Cargo.toml"; + +/// The docs.rs feature list of `mingling`, the single source of truth for the +/// feature combinations used by CI checks. +#[derive(Default, Clone)] +pub struct ResFeatureList { + pub list: Vec<String>, +} + +#[program_setup] +pub fn features_setup(p: &mut Program<ThisProgram>) { + p.with_resource(ResFeatureList { + list: docs_rs_features(), + }); +} + +/// Reads `[package.metadata.docs.rs].features` from `mingling/Cargo.toml`. +#[must_use] +fn docs_rs_features() -> Vec<String> { + let Ok(content) = std::fs::read_to_string(FEATURES_MANIFEST) else { + return Vec::new(); + }; + let Ok(toml_value) = content.parse::<toml::Value>() else { + return Vec::new(); + }; + toml_value + .get("package") + .and_then(|p| p.get("metadata")) + .and_then(|m| m.get("docs")) + .and_then(|d| d.get("rs")) + .and_then(|rs| rs.get("features")) + .and_then(|f| f.as_array()) + .map(|features| { + features + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} diff --git a/mingling_ci/src/res/manifests.rs b/mingling_ci/src/res/manifests.rs new file mode 100644 index 0000000..91836d6 --- /dev/null +++ b/mingling_ci/src/res/manifests.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use mingling::{Program, macros::program_setup}; + +use crate::ThisProgram; + +/// Directories whose manifests are excluded from CI checks. +/// +/// Path is relative to the crate source file (`mingling_ci/src/res/`). +const IGNORED_DIRS_FILE: &str = include_str!("../../../.config/ci-ignored-dirs.txt"); + +/// All `Cargo.toml` manifests the CI will check. +#[derive(Default, Clone)] +pub struct Manifests { + pub path: Vec<PathBuf>, + /// Package name -> its manifest path. + pub package_dirs: HashMap<String, PathBuf>, +} + +#[program_setup] +pub fn manifests_setup(p: &mut Program<ThisProgram>) { + let path = cargo_tomls(); + let package_dirs = path.iter().map(|p| (package_name(p), p.clone())).collect(); + p.with_resource(Manifests { path, package_dirs }); +} + +/// Recursively collects every `Cargo.toml` under the current directory, +/// skipping the legacy `.run` CI directory and any directory listed in +/// `.config/ci-ignored-dirs.txt`. +#[must_use] +fn cargo_tomls() -> Vec<PathBuf> { + let ignored = ignored_dirs(); + let mut cargo_tomls = Vec::new(); + let mut dirs = vec![PathBuf::from(".")]; + while let Some(dir) = dirs.pop() { + if is_ignored(&dir.to_string_lossy(), &ignored) { + continue; + } + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Skip the legacy `.run` CI directory + if path.file_name().and_then(|n| n.to_str()) == Some(".run") { + continue; + } + dirs.push(path); + } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") { + cargo_tomls.push(path); + } + } + } + } + cargo_tomls +} + +/// Parses `.config/ci-ignored-dirs.txt` into directory prefixes: +/// non-empty lines that do not start with `#`, with the trailing `/` stripped +/// (e.g. `./.temp/` → `./.temp`). +fn ignored_dirs() -> Vec<String> { + IGNORED_DIRS_FILE + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.trim_end_matches('/').to_string()) + .collect() +} + +/// Whether `path` (a walk directory, e.g. `./.temp` or `./examples`) is inside +/// one of the ignored directories. +fn is_ignored(path: &str, ignored: &[String]) -> bool { + ignored.iter().any(|dir| { + path.strip_prefix(dir.as_str()) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) +} + +/// Extracts the package name from a `Cargo.toml`. +/// +/// Falls back to the parent directory name (e.g. `mingling_core/Cargo.toml` → +/// `mingling_core`, workspace root → `(root)`), matching the legacy CI. +fn package_name(path: &Path) -> String { + let fallback = || { + path.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("(root)") + .to_string() + }; + + let Ok(content) = std::fs::read_to_string(path) else { + return fallback(); + }; + let Ok(toml_value) = content.parse::<toml::Table>() else { + return fallback(); + }; + toml_value + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .map_or_else(fallback, str::to_string) +} diff --git a/mingling_ci/src/res/print.rs b/mingling_ci/src/res/print.rs new file mode 100644 index 0000000..9d844a6 --- /dev/null +++ b/mingling_ci/src/res/print.rs @@ -0,0 +1,174 @@ +use colored::Colorize; +use mingling::config::ErrorOutput; +use mingling::hook::ProgramHook; +use mingling::{Program, macros::program_setup}; +use mingling::{StringVec, this}; + +use crate::ThisProgram; + +#[program_setup] +pub fn print_setup(p: &mut Program<ThisProgram>) { + p.with_resource(CargoError::default()); + p.with_resource(CargoWarn::default()); + p.with_resource(CargoHelp::default()); + p.with_resource(CargoStatus::default()); + + p.with_hook(ProgramHook::empty().on_begin::<_, ()>(move |_| { + let p = this::<ThisProgram>(); + let silence_err = p.stdout_setting.error_output == ErrorOutput::Hide; + + p.modify_res(|r: &mut CargoError| r.silence = silence_err); + p.modify_res(|r: &mut CargoWarn| r.silence = silence_err); + p.modify_res(|r: &mut CargoHelp| r.silence = silence_err); + p.modify_res(|r: &mut CargoStatus| r.silence = silence_err); + })); +} + +#[derive(Default, Clone)] +pub struct CargoError { + silence: bool, +} + +impl MessagePrinter for CargoError { + fn format(&self, msg: impl Into<StringVec>) -> String { + format!("{}: {}", "error".bold().bright_red(), msg.into().join("")) + } + + fn std_mode(&self) -> StandardOutMode { + if self.silence { + StandardOutMode::Silence + } else { + StandardOutMode::Error + } + } +} + +#[derive(Default, Clone)] +pub struct CargoWarn { + silence: bool, +} + +impl MessagePrinter for CargoWarn { + fn format(&self, msg: impl Into<StringVec>) -> String { + format!("{}: {}", "warning".bright_yellow(), msg.into().join("")) + } + + fn std_mode(&self) -> StandardOutMode { + if self.silence { + StandardOutMode::Silence + } else { + StandardOutMode::Error + } + } +} + +#[derive(Default, Clone)] +pub struct CargoHelp { + silence: bool, +} + +impl MessagePrinter for CargoHelp { + fn format(&self, msg: impl Into<StringVec>) -> String { + format!("{}: {}", "help".bright_white(), msg.into().join("")) + } + + fn std_mode(&self) -> StandardOutMode { + if self.silence { + StandardOutMode::Silence + } else { + StandardOutMode::Error + } + } +} + +#[derive(Default, Clone)] +pub struct CargoStatus { + silence: bool, +} + +impl MessagePrinter for CargoStatus { + fn format(&self, msg: impl Into<StringVec>) -> String { + let parts: Vec<String> = msg.into().to_vec(); + let first = if parts.is_empty() { + String::new() + } else { + parts[0].trim().to_string() + }; + + let (prefix, content) = if first.is_empty() { + // Empty: fall back to Info with full message + ("Info".to_string(), parts.join(" ")) + } else if first.chars().count() == 1 { + // Single character: prefix is Info, entire message is content + ("Info".to_string(), parts.join(" ")) + } else if first.chars().count() <= 12 { + // Single part that is a status prefix (no message after it) + if parts.len() == 1 { + ("Info".to_string(), first) + } else { + // First part is a status prefix, remaining parts are the message + let content = parts[1..].join(" ").trim_start().to_string(); + (first, content) + } + } else { + // First part too long: all is message, fall back to Info + ("Info".to_string(), parts.join(" ")) + }; + + let padding = " ".repeat(12usize.saturating_sub(prefix.chars().count())); + + format!( + "{}{} {}", + padding, + prefix.bold().bright_green(), + content.trim() + ) + } + + fn std_mode(&self) -> StandardOutMode { + if self.silence { + StandardOutMode::Silence + } else { + StandardOutMode::Out + } + } +} + +pub trait MessagePrinter { + #[doc(hidden)] + fn println(&self, msg: impl Into<StringVec>) { + match self.std_mode() { + StandardOutMode::Out => println!("{}", self.format(msg)), + StandardOutMode::Error => eprintln!("{}", self.format(msg)), + StandardOutMode::Silence => {} + } + } + + #[doc(hidden)] + fn print(&self, msg: impl Into<StringVec>) { + match self.std_mode() { + StandardOutMode::Out => print!("{}", self.format(msg)), + StandardOutMode::Error => eprint!("{}", self.format(msg)), + StandardOutMode::Silence => {} + } + } + + /// Formats the message string before output. + fn format(&self, msg: impl Into<StringVec>) -> String; + + /// Returns the standard output mode (stdout or stderr). + fn std_mode(&self) -> StandardOutMode; +} + +/// Specifies where standard output messages should be directed. +/// +/// This enum determines whether messages are printed to stdout, stderr, or suppressed entirely. +#[repr(u8)] +pub enum StandardOutMode { + /// Print messages to standard output (stdout). + Out, + /// Print messages to standard error (stderr). + Error, + /// Suppress all output. + Silence, +} |
