aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-08-18 08:38:38 +0800
committer魏曹先生 <1992414357@qq.com>2026-08-18 08:38:38 +0800
commitc2708e655d350b240bc25e3f6c77026eebcf1b16 (patch)
tree1a6569f4a826db677834791c6423e094036179bd
parent5d0918ff5e19ebc67e4698bc308f6562779b7110 (diff)
refactor(ci-new): extract report log collection into resource
Move log parsing, ANSI stripping, and git info retrieval into a `ResCollectLogs` resource read once during CI setup, keeping the report command focused on rendering. Also add a `package_dirs` map to `Manifests` for direct package name lookups.
-rw-r--r--mingling_ci/src/bin/ci.rs1
-rw-r--r--mingling_ci/src/cmd/cmd_report_collect.rs193
-rw-r--r--mingling_ci/src/cmd/cmd_show_manifests.rs8
-rw-r--r--mingling_ci/src/res.rs3
-rw-r--r--mingling_ci/src/res/collect_logs.rs190
-rw-r--r--mingling_ci/src/res/manifests.rs11
6 files changed, 233 insertions, 173 deletions
diff --git a/mingling_ci/src/bin/ci.rs b/mingling_ci/src/bin/ci.rs
index a212921..1fb5120 100644
--- a/mingling_ci/src/bin/ci.rs
+++ b/mingling_ci/src/bin/ci.rs
@@ -41,6 +41,7 @@ async fn mingling_ci_begin() -> i32 {
// CI Plugins
program.with_setup(ManifestsSetup);
+ program.with_setup(ReportSetup);
program.exec().await
}
diff --git a/mingling_ci/src/cmd/cmd_report_collect.rs b/mingling_ci/src/cmd/cmd_report_collect.rs
index da603c2..bb944b3 100644
--- a/mingling_ci/src/cmd/cmd_report_collect.rs
+++ b/mingling_ci/src/cmd/cmd_report_collect.rs
@@ -9,7 +9,7 @@ use mingling::{
use crate::Next;
use crate::reporter::COLLECT_DIR;
-use crate::res::{CargoError, Manifests, MessagePrinter, package_name};
+use crate::res::{CargoError, Manifests, MessagePrinter, ResCollectLogs};
const OUTPUT_PATH: &str = "./.temp/reports/result.md";
const REPORT_TEMPLATE: &str = include_str!("../../tmpls/report.md");
@@ -24,53 +24,14 @@ 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 {
+pub fn report_collect(manifests: &Manifests, logs: &ResCollectLogs) -> Next {
+ if !PathBuf::from(COLLECT_DIR).is_dir() {
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(
+ let by_task: RowsByTask = logs.statuses.iter().fold(
BTreeMap::new(),
|mut acc, ((task, package), os_statuses)| {
acc.entry(task).or_default().push((package, os_statuses));
@@ -101,8 +62,11 @@ pub fn report_collect(manifests: &Manifests) -> Next {
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());
+ let stdout = logs
+ .err_outputs
+ .get(&(task.clone(), os.clone(), package.clone()))
+ .cloned()
+ .unwrap_or_default();
fail_arms.push(HashMap::from([
("package_name".to_string(), package.clone()),
("stdout".to_string(), stdout),
@@ -122,104 +86,30 @@ pub fn report_collect(manifests: &Manifests) -> Next {
)]));
}
- 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.insert_param("date".to_string(), logs.git.date.clone());
+ template.insert_param("commit_hash".to_string(), logs.git.commit_hash.clone());
*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();
+ let output = PathBuf::from(OUTPUT_PATH);
+ let parent = output.parent().expect("output path has a parent");
- ResultCollectResults {
- output: OUTPUT_PATH.into(),
- fail_count,
+ if let Err(e) = std::fs::create_dir_all(parent).and_then(|()| std::fs::write(&output, expanded))
+ {
+ return ErrorReportWrite(format!("failed to write {}: {e}", output.display())).to_chain();
}
- .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,
- }
+ ResultCollectResults { output, fail_count }.to_chain()
}
/// 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)
+ .package_dirs
+ .get(package)
.and_then(|path| path.parent())
.map_or_else(|| "—".to_string(), |dir| dir.to_string_lossy().into_owned())
}
@@ -232,23 +122,6 @@ fn pass_cell(status: Option<&bool>) -> 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 {
@@ -259,6 +132,9 @@ pub struct ResultCollectResults {
#[derive(Grouped, Default)]
pub struct ErrorNoCollectDir;
+#[derive(Grouped, Default)]
+pub struct ErrorReportWrite(pub String);
+
#[renderer(buffer)]
pub fn render_collect_results(r: ResultCollectResults) {
r_println!("Collected {} failing logs", r.fail_count);
@@ -272,22 +148,9 @@ pub fn render_error_no_collect_dir(_: ErrorNoCollectDir, error: &CargoError) ->
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"), "你好世界!");
- }
+#[renderer]
+pub fn render_error_report_write(e: ErrorReportWrite, error: &CargoError) -> RenderResult {
+ let render_result = RenderResult::new();
+ error.println(vec![format!("Report: {}", e.0)]);
+ render_result
}
diff --git a/mingling_ci/src/cmd/cmd_show_manifests.rs b/mingling_ci/src/cmd/cmd_show_manifests.rs
index 32e49c0..2be82d2 100644
--- a/mingling_ci/src/cmd/cmd_show_manifests.rs
+++ b/mingling_ci/src/cmd/cmd_show_manifests.rs
@@ -10,15 +10,15 @@ use prettytable::{
format::{FormatBuilder, LinePosition, LineSeparator},
};
-use crate::res::{Manifests, package_name};
+use crate::res::Manifests;
#[command(node = "show-manifests")]
pub fn show_manifests(manifests: &Manifests) -> ResultPrintManifests {
let mut entries: Vec<ManifestEntry> = manifests
- .path
+ .package_dirs
.iter()
- .map(|path| ManifestEntry {
- name: package_name(path),
+ .map(|(name, path)| ManifestEntry {
+ name: name.clone(),
path: path.clone(),
})
.collect();
diff --git a/mingling_ci/src/res.rs b/mingling_ci/src/res.rs
index 5b33a8f..7e7b172 100644
--- a/mingling_ci/src/res.rs
+++ b/mingling_ci/src/res.rs
@@ -1,3 +1,6 @@
+mod collect_logs;
+pub use collect_logs::*;
+
mod manifests;
pub use manifests::*;
diff --git a/mingling_ci/src/res/collect_logs.rs b/mingling_ci/src/res/collect_logs.rs
new file mode 100644
index 0000000..b764b49
--- /dev/null
+++ b/mingling_ci/src/res/collect_logs.rs
@@ -0,0 +1,190 @@
+//! 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, package) -> os -> ok`
+ pub statuses: BTreeMap<(String, String), BTreeMap<String, bool>>,
+ /// `(task, os, package) -> stripped error output`
+ pub err_outputs: BTreeMap<(String, String, String), String>,
+ pub git: GitInfo,
+}
+
+impl ResCollectLogs {
+ /// Reads `collect/{task}/{os}/{package}.{ok|err}` and the git info.
+ #[must_use]
+ pub fn read() -> Self {
+ let mut logs = Self::default();
+
+ if let Ok(task_entries) = std::fs::read_dir(COLLECT_DIR) {
+ 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) {
+ logs.statuses
+ .entry((task.clone(), package.clone()))
+ .or_default()
+ .insert(os.clone(), ok);
+ if !ok {
+ let err = std::fs::read_to_string(file.path()).unwrap_or_default();
+ logs.err_outputs
+ .insert((task.clone(), os.clone(), package), strip_ansi(&err));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ logs.git = git_info();
+ logs
+ }
+}
+
+#[program_setup]
+pub fn report_setup(p: &mut Program<ThisProgram>) {
+ p.with_resource(ResCollectLogs::read());
+}
+
+/// 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,
+ }
+}
+
+/// 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/manifests.rs b/mingling_ci/src/res/manifests.rs
index 56829bb..91836d6 100644
--- a/mingling_ci/src/res/manifests.rs
+++ b/mingling_ci/src/res/manifests.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::path::{Path, PathBuf};
use mingling::{Program, macros::program_setup};
@@ -13,13 +14,15 @@ const IGNORED_DIRS_FILE: &str = include_str!("../../../.config/ci-ignored-dirs.t
#[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>) {
- p.with_resource(Manifests {
- path: cargo_tomls(),
- });
+ 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,
@@ -77,7 +80,7 @@ fn is_ignored(path: &str, ignored: &[String]) -> bool {
///
/// Falls back to the parent directory name (e.g. `mingling_core/Cargo.toml` →
/// `mingling_core`, workspace root → `(root)`), matching the legacy CI.
-pub(crate) fn package_name(path: &Path) -> String {
+fn package_name(path: &Path) -> String {
let fallback = || {
path.parent()
.and_then(|p| p.file_name())