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'.
This commit is contained in:
2026-08-24 11:58:19 +02:00
parent 429429e414
commit c9b48d4573
7 changed files with 360 additions and 50 deletions
+45 -20
View File
@@ -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-<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) {
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<dyn Classifier>) {
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>) {
{
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<Self>) -> Arc<dyn LineSink> {
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<Option<PathBuf>> = Mutex::new(None);
+130
View File
@@ -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<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
@@ -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);