diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-18 08:27:36 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-18 08:27:36 +0800 |
| commit | 5d0918ff5e19ebc67e4698bc308f6562779b7110 (patch) | |
| tree | a9d7f5fb54afd16056b0a4c24d118db2d95f8d78 /mingling_ci/src/cmd | |
| parent | 00cf68abdd2be1cfca8b5a268d087ea2ccedf1dd (diff) | |
feat: add new Mingling CI system crate (WIP)
Add `mingling_ci` as a standalone CI system built on Mingling 0.4.0
to validate the next version, with report collection and manifest
listing commands.
Diffstat (limited to 'mingling_ci/src/cmd')
| -rw-r--r-- | mingling_ci/src/cmd/cmd_report_collect.rs | 293 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_show_manifests.rs | 71 |
2 files changed, 364 insertions, 0 deletions
diff --git a/mingling_ci/src/cmd/cmd_report_collect.rs b/mingling_ci/src/cmd/cmd_report_collect.rs new file mode 100644 index 0000000..da603c2 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_report_collect.rs @@ -0,0 +1,293 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; + +use crate::Next; +use crate::reporter::COLLECT_DIR; +use crate::res::{CargoError, Manifests, MessagePrinter, package_name}; + +const OUTPUT_PATH: &str = "./.temp/reports/result.md"; +const REPORT_TEMPLATE: &str = include_str!("../../tmpls/report.md"); +const TASK_SECTION_TEMPLATE: &str = include_str!("../../tmpls/task_section.md"); + +/// Maps a package to its per-OS pass/fail status. +type OsStatuses = BTreeMap<String, bool>; + +/// A row in a task section: package name and its per-OS statuses. +type TaskRow<'a> = (&'a String, &'a OsStatuses); + +/// Rows grouped by task name. +type RowsByTask<'a> = BTreeMap<&'a String, Vec<TaskRow<'a>>>; + +/// Git commit date and short hash for the report. +struct GitInfo { + date: String, + commit_hash: String, +} + +#[command(node = "report-collect")] +pub fn report_collect(manifests: &Manifests) -> Next { + let Ok(task_entries) = std::fs::read_dir(COLLECT_DIR) else { + return ErrorNoCollectDir.to_chain(); + }; + + // Parse `collect/{task}/{os}/{package}.{ok|err}` and group them by + // (task, package) -> os -> ok. + let mut statuses: BTreeMap<(String, String), OsStatuses> = BTreeMap::new(); + for task_entry in task_entries.flatten() { + if !task_entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let task = task_entry.file_name().to_string_lossy().into_owned(); + + let Ok(os_entries) = std::fs::read_dir(task_entry.path()) else { + continue; + }; + for os_entry in os_entries.flatten() { + if !os_entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let os = os_entry.file_name().to_string_lossy().into_owned(); + + let Ok(files) = std::fs::read_dir(os_entry.path()) else { + continue; + }; + for file in files.flatten() { + let file_name = file.file_name().to_string_lossy().into_owned(); + if let Some((package, ok)) = parse_log_name(&file_name) { + statuses + .entry((task.clone(), package)) + .or_default() + .insert(os.clone(), ok); + } + } + } + } + + // Group rows by task: task -> [(package, os_statuses)]. + let by_task: RowsByTask = statuses.iter().fold( + BTreeMap::new(), + |mut acc, ((task, package), os_statuses)| { + acc.entry(task).or_default().push((package, os_statuses)); + acc + }, + ); + + // Render one section per task (table rows + this task's failures). + let mut fail_count = 0; + let mut sections: Vec<HashMap<String, String>> = Vec::new(); + for (task, rows) in by_task { + let mut row_arms = Vec::new(); + let mut fail_arms = Vec::new(); + for (package, os_statuses) in rows { + row_arms.push(HashMap::from([ + ("package_name".to_string(), package.clone()), + ("package_dir".to_string(), package_dir(manifests, package)), + ( + "pass_win".to_string(), + pass_cell(os_statuses.get("Windows")), + ), + ( + "pass_linux".to_string(), + pass_cell(os_statuses.get("Linux")), + ), + ("pass_mac".to_string(), pass_cell(os_statuses.get("MacOS"))), + ])); + + for (os, ok) in os_statuses { + if !ok { + let path = format!("{COLLECT_DIR}/{task}/{os}/{package}.err"); + let stdout = strip_ansi(&std::fs::read_to_string(path).unwrap_or_default()); + fail_arms.push(HashMap::from([ + ("package_name".to_string(), package.clone()), + ("stdout".to_string(), stdout), + ])); + fail_count += 1; + } + } + } + + let mut section = Template::from(TASK_SECTION_TEMPLATE); + section.insert_param("task_name".to_string(), task.clone()); + *section.add_impl("rows".to_string()) = row_arms; + *section.add_impl("fails".to_string()) = fail_arms; + sections.push(HashMap::from([( + "section".to_string(), + section.expand().unwrap_or_default(), + )])); + } + + let git = git_info(); + let mut template = Template::from(REPORT_TEMPLATE); + template.insert_param("date".to_string(), git.date); + template.insert_param("commit_hash".to_string(), git.commit_hash); + *template.add_impl("task_sections".to_string()) = sections; + + let expanded = template.expand().unwrap_or_default(); + std::fs::create_dir_all(std::path::Path::new(OUTPUT_PATH).parent().unwrap()).ok(); + std::fs::write(OUTPUT_PATH, expanded).ok(); + + ResultCollectResults { + output: OUTPUT_PATH.into(), + fail_count, + } + .to_chain() +} + +/// Parses a `{package}.{ok|err}` file name into `(package, ok)`. +fn parse_log_name(file_name: &str) -> Option<(String, bool)> { + file_name + .strip_suffix(".ok") + .map(|n| (n.to_string(), true)) + .or_else(|| { + file_name + .strip_suffix(".err") + .map(|n| (n.to_string(), false)) + }) +} + +/// 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, + } +} + +/// Maps a package name to its manifest directory (e.g. `mingling` → +/// `./mingling`), or `—` when the manifest is unknown. +fn package_dir(manifests: &Manifests, package: &str) -> String { + manifests + .path + .iter() + .find(|path| package_name(path) == package) + .and_then(|path| path.parent()) + .map_or_else(|| "—".to_string(), |dir| dir.to_string_lossy().into_owned()) +} + +fn pass_cell(status: Option<&bool>) -> String { + match status { + Some(true) => "✅".to_string(), + Some(false) => "❌".to_string(), + None => "—".to_string(), + } +} + +/// 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"]), + } +} + +/// The generated report. +#[derive(Grouped)] +pub struct ResultCollectResults { + pub output: PathBuf, + pub fail_count: usize, +} + +#[derive(Grouped, Default)] +pub struct ErrorNoCollectDir; + +#[renderer(buffer)] +pub fn render_collect_results(r: ResultCollectResults) { + r_println!("Collected {} failing logs", r.fail_count); + r_println!("Report generated at {}", r.output.display()); +} + +#[renderer] +pub fn render_error_no_collect_dir(_: ErrorNoCollectDir, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("No collect directory: {COLLECT_DIR}")]); + render_result +} + +#[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/cmd/cmd_show_manifests.rs b/mingling_ci/src/cmd/cmd_show_manifests.rs new file mode 100644 index 0000000..32e49c0 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_show_manifests.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +use mingling::{ + Grouped, + macros::{buffer, command, r_println, renderer}, +}; + +use prettytable::{ + Cell, Row, Table, + format::{FormatBuilder, LinePosition, LineSeparator}, +}; + +use crate::res::{Manifests, package_name}; + +#[command(node = "show-manifests")] +pub fn show_manifests(manifests: &Manifests) -> ResultPrintManifests { + let mut entries: Vec<ManifestEntry> = manifests + .path + .iter() + .map(|path| ManifestEntry { + name: package_name(path), + path: path.clone(), + }) + .collect(); + entries.sort_by(|a, b| a.path.cmp(&b.path)); + ResultPrintManifests { entries } +} + +/// All manifests the CI will check, sorted by path. +#[derive(Grouped)] +pub struct ResultPrintManifests { + pub entries: Vec<ManifestEntry>, +} + +#[derive(Debug, Clone)] +pub struct ManifestEntry { + pub name: String, + pub path: PathBuf, +} + +#[renderer(buffer)] +pub fn render_print_manifests(r: ResultPrintManifests) { + let mut table = Table::new(); + + table.set_format( + FormatBuilder::new() + .column_separator('│') + .borders('│') + .separator(LinePosition::Top, LineSeparator::new('─', '┬', '┌', '┐')) + .separator(LinePosition::Title, LineSeparator::new('─', '┼', '├', '┤')) + .separator(LinePosition::Bottom, LineSeparator::new('─', '┴', '└', '┘')) + .padding(1, 1) + .build(), + ); + + table.set_titles(Row::new(vec![ + Cell::new("#"), + Cell::new("Package-Name"), + Cell::new("Package-Path"), + ])); + + for (index, entry) in r.entries.iter().enumerate() { + table.add_row(Row::new(vec![ + Cell::new(&(index + 1).to_string()), + Cell::new(&entry.name), + Cell::new(&entry.path.to_string_lossy()), + ])); + } + + r_println!("{table}"); +} |
