Files
pkh/src/logfmt.rs
T
vhaudiquet 8cf9489072 logfmt: teach the make classifier kbuild status tags and warnings
Kernel builds abbreviate every invocation to an uppercase status tag
(CC, LD, HOSTCC, MODPOST, ...) instead of printing the full command
line, so the substring rules that catch classic make output never
fired and the rolling pane went static for the whole kbuild stream.
Recognize the common tags after leading whitespace and show those
lines.

Also surface warnings instead of hiding them: gcc's lowercase
'file.c:12:3: warning:' diagnostics and kbuild's line-leading
'WARNING:' banners (modpost, etc.) now map to Action::Warning.
2026-09-29 16:35:30 +02:00

729 lines
24 KiB
Rust

//! Line classifiers rewriting raw subprocess output for the live UI
//!
//! Each classifier is a small stateful machine fed every captured line of a
//! build phase; it decides what to display (rewritten lines, warnings,
//! errors) and whether the line carries countable progress. Classifiers are
//! pure with respect to the UI: they only return [`Action`]s.
use std::sync::{LazyLock, OnceLock};
use crate::context::Stream;
use regex::Regex;
/// Maximum length of a rewritten line displayed in the rolling pane
pub(crate) const MAX_LINE_WIDTH: usize = 120;
/// What a classifier decided to do with a captured line
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// Drop the line (noise)
Hidden,
/// Display a rewritten line in the rolling pane
Shown(String),
/// Display a warning line (yellow)
Warning(String),
/// Display an error line (red, sticky)
Error(String),
/// Update the determinate progress bar
Progress {
/// Current position
pos: u64,
/// Total number of items (0 = unknown)
total: u64,
},
}
/// Stateful classifier turning raw subprocess lines into UI actions
pub trait Classifier: Send {
/// Feed one captured line, returning the actions it produces
fn feed(&mut self, stream: Stream, line: &str) -> Vec<Action>;
}
/// Truncate a line to [`MAX_LINE_WIDTH`], appending an ellipsis if cut
pub(crate) fn truncate(line: &str) -> String {
if line.chars().count() <= MAX_LINE_WIDTH {
line.to_string()
} else {
let cut: String = line.chars().take(MAX_LINE_WIDTH - 1).collect();
format!("{}…", cut.trim_end())
}
}
/// Classify apt-style severity prefixes (`E:` / `W:`)
fn apt_severity(line: &str) -> Option<Action> {
if line.starts_with("E:") {
Some(Action::Error(truncate(line)))
} else if line.starts_with("W:") {
Some(Action::Warning(truncate(line)))
} else {
None
}
}
/// Classifier for `apt-get update` output
///
/// Collapses `Get:/Hit:/Ign:` lines into a running source counter and always
/// surfaces errors and warnings.
#[derive(Default)]
pub struct AptUpdateClassifier {
sources: u64,
}
impl AptUpdateClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for AptUpdateClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
if line.starts_with("Get:") || line.starts_with("Hit:") || line.starts_with("Ign:") {
self.sources += 1;
vec![Action::Shown(format!(
"Updating package lists… ({} sources)",
self.sources
))]
} else if let Some(severity) = apt_severity(line) {
vec![severity]
} else {
vec![Action::Hidden]
}
}
}
/// Classifier for `apt-get install` / `apt-get build-dep` output
///
/// Parses the upfront summary ("N upgraded, M newly installed, …") to derive
/// a total, then counts `Unpacking`/`Setting up` lines to drive a determinate
/// progress bar.
#[derive(Default)]
pub struct AptInstallClassifier {
label: String,
total: u64,
done: u64,
}
impl AptInstallClassifier {
/// Create a classifier for an install phase labeled `label`
pub fn new(label: &str) -> Self {
Self {
label: label.to_string(),
..Default::default()
}
}
fn progress(&self) -> Action {
if self.total > 0 {
Action::Progress {
pos: self.done.min(self.total),
total: self.total,
}
} else {
Action::Hidden
}
}
}
impl Classifier for AptInstallClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
static SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
let summary_re = SUMMARY_RE.get_or_init(|| {
Regex::new(r"(\d+) (?:upgraded|newly installed|re-installed)").unwrap()
});
static UNPACK_RE: OnceLock<Regex> = OnceLock::new();
let unpack_re =
UNPACK_RE.get_or_init(|| Regex::new(r"^Unpacking ([^ ]+) \(([^)]+)\)").unwrap());
static SETUP_RE: OnceLock<Regex> = OnceLock::new();
let setup_re =
SETUP_RE.get_or_init(|| Regex::new(r"^Setting up ([^ ]+) \(([^)]+)\)").unwrap());
if summary_re.is_match(line) && !self.label.is_empty() {
// Only accept the summary once: later lines may repeat counts
if self.total == 0 {
let total: u64 = summary_re
.captures_iter(line)
.filter_map(|c| c[1].parse::<u64>().ok())
.sum();
self.total = total;
vec![Action::Shown(format!("{}: {} packages", self.label, total))]
} else {
vec![Action::Hidden]
}
} else if let Some(caps) = unpack_re.captures(line) {
self.done += 1;
vec![
Action::Shown(format!(
"{}: unpacking {} ({})",
self.label, &caps[1], &caps[2]
)),
self.progress(),
]
} else if let Some(caps) = setup_re.captures(line) {
self.done += 1;
vec![
Action::Shown(format!(
"{}: setting up {} ({})",
self.label, &caps[1], &caps[2]
)),
self.progress(),
]
} else if let Some(severity) = apt_severity(line) {
vec![severity]
} else {
vec![Action::Hidden]
}
}
}
/// Classifier for `quilt push -a` output
///
/// Driven by the number of patches listed in `debian/patches/series`, known
/// before the command runs.
pub struct QuiltClassifier {
total: u64,
applied: u64,
}
impl QuiltClassifier {
/// Create a classifier expecting `total` patches
pub fn new(total: usize) -> Self {
Self {
total: total as u64,
applied: 0,
}
}
}
impl Classifier for QuiltClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
static APPLYING_RE: OnceLock<Regex> = OnceLock::new();
let applying_re =
APPLYING_RE.get_or_init(|| Regex::new(r"^Applying patch ([^ ]+)").unwrap());
if line.contains("failed") || line.contains("Failed") {
// Check failures first: "Applying patch x failed" must not be
// counted as a successful application
vec![Action::Error(truncate(line))]
} else if let Some(caps) = applying_re.captures(line) {
self.applied += 1;
let mut actions = vec![Action::Shown(format!("Applying patch {}", &caps[1]))];
if self.total > 0 {
actions.push(Action::Progress {
pos: self.applied.min(self.total),
total: self.total,
});
}
actions
} else if line.starts_with("Now at patch") {
vec![Action::Shown(truncate(line))]
} else {
vec![Action::Hidden]
}
}
}
/// Classifier for make/cmake-based builds (`debian/rules build`, dh helpers)
///
/// Detects `[ 42%]`-style progress markers, hides directory enter/leave
/// noise, and shows compile/link/dh lines as well as kbuild status-tag
/// lines and warnings.
#[derive(Default)]
pub struct MakeClassifier {}
impl MakeClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for MakeClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
static PERCENT_RE: OnceLock<Regex> = OnceLock::new();
let percent_re = PERCENT_RE.get_or_init(|| Regex::new(r"\[\s*(\d+)%\]").unwrap());
// kbuild abbreviates each invocation to an uppercase status tag
// (` CC [M] drivers/x.o`, ` HOSTCC scripts/foo`) instead of
// printing the full command line, so the classic-make substring
// rules below never fire for kernel builds; this tag list is the
// kbuild counterpart of those substrings.
static KBUILD_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\s*(CC|HOSTCC|HOSTLD|HOSTAR|LD|AR|AS|OBJCOPY|STRIP|GEN|UPD|DESCEND|MODPOST|MODORDER|BTF|SHIPPED|INSTALL|INSTALLMOD|DEPMOD|SIGN|WRAP|SYSHDR|SYSTBL|KSYMS)\b",
)
.unwrap()
});
if let Some(caps) = percent_re.captures(line) {
let pct: u64 = caps[1].parse().unwrap_or(0);
return vec![
Action::Shown(truncate(line)),
Action::Progress {
pos: pct,
total: 100,
},
];
}
if line.contains("error:")
|| line.contains("Error ")
|| line.contains("*** [")
|| line.contains("failed")
{
return vec![Action::Error(truncate(line))];
}
// gcc-style diagnostics (`file.c:12:3: warning: ...`) and kbuild's
// uppercase banners (`WARNING: modpost: ...`) must not be drowned
// out by the activity checks below.
if line.contains("warning:") || line.trim_start().starts_with("WARNING:") {
return vec![Action::Warning(truncate(line))];
}
if line.contains("make[")
&& (line.contains("Entering directory") || line.contains("Leaving directory"))
{
return vec![Action::Hidden];
}
if line.starts_with("dh_")
|| line.contains("gcc ")
|| line.contains("g++ ")
|| line.contains("cc ")
|| line.contains("clang")
|| line.contains("ld ")
|| line.contains("ar ")
|| KBUILD_TAG_RE.is_match(line)
{
return vec![Action::Shown(truncate(line))];
}
vec![Action::Hidden]
}
}
/// Classifier for `dpkg-source` output (source-build phases)
///
/// The build pipeline pins `LC_ALL=C`, so dpkg-source emits stable English
/// messages prefixed with `info:` / `warning:` / `error:`; the prefix is
/// stripped and the severity drives the pane color. Raw `tar:` diagnostics
/// emitted while repacking tarballs are surfaced too.
#[derive(Default)]
pub struct DpkgSourceClassifier {}
impl DpkgSourceClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for DpkgSourceClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
const PREFIX: &str = "dpkg-source: ";
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
if let Some(rest) = rest.strip_prefix("info: ") {
vec![Action::Shown(truncate(rest))]
} else if let Some(rest) = rest.strip_prefix("warning: ") {
vec![Action::Warning(truncate(rest))]
} else if let Some(rest) = rest.strip_prefix("error: ") {
vec![Action::Error(truncate(rest))]
} else if let Some(tar) = rest.strip_prefix("tar: ") {
// Diagnostics from the tarball repacking subprocess; warnings
// about unknown header keywords are benign, real failures are not.
let lower = tar.to_lowercase();
if ["error", "cannot", "failed", "exited"]
.iter()
.any(|m| lower.contains(m))
{
vec![Action::Error(truncate(tar))]
} else {
vec![Action::Warning(truncate(tar))]
}
} else if line == PREFIX.trim_end() || rest.is_empty() {
vec![Action::Hidden]
} else {
// Unprefixed output from a foreign subprocess: keep it visible
vec![Action::Shown(truncate(line))]
}
}
}
/// Classifier for `mmdebstrap` output (chroot tarball creation)
///
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
/// else is chroot-internal apt/dpkg noise.
#[derive(Default)]
pub struct MmdebstrapClassifier {}
impl MmdebstrapClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for MmdebstrapClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
if let Some(rest) = line.strip_prefix("I: ") {
vec![Action::Shown(truncate(rest))]
} else if let Some(rest) = line.strip_prefix("W: ") {
vec![Action::Warning(truncate(rest))]
} else if let Some(rest) = line.strip_prefix("E: ") {
vec![Action::Error(truncate(rest))]
} else if line.starts_with("Setting up ") {
vec![Action::Shown(truncate(line))]
} else {
vec![Action::Hidden]
}
}
}
/// Generic fallback classifier: shows the last meaningful line and surfaces
/// obvious error/warning patterns.
#[derive(Default)]
pub struct GenericClassifier {}
impl GenericClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for GenericClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
if line.starts_with("E:")
|| line.contains("error:")
|| line.contains("Error ")
|| line.contains("failed")
{
vec![Action::Error(truncate(line))]
} else if line.starts_with("W:") {
vec![Action::Warning(truncate(line))]
} else {
vec![Action::Shown(truncate(line))]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_one(c: &mut dyn Classifier, line: &str) -> Vec<Action> {
c.feed(Stream::Stdout, line)
}
#[test]
fn test_apt_update_collapses_sources_and_surfaces_errors() {
let mut c = AptUpdateClassifier::new();
assert_eq!(
feed_one(&mut c, "Hit:1 http://archive.ubuntu.com noble InRelease"),
vec![Action::Shown(
"Updating package lists… (1 sources)".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"Get:2 http://security.ubuntu.com noble-security InRelease"
),
vec![Action::Shown(
"Updating package lists… (2 sources)".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "E: Repository 'x' changed its 'suite' value"),
vec![Action::Error(
"E: Repository 'x' changed its 'suite' value".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "Reading package lists..."),
vec![Action::Hidden]
);
}
#[test]
fn test_apt_install_counts_packages() {
let mut c = AptInstallClassifier::new("Installing build dependencies");
// Summary line sets the total
assert_eq!(
feed_one(
&mut c,
"2 upgraded, 3 newly installed, 0 to remove and 0 not upgraded."
),
vec![Action::Shown(
"Installing build dependencies: 5 packages".to_string()
)]
);
// Unpacking and setting up drive progress
assert_eq!(
feed_one(&mut c, "Unpacking libfoo (1.2-3)"),
vec![
Action::Shown(
"Installing build dependencies: unpacking libfoo (1.2-3)".to_string()
),
Action::Progress { pos: 1, total: 5 }
]
);
assert_eq!(
feed_one(&mut c, "Setting up libfoo (1.2-3)"),
vec![
Action::Shown(
"Installing build dependencies: setting up libfoo (1.2-3)".to_string()
),
Action::Progress { pos: 2, total: 5 }
]
);
}
#[test]
fn test_quilt_counts_patches() {
let mut c = QuiltClassifier::new(2);
assert_eq!(
feed_one(&mut c, "Applying patch debian/patches/foo.patch"),
vec![
Action::Shown("Applying patch debian/patches/foo.patch".to_string()),
Action::Progress { pos: 1, total: 2 }
]
);
assert_eq!(
feed_one(&mut c, "Applying patch debian/patches/bar.patch"),
vec![
Action::Shown("Applying patch debian/patches/bar.patch".to_string()),
Action::Progress { pos: 2, total: 2 }
]
);
assert_eq!(
feed_one(&mut c, "Applying patch x failed"),
vec![Action::Error("Applying patch x failed".to_string())]
);
}
#[test]
fn test_make_detects_percent_and_hides_noise() {
let mut c = MakeClassifier::new();
assert_eq!(
feed_one(
&mut c,
"[ 42%] Building CXX object CMakeFiles/hello.dir/hello.o"
),
vec![
Action::Shown(
"[ 42%] Building CXX object CMakeFiles/hello.dir/hello.o".to_string()
),
Action::Progress {
pos: 42,
total: 100
}
]
);
assert_eq!(
feed_one(&mut c, "make[2]: Entering directory '/tmp/build'"),
vec![Action::Hidden]
);
assert_eq!(
feed_one(&mut c, "make[1]: *** [Makefile:531: hello.o] Error 1"),
vec![Action::Error(
"make[1]: *** [Makefile:531: hello.o] Error 1".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "dh_auto_build"),
vec![Action::Shown("dh_auto_build".to_string())]
);
}
#[test]
fn test_make_shows_kbuild_status_tags() {
let mut c = MakeClassifier::new();
assert_eq!(
feed_one(&mut c, " CC [M] drivers/net/foo.o"),
vec![Action::Shown(" CC [M] drivers/net/foo.o".to_string())]
);
assert_eq!(
feed_one(&mut c, " LD [M] drivers/bar.ko"),
vec![Action::Shown(" LD [M] drivers/bar.ko".to_string())]
);
assert_eq!(
feed_one(&mut c, " HOSTCC scripts/tool"),
vec![Action::Shown(" HOSTCC scripts/tool".to_string())]
);
assert_eq!(
feed_one(&mut c, " MODPOST 1 modules"),
vec![Action::Shown(" MODPOST 1 modules".to_string())]
);
// kbuild tag lines still roll the pane while make progress markers
// update the progress bar
assert_eq!(
feed_one(&mut c, "[ 45%] Building C object x.o"),
vec![
Action::Shown("[ 45%] Building C object x.o".to_string()),
Action::Progress {
pos: 45,
total: 100
}
]
);
// plain noise stays hidden
assert_eq!(
feed_one(&mut c, "test -x debian/scripts/foo"),
vec![Action::Hidden]
);
}
#[test]
fn test_make_shows_warnings_and_keeps_regressions() {
let mut c = MakeClassifier::new();
assert_eq!(
feed_one(&mut c, "drivers/x.c:12:3: warning: unused variable 'y'"),
vec![Action::Warning(
"drivers/x.c:12:3: warning: unused variable 'y'".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "WARNING: modpost: missing MODULE_LICENSE"),
vec![Action::Warning(
"WARNING: modpost: missing MODULE_LICENSE".to_string()
)]
);
// configure-stage probe lines embed the toolchain name and must
// stay visible
assert_eq!(
feed_one(
&mut c,
"checking whether riscv64-linux-gnu-gcc understands -c and -o together... (cached) yes"
),
vec![Action::Shown(
"checking whether riscv64-linux-gnu-gcc understands -c and -o together... (cached) yes"
.to_string()
)]
);
}
#[test]
fn test_mmdebstrap_prefixes() {
let mut c = MmdebstrapClassifier::new();
assert_eq!(
feed_one(&mut c, "I: chroot architecture is amd64"),
vec![Action::Shown("chroot architecture is amd64".to_string())]
);
assert_eq!(
feed_one(&mut c, "W: some warning"),
vec![Action::Warning("some warning".to_string())]
);
assert_eq!(
feed_one(&mut c, "Get:1 http://x InRelease"),
vec![Action::Hidden]
);
}
#[test]
fn test_generic_shows_lines_and_errors() {
let mut c = GenericClassifier::new();
assert_eq!(
feed_one(&mut c, "some random output"),
vec![Action::Shown("some random output".to_string())]
);
assert_eq!(
feed_one(&mut c, "something failed badly"),
vec![Action::Error("something failed badly".to_string())]
);
}
#[test]
fn test_dpkg_source_severity_prefixes() {
let mut c = DpkgSourceClassifier::new();
assert_eq!(
feed_one(
&mut c,
"dpkg-source: info: using patch list from debian/patches/series"
),
vec![Action::Shown(
"using patch list from debian/patches/series".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: info: applying patch debian/patches/reproducible.patch"
),
vec![Action::Shown(
"applying patch debian/patches/reproducible.patch".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: info: building hello in ../hello_2.10-5.dsc"
),
vec![Action::Shown(
"building hello in ../hello_2.10-5.dsc".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: warning: upstream signing key but no upstream signature"
),
vec![Action::Warning(
"upstream signing key but no upstream signature".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: error: unrepresentable changes to source"
),
vec![Action::Error(
"unrepresentable changes to source".to_string()
)]
);
}
#[test]
fn test_dpkg_source_tar_and_unknown_lines() {
let mut c = DpkgSourceClassifier::new();
// Benign tar header-keyword warnings stay yellow
assert_eq!(
feed_one(
&mut c,
"tar: Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'"
),
vec![Action::Warning(
"Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'".to_string()
)]
);
// Real tar failures are errors
assert_eq!(
feed_one(
&mut c,
"tar: ../hello_2.10.orig.tar.xz: Cannot open: No such file or directory"
),
vec![Action::Error(
"../hello_2.10.orig.tar.xz: Cannot open: No such file or directory".to_string()
)]
);
// Unprefixed foreign output stays visible
assert_eq!(
feed_one(&mut c, "gpgv: Signature made Tue 01 Jan 2026"),
vec![Action::Shown(
"gpgv: Signature made Tue 01 Jan 2026".to_string()
)]
);
}
#[test]
fn test_truncate_long_lines() {
let long = "x".repeat(300);
let truncated = truncate(&long);
assert_eq!(truncated.chars().count(), MAX_LINE_WIDTH);
assert!(truncated.ends_with('…'));
assert_eq!(truncate("short"), "short");
}
}