aboutsummaryrefslogtreecommitdiff
path: root/mingling_ci/src/res
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_ci/src/res')
-rw-r--r--mingling_ci/src/res/collect_logs.rs203
-rw-r--r--mingling_ci/src/res/crate_config.rs79
-rw-r--r--mingling_ci/src/res/features.rs47
-rw-r--r--mingling_ci/src/res/manifests.rs103
-rw-r--r--mingling_ci/src/res/print.rs174
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,
+}