//! 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"), "你好世界!"); } }