From c9b48d45735c574a72c1d80bf8f08aa87f9089bc Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Mon, 24 Aug 2026 11:56:16 +0200 Subject: [PATCH] build: live view for source builds, pin LC_ALL for the pipeline Route 'pkh build' through the DebUi capture machinery 'pkh deb' already uses instead of letting dpkg-source inherit the terminal: - pin LANG=C and LC_ALL=C so dpkg-source emits deterministic English diagnostics regardless of the session locale; - new DpkgSourceClassifier rewrites info:/warning:/error: lines into colored pane entries, telling benign tar warnings from failures; - DebUi generalizes for reuse (arbitrary phase labels, build-specific log naming); run_source_build() drives phases and pipes subprocess output through the sink when a UI is present; - glyph-free house-style summaries: 'Built in Ns:' plus artifact paths relative to cwd; failures print captured errors + log path; - drop/capitalize pipeline chatter, add 'pkh build --verbose' to bypass the view like 'pkh deb --verbose'. --- src/build/env.rs | 12 +++- src/build/mod.rs | 159 ++++++++++++++++++++++++++++++++++++++------- src/context/mod.rs | 2 +- src/main.rs | 23 ++++++- src/ui.rs | 19 ++++++ src/ui/deb.rs | 65 ++++++++++++------ src/ui/logfmt.rs | 130 ++++++++++++++++++++++++++++++++++++ 7 files changed, 360 insertions(+), 50 deletions(-) diff --git a/src/build/env.rs b/src/build/env.rs index 24ef5cd..e1cd431 100644 --- a/src/build/env.rs +++ b/src/build/env.rs @@ -12,20 +12,26 @@ pub fn num_parallel() -> usize { .unwrap_or(1) } -/// Compute the environment variables exported by `dpkg-buildpackage` before -/// running any build step. +/// Compute the environment variables exported before running any build step. /// /// Mirrors dpkg behavior: /// - `SOURCE_DATE_EPOCH` from the changelog entry timestamp /// (), /// - `DEB_BUILD_OPTIONS=parallel=N` (auto-detected job count), /// - `DEB_BUILD_PROFILES` when non-default profiles are requested. +/// +/// The locale is pinned to `C` (`LC_ALL`, which takes precedence over any +/// inherited session setting, plus `LANG`) so build tools emit deterministic, +/// English diagnostics — required for reliable log classification and +/// reproducible builds. pub fn build_env( source_date_epoch: i64, parallel: usize, build_profiles: &[String], ) -> BTreeMap { let mut env = BTreeMap::new(); + env.insert("LANG".to_string(), "C".to_string()); + env.insert("LC_ALL".to_string(), "C".to_string()); env.insert( "SOURCE_DATE_EPOCH".to_string(), source_date_epoch.to_string(), @@ -257,6 +263,8 @@ mod tests { #[test] fn build_env_values() { let env = build_env(1787392800, 16, &[]); + assert_eq!(env.get("LANG").unwrap(), "C"); + assert_eq!(env.get("LC_ALL").unwrap(), "C"); assert_eq!(env.get("SOURCE_DATE_EPOCH").unwrap(), "1787392800"); assert_eq!(env.get("DEB_BUILD_OPTIONS").unwrap(), "parallel=16"); assert!(!env.contains_key("DEB_BUILD_PROFILES")); diff --git a/src/build/mod.rs b/src/build/mod.rs index 574ceb2..66857ca 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -15,11 +15,16 @@ pub mod env; use std::collections::{BTreeMap, HashMap}; use std::error::Error; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use crate::context::capture::pump; +use crate::context::{LineSink, Stream}; use crate::debian::{ ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs, }; +use crate::ui::deb::DebUi; +use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier}; /// Options for a native source-package build. #[derive(Debug, Clone, Default)] @@ -52,11 +57,46 @@ pub struct SourceBuildOutput { /// Build a Debian source package (to a .dsc) using the native pipeline. /// -/// Keeps the historical pkh entry-point signature; see [`run_source_build`] -/// for the configurable version. -pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box> { +/// When `ui` is set, subprocess output is captured into a live view (status +/// bar + rolling pane) and tee'd to a log file; on failure the view prints a +/// summary of the last captured errors. Without a UI, commands inherit the +/// terminal as before. +pub fn build_source_package( + cwd: Option<&Path>, + ui: Option>, +) -> Result<(), Box> { let cwd = cwd.unwrap_or_else(|| Path::new(".")); - let output = run_source_build(cwd, &SourceBuildOptions::default())?; + let output = match run_source_build(cwd, &SourceBuildOptions::default(), ui.clone()) { + Ok(output) => output, + Err(e) => { + if let Some(u) = &ui { + u.finish_failure(); + } + return Err(e); + } + }; + + // Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes. + let mut artifacts = Vec::with_capacity(3 + output.tarballs.len()); + artifacts.push(output.dsc.clone()); + artifacts.extend(output.tarballs.iter().cloned()); + artifacts.push(output.buildinfo.clone()); + artifacts.push(output.changes.clone()); + + // The live view lists the artifacts itself when it renders; otherwise + // (verbose mode or non-TTY stdout) print them as plain lines. + let listed = match &ui { + Some(u) if u.is_enabled() => { + u.finish_success(&artifacts, u.elapsed()); + true + } + _ => false, + }; + if !listed { + for artifact in &artifacts { + println!(" {}", crate::ui::display_path(artifact)); + } + } if output.signed { println!("Package built and signed successfully!"); @@ -81,7 +121,9 @@ pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box> { pub fn run_source_build( cwd: &Path, opts: &SourceBuildOptions, + ui: Option>, ) -> Result> { + let sink: Option> = ui.as_ref().map(|u| u.sink()); // ------------------------------------------------------------------ // 1. Sanity checks // ------------------------------------------------------------------ @@ -116,9 +158,9 @@ pub fn run_source_build( let entry = crate::debian::parse_changelog_entry(&changelog_path)?; let ctrl = ControlInfo::parse(&control_path)?; - log::info!("source package {}", entry.source); - log::info!("source version {}", entry.version.full()); - log::info!("source distribution {}", entry.distribution); + if let Some(u) = &ui { + u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution); + } // binNMU builds reference the *previous* (source) version in their // artifact metadata, like dpkg-genchanges/genbuildinfo do. @@ -167,19 +209,19 @@ pub fn run_source_build( if signing_key.is_none() { match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) { Ok(Some(key)) => { - log::info!("using GPG key {} for signing", key); + log::info!("Using GPG key {} for signing", key); signing_key = Some(key); } Ok(None) => { log::warn!( - "no GPG secret key found for {} <{}>, building without signing", + "No GPG secret key found for {} <{}>, building without signing", entry.maintainer_name, entry.maintainer_email ); } Err(e) => { log::warn!( - "failed to check for GPG key: {}, building without signing", + "Failed to check for GPG key: {}, building without signing", e ); } @@ -188,7 +230,7 @@ pub fn run_source_build( let do_sign = match &signing_key { None => false, Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => { - log::warn!("not signing UNRELEASED build; use force_sign to override"); + log::warn!("Not signing UNRELEASED build; use force_sign to override"); false } Some(_) => true, @@ -197,17 +239,24 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 5. dpkg-source lifecycle: before-build + source build // ------------------------------------------------------------------ + if let Some(u) = &ui { + u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new())); + } run_command( cwd, "dpkg-source", &["-I", "-i", "--before-build", "."], &pipeline_env, + sink.as_ref(), )?; // Build-dependency check (native dpkg-checkbuilddeps equivalent). // dpkg-buildpackage skips it entirely for source-only builds unless // forced with -D; unsatisfied dependencies abort with exit status 3. if opts.force_dep_check { + if let Some(u) = &ui { + u.progress_message("Checking build dependencies"); + } let check_opts = crate::debian::deps::CheckOpts { host_arch: arch_vars .get("DEB_HOST_ARCH") @@ -225,7 +274,19 @@ pub fn run_source_build( } } - run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?; + if let Some(u) = &ui { + u.phase_custom( + "Building source package", + Box::new(DpkgSourceClassifier::new()), + ); + } + run_command( + cwd, + "dpkg-source", + &["-I", "-i", "-b", "."], + &pipeline_env, + sink.as_ref(), + )?; if !dsc_path.exists() { return Err(format!( @@ -259,6 +320,9 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 6. .buildinfo generation (native dpkg-genbuildinfo equivalent) // ------------------------------------------------------------------ + if let Some(u) = &ui { + u.progress_message("Generating .buildinfo"); + } let mut checksums = FileChecksums::new(); checksums.add_file_as(&ref_dsc_path, &ref_dsc_name)?; @@ -304,6 +368,9 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 7. .changes generation (native dpkg-genchanges equivalent) // ------------------------------------------------------------------ + if let Some(u) = &ui { + u.progress_message("Generating .changes"); + } // Pull the tarball checksums out of the referenced .dsc so they are // distributed through the .changes like dpkg-genchanges does, in the // order the .dsc itself lists them. @@ -416,11 +483,15 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 8. dpkg-source after-build (unapplies quilt patches it applied) // ------------------------------------------------------------------ + if let Some(u) = &ui { + u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new())); + } run_command( cwd, "dpkg-source", &["-I", "-i", "--after-build", "."], &pipeline_env, + sink.as_ref(), )?; // ------------------------------------------------------------------ @@ -430,7 +501,11 @@ pub fn run_source_build( if let Some(keyid) = signing_key.filter(|_| do_sign) { crate::utils::gpg::validate_key_id(&keyid)?; - println!("signfile {}", dsc_name); + if let Some(u) = &ui { + u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new())); + } + + log::info!("Signing {}", dsc_name); crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?; // The freshly built .dsc changed: refresh its checksums inside the // .buildinfo. For binary-only builds the metadata references the @@ -441,13 +516,13 @@ pub fn run_source_build( } buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?; - println!("signfile {}", buildinfo_name); + log::info!("Signing {}", buildinfo_name); crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?; // Both .dsc and .buildinfo changed: refresh the .changes. checksums.add_file_as(&buildinfo_path, &buildinfo_name)?; changes::save_changes(&changes_path, &render_changes_doc(&checksums))?; - println!("signfile {}", changes_name); + log::info!("Signing {}", changes_name); crate::utils::gpg::clearsign_file(&changes_path, &keyid)?; signed = true; @@ -472,13 +547,17 @@ struct PartialChecksum { } /// Run a build command in `cwd` with extra environment variables layered on -/// top of the inherited environment, with stdio attached to the terminal. +/// top of the inherited environment. +/// +/// When `sink` is set, stdout/stderr are piped and every line is forwarded to +/// it (live view + tee log); otherwise stdio is inherited from the terminal. /// Returns an error on non-zero exit status. fn run_command( cwd: &Path, program: &str, args: &[&str], env: &BTreeMap, + sink: Option<&Arc>, ) -> Result<(), Box> { log::debug!( "running: {} {} (in {})", @@ -486,12 +565,44 @@ fn run_command( args.join(" "), cwd.display() ); - let status = Command::new(program) - .current_dir(cwd) - .envs(env) - .args(args) - .status() - .map_err(|e| format!("failed to run '{}': {}", program, e))?; + + let mut cmd = Command::new(program); + cmd.current_dir(cwd).envs(env).args(args); + + let status = match sink { + None => cmd + .status() + .map_err(|e| format!("failed to run '{}': {}", program, e))?, + Some(sink) => { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to run '{}': {}", program, e))?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + // One reader thread per stream; interleaving across streams is + // approximate (channel arrival order), acceptable for display. + let out_sink = sink.clone(); + let err_sink = sink.clone(); + let out_thread = std::thread::spawn(move || { + if let Some(out) = stdout { + pump(out, Stream::Stdout, &*out_sink); + } + }); + let err_thread = std::thread::spawn(move || { + if let Some(err) = stderr { + pump(err, Stream::Stderr, &*err_sink); + } + }); + let _ = out_thread.join(); + let _ = err_thread.join(); + + child + .wait() + .map_err(|e| format!("failed to wait for '{}': {}", program, e))? + } + }; if !status.success() { return Err(format!( @@ -852,7 +963,7 @@ mod differential_tests { let ours_tree = ours_root.join(&tree_name); run_dpkg(&golden_tree); - run_source_build(&ours_tree, &SourceBuildOptions::default()) + run_source_build(&ours_tree, &SourceBuildOptions::default(), None) .expect("native source pipeline failed"); let entry = diff --git a/src/context/mod.rs b/src/context/mod.rs index 77643ab..862c217 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1,5 +1,5 @@ mod api; -mod capture; +pub(crate) mod capture; mod local; mod manager; mod schroot; diff --git a/src/main.rs b/src/main.rs index 9fc07a7..8c8b682 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,11 @@ fn main() { .arg(arg!(--backport "This changelog is for a backport entry").required(false)) .arg(arg!(-v --version "Target version").required(false)), ) - .subcommand(Command::new("build").about("Build the source package (into a .dsc)")) + .subcommand( + Command::new("build") + .about("Build the source package (into a .dsc)") + .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)), + ) .subcommand( Command::new("deb") .about("Build the source package into binary package (.deb)") @@ -248,9 +252,22 @@ fn main() { std::process::exit(1); }); } - Some(("build", _sub_matches)) => { + Some(("build", sub_matches)) => { let cwd = current_dir_or_exit(); - if let Err(e) = pkh::build::build_source_package(Some(&cwd)) { + let verbose = sub_matches + .get_one::("verbose") + .copied() + .unwrap_or(false); + + // Live build view: disabled by --verbose or when stdout is not a + // terminal (DebUi handles the non-TTY case itself) + let ui = if verbose { + None + } else { + Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi))) + }; + + if let Err(e) = pkh::build::build_source_package(Some(&cwd), ui) { error!("{}", e); // Unmet build dependencies/conflicts exit with status 3, // like dpkg-buildpackage does. diff --git a/src/ui.rs b/src/ui.rs index 71af7d2..85eca22 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,8 +13,27 @@ use crossterm::{ }; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::io::{self, Write}; +use std::path::Path; use std::time::Duration; +/// Render a path for terminal display: relative to the current working +/// directory when the target lives inside it or directly next to it +/// (`../name`, the usual layout of build artifacts), absolute otherwise. +pub fn display_path(path: &Path) -> String { + let Ok(cwd) = std::env::current_dir() else { + return path.display().to_string(); + }; + if let Ok(rel) = path.strip_prefix(&cwd) { + return rel.display().to_string(); + } + if let Some(parent) = cwd.parent() + && let Ok(rel) = path.strip_prefix(parent) + { + return format!("../{}", rel.display()); + } + path.display().to_string() +} + /// Create a spinner-style progress bar attached to `multi`, returning the bar /// and a callback compatible with [`crate::ProgressCallback`] pub fn create_progress_bar( diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 334d406..265c88f 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -1,6 +1,6 @@ -//! Live UI for `pkh deb`: a status bar with the current build phase on top -//! and a rolling pane of rewritten log lines below ("a terminal in the -//! terminal"). +//! Live build view (`pkh deb`, `pkh build`): a status bar with the current +//! build phase on top and a rolling pane of rewritten log lines below +//! ("a terminal in the terminal"). //! //! Subprocess output is captured through a [`LineSink`] implementation, //! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place @@ -127,7 +127,7 @@ struct Shared { started: Instant, } -/// Live build view for `pkh deb` +/// 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 @@ -202,20 +202,35 @@ impl DebUi { ui } - /// Identify the package being built; names the log file and the status bar + /// Identify the binary package being built; names the log file and the + /// status bar pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) { if self.shared.enabled { self.shared.top.set_prefix(format!( "Building {package} ({version}) for {series}/{arch}" )); } + self.open_log("deb", package, version, &format!("for {series}/{arch}")); + } - // Rename the log file to include the package identity (best-effort), - // then open it so subsequent captured lines are tee'd. + /// Identify the source package being built; names the log file + /// (`build---.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) { let old_path = self.shared.log_path.lock().unwrap().clone(); let log_path = match old_path.parent() { Some(dir) => dir.join(format!( - "deb-{package}-{version}-{}.log", + "{kind}-{package}-{version}-{}.log", self.shared.timestamp )), None => old_path.clone(), @@ -231,11 +246,7 @@ impl DebUi { Ok(mut file) => { let _ = writeln!( file, - "# pkh deb {} ({}) for {}/{} started {}", - package, - version, - series, - arch, + "# pkh {kind} {package} ({version}) {detail} started {}", chrono::Utc::now().to_rfc3339() ); *self.shared.tee.lock().unwrap() = Some(file); @@ -252,12 +263,18 @@ impl DebUi { /// Switch to a phase, installing its default classifier pub fn phase(&self, phase: Phase) { - self.phase_with(phase, default_classifier(phase)); + self.phase_custom(phase.label(), default_classifier(phase)); } /// Switch to a phase with a custom classifier (e.g. quilt with a known /// patch count) pub fn phase_with(&self, phase: Phase, classifier: Box) { + 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) { { let mut st = self.shared.state.lock().unwrap(); st.classifier = classifier; @@ -267,7 +284,7 @@ impl DebUi { } if self.shared.enabled { self.shared.top.set_style(spinner_style()); - self.shared.top.set_message(phase.label()); + self.shared.top.set_message(label.to_string()); self.shared.pane.set_message(""); } } @@ -299,6 +316,12 @@ 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) -> Arc { Arc::new(Sink { @@ -325,14 +348,15 @@ impl DebUi { self.shared.pane.finish_and_clear(); } - /// Clear the widget and print a success summary with the artifacts + /// 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.suspend(); if self.shared.enabled && !artifacts.is_empty() { + println!("Built in {}s:", elapsed.as_secs()); for artifact in artifacts { - println!(" → {}", artifact.display()); + println!(" {}", crate::ui::display_path(artifact)); } - println!(" ✔ Built in {}s", elapsed.as_secs()); } } @@ -494,12 +518,13 @@ fn is_stdout_tty() -> bool { unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 } } -/// Default log file path for a given timestamp +/// Default (placeholder) log file path for a given timestamp; renamed by +/// [`DebUi::set_target`] / [`DebUi::set_build_target`] once the target is known fn default_log_path(timestamp: &str) -> PathBuf { let dir = ProjectDirs::from("com", "pkh", "pkh") .map(|dirs| dirs.cache_dir().join("logs")) .unwrap_or_else(std::env::temp_dir); - dir.join(format!("deb-{timestamp}.log")) + dir.join(format!("pkh-{timestamp}.log")) } static SIGINT_LOG_PATH: Mutex> = Mutex::new(None); diff --git a/src/ui/logfmt.rs b/src/ui/logfmt.rs index f6f3738..cd5da90 100644 --- a/src/ui/logfmt.rs +++ b/src/ui/logfmt.rs @@ -282,6 +282,54 @@ impl Classifier for MakeClassifier { } } +/// 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 @@ -499,6 +547,88 @@ mod tests { ); } + #[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);