From ec9edc294fd5e7e29977fc7b0e6fb953422bc0e2 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Wed, 19 Aug 2026 05:54:00 +0800 Subject: chore: reorganize dev tools into dev/ directory and consolidate configs Move CI, dev tools, and configs from root-level scattered locations into a unified `dev/` directory structure. Update all references across build scripts, documentation, and editor configurations. Also consolidate editor config generation into build.rs for automated synchronization of rust-analyzer settings across VS Code and Zed. --- dev/ci/src/res/collect_logs.rs | 203 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 dev/ci/src/res/collect_logs.rs (limited to 'dev/ci/src/res/collect_logs.rs') diff --git a/dev/ci/src/res/collect_logs.rs b/dev/ci/src/res/collect_logs.rs new file mode 100644 index 0000000..6017168 --- /dev/null +++ b/dev/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>, + /// `(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) { + 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"), "你好世界!"); + } +} -- cgit