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:
+71
-27
@@ -3,7 +3,7 @@
|
||||
//! ("a terminal in the terminal").
|
||||
//!
|
||||
//! Subprocess output is captured through a [`LineSink`] implementation,
|
||||
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
|
||||
//! rewritten by classifiers ([`crate::logfmt`]) and rendered in place
|
||||
//! with indicatif, so pkh's own log lines keep printing above the widget via
|
||||
//! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log
|
||||
//! file under the pkh cache directory.
|
||||
@@ -21,7 +21,7 @@ use directories::ProjectDirs;
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
|
||||
use crate::context::{LineSink, Stream};
|
||||
use crate::ui::logfmt::{
|
||||
use crate::logfmt::{
|
||||
Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier,
|
||||
MakeClassifier, MmdebstrapClassifier, QuiltClassifier,
|
||||
};
|
||||
@@ -130,8 +130,10 @@ struct Shared {
|
||||
/// Live build view for `pkh deb` / `pkh build`
|
||||
///
|
||||
/// Create one per build (disabled automatically when stdout is not a TTY or
|
||||
/// when the user requests verbose output), pass it down as
|
||||
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`].
|
||||
/// when the user requests verbose output), then either pass it down through
|
||||
/// the [`crate::report::BuildView`] port (source builds) or as
|
||||
/// `Option<Arc<DebUi>>` (the `pkh deb` flow, until it migrates), and feed
|
||||
/// subprocess output through [`DebUi::sink`].
|
||||
pub struct DebUi {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
@@ -213,17 +215,6 @@ impl DebUi {
|
||||
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
|
||||
}
|
||||
|
||||
/// Identify the source package being built; names the log file
|
||||
/// (`build-<package>-<version>-<timestamp>.log`) and the status bar
|
||||
pub fn set_build_target(&self, package: &str, version: &str, distribution: &str) {
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building source package {package} ({version}) for {distribution}"
|
||||
));
|
||||
}
|
||||
self.open_log("build", package, version, &format!("for {distribution}"));
|
||||
}
|
||||
|
||||
/// Rename the placeholder log file to include the build identity
|
||||
/// (best-effort), then open it so subsequent captured lines are tee'd
|
||||
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
|
||||
@@ -272,9 +263,10 @@ impl DebUi {
|
||||
self.phase_custom(phase.label(), classifier);
|
||||
}
|
||||
|
||||
/// Switch to an arbitrary status label with a custom classifier; used by
|
||||
/// flows whose phases are not part of [`Phase`] (e.g. source builds)
|
||||
pub fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
||||
/// Switch to an arbitrary status label with a custom classifier; the
|
||||
/// shared implementation behind [`DebUi::phase`], [`DebUi::phase_with`]
|
||||
/// and the [`BuildView`] port
|
||||
fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
|
||||
{
|
||||
let mut st = self.shared.state.lock().unwrap();
|
||||
st.classifier = classifier;
|
||||
@@ -316,12 +308,6 @@ impl DebUi {
|
||||
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Whether the widget renders at all (false on non-TTY stdout); callers
|
||||
/// use this to fall back to plain-line summaries
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.shared.enabled
|
||||
}
|
||||
|
||||
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
|
||||
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
|
||||
Arc::new(Sink {
|
||||
@@ -351,6 +337,18 @@ impl DebUi {
|
||||
/// Clear the widget and print a success summary with the artifacts,
|
||||
/// rendered relative to the working directory when possible
|
||||
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
||||
self.success_summary(artifacts, elapsed);
|
||||
}
|
||||
|
||||
/// Clear the widget and print a failure summary (recent captured errors
|
||||
/// and the path to the full log)
|
||||
pub fn finish_failure(&self) {
|
||||
self.failure_summary();
|
||||
}
|
||||
|
||||
/// Shared body of the success outcome ([`DebUi::finish_success`] and the
|
||||
/// [`BuildView`] port)
|
||||
fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) {
|
||||
self.suspend();
|
||||
if self.shared.enabled && !artifacts.is_empty() {
|
||||
println!("Built in {}s:", elapsed.as_secs());
|
||||
@@ -360,9 +358,9 @@ impl DebUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the widget and print a failure summary (recent captured errors
|
||||
/// and the path to the full log)
|
||||
pub fn finish_failure(&self) {
|
||||
/// Shared body of the failure outcome ([`DebUi::finish_failure`] and the
|
||||
/// [`BuildView`] port)
|
||||
fn failure_summary(&self) {
|
||||
self.suspend();
|
||||
|
||||
let st = self.shared.state.lock().unwrap();
|
||||
@@ -397,6 +395,52 @@ impl DebUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::report::BuildView`] port: forwards build events to the live
|
||||
/// widget, so core flows can drive the view without knowing it is a
|
||||
/// terminal widget. Where the inherent methods keep their richer signatures
|
||||
/// (e.g. [`DebUi::finish_success`] takes the elapsed duration explicitly for
|
||||
/// the `pkh deb` flow), the port bridges them.
|
||||
impl crate::report::BuildView for DebUi {
|
||||
fn target(&self, package: &str, version: &str, target: &str) {
|
||||
if self.shared.enabled {
|
||||
self.shared.top.set_prefix(format!(
|
||||
"Building source package {package} ({version}) for {target}"
|
||||
));
|
||||
}
|
||||
self.open_log("build", package, version, &format!("for {target}"));
|
||||
}
|
||||
|
||||
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
|
||||
self.phase_custom(name, classifier);
|
||||
}
|
||||
|
||||
fn message(&self, text: &str) {
|
||||
self.progress_message(text);
|
||||
}
|
||||
|
||||
fn progress(&self, label: &str, pos: usize, total: usize) {
|
||||
self.count_progress(label, pos, total);
|
||||
}
|
||||
|
||||
fn sink(&self) -> Option<Arc<dyn LineSink>> {
|
||||
Some(Arc::new(Sink {
|
||||
shared: self.shared.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn finish_success(&self, artifacts: &[PathBuf]) {
|
||||
self.success_summary(artifacts, self.elapsed());
|
||||
}
|
||||
|
||||
fn finish_failure(&self) {
|
||||
self.failure_summary();
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.shared.enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DebUi {
|
||||
fn drop(&mut self) {
|
||||
// Safety net: clear the widget on early returns/unwinds
|
||||
|
||||
@@ -1,640 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,17 @@ pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::E
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::report::Prompter`] answered by the interactive terminal: each
|
||||
/// question runs the matching raw-mode prompt, falling back to the default
|
||||
/// answer when no interactive terminal is attached (CI, piped input).
|
||||
pub struct TerminalPrompter;
|
||||
|
||||
impl crate::report::Prompter for TerminalPrompter {
|
||||
fn confirm(&self, question: &str, default: bool) -> bool {
|
||||
confirm(question, default).unwrap_or(default)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `prompt` with the terminal in raw mode, always restoring it
|
||||
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
|
||||
/// enabled.
|
||||
|
||||
Reference in New Issue
Block a user