A rules chown failing for the unprivileged build user is expected and harmless (see the revert of the build-step fakeroot wrap), but a sysroot tree can print thousands of 'chown: changing ownership of ...: Operation not permitted' lines. MakeClassifier now shows one warning digest for the flood and hides the rest; every other line, make errors included, keeps its classification. The full tee in the record's build.log is untouched.
800 lines
26 KiB
Rust
800 lines
26 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. 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<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()
|
|
});
|
|
|
|
static CHOWN_EPERM_RE: LazyLock<Regex> = 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<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())]
|
|
);
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|