//! 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; } /// 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 { 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 { 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 { static SUMMARY_RE: OnceLock = OnceLock::new(); let summary_re = SUMMARY_RE.get_or_init(|| { Regex::new(r"(\d+) (?:upgraded|newly installed|re-installed)").unwrap() }); static UNPACK_RE: OnceLock = OnceLock::new(); let unpack_re = UNPACK_RE.get_or_init(|| Regex::new(r"^Unpacking ([^ ]+) \(([^)]+)\)").unwrap()); static SETUP_RE: OnceLock = 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::().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 { static APPLYING_RE: OnceLock = 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. Collapses the per-file `chown: ... Operation not /// permitted` flood of a rootless build into one digest line: a rules /// chown failing for the unprivileged build user is expected and harmless /// (the .deb is assembled in the binary step's fakeroot session, which /// records root ownership anyway), but a sysroot can emit thousands of /// lines. #[derive(Default)] pub struct MakeClassifier { /// How many chown-EPERM lines were collapsed so far. suppressed_chown: usize, } 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 { static PERCENT_RE: OnceLock = 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 = 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() }); static CHOWN_EPERM_RE: LazyLock = LazyLock::new(|| { Regex::new(r"^chown: (changing ownership|cannot read directory|cannot operate on)") .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 CHOWN_EPERM_RE.is_match(line) { self.suppressed_chown += 1; return if self.suppressed_chown == 1 { vec![Action::Warning(truncate( "chown: ownership changes skipped by the unprivileged build user \ (harmless for the .deb; further messages collapsed)", ))] } else { vec![Action::Hidden] }; } 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 { 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 { 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 { 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 { 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())] ); } /// A rootless build's per-file chown failures collapse into one /// digest warning; every other line keeps its normal classification. #[test] fn test_make_collapses_chown_eperm_flood() { let mut c = MakeClassifier::new(); let first = "chown: changing ownership of \ '/tmp/pkh-build/k3/demos/sysroot/usr/share/zoneinfo/Asia/Sao_Paulo': \ Operation not permitted"; assert_eq!( feed_one(&mut c, first), vec![Action::Warning( "chown: ownership changes skipped by the unprivileged build user \ (harmless for the .deb; further messages collapsed)" .to_string() )] ); assert_eq!( feed_one( &mut c, "chown: changing ownership of \ '/tmp/pkh-build/k3/demos/sysroot/usr/share/zoneinfo/Asia/Tokyo': \ Operation not permitted" ), vec![Action::Hidden] ); assert_eq!( feed_one( &mut c, "chown: cannot read directory '/tmp/pkh-build/k3/demos': Permission denied" ), vec![Action::Hidden] ); // A non-chown line keeps its own classification after the flood. assert_eq!( feed_one(&mut c, " CC [M] drivers/net/foo.o"), vec![Action::Shown(" CC [M] drivers/net/foo.o".to_string())] ); // The digest must not swallow real make errors. assert_eq!( feed_one(&mut c, "make[1]: *** [Makefile:531: all] Error 2"), vec![Action::Error( "make[1]: *** [Makefile:531: all] Error 2".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"); } }