report: add BuildView/Prompter ports and drive pkh build through them

Core flows no longer reach into the terminal UI: build_source_package
takes a BuildSourceOptions struct (source tree, domain options, view,
prompter) and reports phases, messages and outcomes through the
environment-agnostic ports in the new report module. The classifiers
move from ui/logfmt to the core logfmt module, DebUi becomes a
BuildView adapter, the re-vendor retry asks the prompter instead of
checking for a TTY, and artifact/success printing moves to the CLI.

Headless consumers pass report::Quiet; an embedding (e.g. a builder
server forwarding events to a web frontend) implements BuildView and
maps the plain-data events onto its own wire format.
This commit is contained in:
2026-09-18 20:02:56 +02:00
parent a7d2cfdc6e
commit 27b1083b15
11 changed files with 335 additions and 179 deletions
+640
View File
@@ -0,0 +1,640 @@
//! 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::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.
#[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());
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("make[")
&& (line.contains("Entering directory") || line.contains("Leaving directory"))
{
return vec![Action::Hidden];
}
if line.contains("error:")
|| line.contains("Error ")
|| line.contains("*** [")
|| line.contains("failed")
{
return vec![Action::Error(truncate(line))];
}
if line.starts_with("dh_")
|| line.contains("gcc ")
|| line.contains("g++ ")
|| line.contains("cc ")
|| line.contains("clang")
|| line.contains("ld ")
|| line.contains("ar ")
{
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_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");
}
}