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:
+640
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user