Files
pkh/src/ui/deb.rs
T
vhaudiquet 6caedce61a report: reproduce the pre-refactor CLI output through the ports
Instead of carrying raw UI in core, the ports now represent everything
the CLI used to do inline:

- Prompter::present shows context outside of a question (the wizard
  summary screen, the vendoring notice spacing); TerminalPrompter
  prints it on stdout exactly like the println!s it replaces, server
  embeds forward it as a display event.
- generate_entry returns the generated entry (package, versions,
  series, path) instead of printing; the CLI renders the same lines.
- BuildTarget carries a flow-composed display line and a tee_log flag:
  the terminal adapter renders it verbatim ("Building source package
  ...", "Building ... for series/arch", "Uploading ... to ...") and
  uploads open no build log.
- The unmet build-dependency diagnostics are rendered by the CLI from
  the typed error, in the original order (details, then summary).
- --verbose constructs no live view at all (an idle widget used to
  linger), and the re-vendor offer only logs when it is actually
  asked, so headless runs print the error exactly once.
2026-09-19 00:52:31 +02:00

519 lines
17 KiB
Rust

//! 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::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.
use std::collections::VecDeque;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use crate::context::{LineSink, Stream};
use crate::logfmt::{Action, Classifier, GenericClassifier};
use crate::report::BuildTarget;
/// Number of lines displayed in the rolling pane
const PANE_LINES: usize = 10;
/// Minimum interval between pane redraws
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
/// Visual kind of a pane line, driving its color
#[derive(Debug, Clone, Copy, PartialEq)]
enum Kind {
Normal,
Warning,
Error,
}
/// Mutable state shared between the sink and the widget
struct Pipeline {
classifier: Box<dyn Classifier>,
lines: VecDeque<(Kind, String)>,
errors: Vec<String>,
last_draw: Instant,
bar_total: u64,
}
/// State shared between [`DebUi`] and its sinks
struct Shared {
top: ProgressBar,
pane: ProgressBar,
state: Mutex<Pipeline>,
tee: Mutex<Option<File>>,
log_path: Mutex<PathBuf>,
timestamp: String,
enabled: bool,
/// Set once the widget has been removed from the terminal; afterwards all
/// rendering is skipped so late events cannot redraw stale frames.
suspended: AtomicBool,
started: Instant,
}
/// Live build view for `pkh deb` / `pkh build`
///
/// Create one per build (it disables itself automatically when stdout is not
/// a TTY) and pass it down through the [`crate::report::BuildView`] port.
/// Subprocess output reaches it through [`crate::report::BuildView::sink`].
pub struct DebUi {
shared: Arc<Shared>,
}
impl DebUi {
/// Create a live view attached to `multi`
///
/// When stdout is not a TTY, the widget is disabled: no bars are drawn,
/// but captured output is still tee'd to the log file.
pub fn new(multi: &MultiProgress) -> Self {
let enabled = is_stdout_tty();
let top = if enabled {
let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_style(spinner_style());
pb.set_prefix("Building package");
pb.set_message("(starting…)");
pb
} else {
ProgressBar::hidden()
};
let pane = if enabled {
let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(150));
// No template margin: multi-line messages are only prefixed by
// the template on their first line, which would misalign the
// pane; each rendered line carries its own indent instead.
pb.set_style(
ProgressStyle::default_bar()
.template("{msg}")
.expect("valid template"),
);
pb.set_message(" │ (starting…)");
pb
} else {
ProgressBar::hidden()
};
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
let log_path = default_log_path(&timestamp);
let ui = Self {
shared: Arc::new(Shared {
top,
pane,
state: Mutex::new(Pipeline {
classifier: Box::new(GenericClassifier::new()),
lines: VecDeque::new(),
errors: Vec::new(),
last_draw: Instant::now(),
bar_total: 0,
}),
tee: Mutex::new(None),
log_path: Mutex::new(log_path.clone()),
timestamp,
enabled,
suspended: AtomicBool::new(false),
started: Instant::now(),
}),
};
if ui.shared.enabled {
install_sigint_hook(&log_path);
}
ui
}
/// 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 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!(
"{kind}-{package}-{version}-{}.log",
self.shared.timestamp
)),
None => old_path.clone(),
};
let _ = fs::rename(&old_path, &log_path);
*self.shared.log_path.lock().unwrap() = log_path.clone();
update_sigint_log_path(&log_path);
if let Some(dir) = log_path.parent() {
let _ = fs::create_dir_all(dir);
}
match File::create(&log_path) {
Ok(mut file) => {
let _ = writeln!(
file,
"# pkh {kind} {package} ({version}) {detail} started {}",
chrono::Utc::now().to_rfc3339()
);
*self.shared.tee.lock().unwrap() = Some(file);
}
Err(e) => {
log::warn!(
"Could not create build log file {}: {}",
log_path.display(),
e
);
}
}
}
/// Switch to an arbitrary status label with a custom classifier
fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
{
let mut st = self.shared.state.lock().unwrap();
st.classifier = classifier;
st.lines.clear();
st.bar_total = 0;
st.last_draw = Instant::now();
}
if self.shared.enabled {
self.shared.top.set_style(spinner_style());
self.shared.top.set_message(label.to_string());
self.shared.pane.set_message("");
}
}
/// Whether the widget is enabled and still drawn
fn active(&self) -> bool {
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
}
/// Release the widget from the terminal (e.g. before printing
/// passthrough diagnostics or letting child cleanup commands write to
/// the terminal); idempotent
///
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
/// right after the clear, leaving stale copies of the widget on screen.
fn suspend(&self) {
if !self.shared.enabled {
return;
}
if self.shared.suspended.swap(true, Ordering::SeqCst) {
return;
}
self.shared.top.disable_steady_tick();
self.shared.pane.disable_steady_tick();
self.shared.top.finish_and_clear();
self.shared.pane.finish_and_clear();
}
/// Success outcome body: clear the widget and print the artifacts,
/// rendered relative to the working directory when possible
fn success_summary(&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!(" {}", crate::report::display_path(artifact));
}
}
}
/// Failure outcome body: clear the widget and print a summary (recent
/// captured errors and the path to the full log)
fn failure_summary(&self) {
self.suspend();
let st = self.shared.state.lock().unwrap();
if self.shared.enabled && !st.errors.is_empty() {
eprintln!("Last captured errors:");
for err in st
.errors
.iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.iter()
.rev()
{
eprintln!(" {}", err);
}
}
let log_path = self.shared.log_path.lock().unwrap().clone();
if log_path.exists() {
eprintln!("Full log: {}", log_path.display());
}
}
}
/// [`crate::report::BuildView`] port: forwards build events to the live
/// widget, so core flows drive the view without knowing it is a terminal
/// widget.
impl crate::report::BuildView for DebUi {
fn target(&self, target: BuildTarget<'_>) {
if self.shared.enabled {
self.shared.top.set_prefix(target.display.clone());
}
if target.tee_log {
let kind = if target.source_only { "build" } else { "deb" };
self.open_log(
kind,
target.package,
target.version,
&format!("for {}", target.target),
);
}
}
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
self.phase_custom(name, classifier);
}
fn message(&self, text: &str) {
if self.active() {
self.shared.top.set_message(text.to_string());
}
}
fn progress(&self, label: &str, pos: usize, total: usize) {
if !self.active() || total == 0 {
return;
}
apply_progress(
&self.shared.top,
&mut self.shared.state.lock().unwrap(),
pos as u64,
total as u64,
);
self.shared.top.set_message(label.to_string());
}
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.shared.started.elapsed());
}
fn finish_failure(&self) {
self.failure_summary();
}
fn suspend(&self) {
DebUi::suspend(self);
}
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
self.suspend();
}
}
/// Bridge forwarding captured subprocess lines into the pipeline
struct Sink {
shared: Arc<Shared>,
}
impl LineSink for Sink {
fn line(&self, stream: Stream, line: &str) {
// Tee the raw line first: nothing should be lost, even when the
// widget is disabled.
{
let mut tee = self.shared.tee.lock().unwrap();
if let Some(file) = tee.as_mut() {
let _ = writeln!(file, "{line}");
}
}
if !self.shared.enabled || self.shared.suspended.load(Ordering::SeqCst) {
return;
}
let mut st = self.shared.state.lock().unwrap();
for action in st.classifier.feed(stream, line) {
match action {
Action::Hidden => {}
Action::Progress { pos, total } => {
apply_progress(&self.shared.top, &mut st, pos, total)
}
Action::Shown(text) => push_line(&self.shared, &mut st, Kind::Normal, text),
Action::Warning(text) => push_line(&self.shared, &mut st, Kind::Warning, text),
Action::Error(text) => {
if st.errors.len() < 100 {
st.errors.push(text.clone());
}
push_line(&self.shared, &mut st, Kind::Error, text);
}
}
}
}
}
/// Update the determinate progress bar, restyling it when needed
fn apply_progress(top: &ProgressBar, st: &mut Pipeline, pos: u64, total: u64) {
if total == 0 {
return;
}
if st.bar_total != total {
st.bar_total = total;
top.set_style(determinate_style());
top.set_length(total);
}
top.set_position(pos.min(total));
}
/// Push a line into the rolling pane and redraw (throttled)
///
/// When the pane is full, the oldest non-error line is dropped first so
/// errors stay visible longer.
fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) {
st.lines.push_back((kind, text));
while st.lines.len() > PANE_LINES {
let drop_idx = st
.lines
.iter()
.position(|(k, _)| *k != Kind::Error)
.unwrap_or(0);
st.lines.remove(drop_idx);
}
let now = Instant::now();
if now.duration_since(st.last_draw) >= REDRAW_INTERVAL {
st.last_draw = now;
shared.pane.set_message(render_pane(&st.lines));
}
}
/// Render the pane content with per-kind colors
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
lines
.iter()
.map(|(kind, text)| match kind {
Kind::Normal => format!(" │ {text}"),
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
Kind::Error => format!(" │ {}", text.as_str().red()),
})
.collect::<Vec<_>>()
.join("\n")
}
/// Status bar style while no determinate progress is known
///
/// The target lives on the first line and the current phase/message on its own
/// line below, so narrow terminals are not overflowed.
fn spinner_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg}")
.expect("valid template")
}
/// Status bar style with a determinate progress bar
///
/// Same two-line layout as [`spinner_style`], plus the bar on a third line.
fn determinate_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg} [{bar:24.cyan/blue}] {pos}/{len}")
.expect("valid template")
.progress_chars("=> ")
}
/// Whether stdout is a terminal
fn is_stdout_tty() -> bool {
// SAFETY: isatty only inspects the file descriptor
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
}
/// 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!("pkh-{timestamp}.log"))
}
static SIGINT_LOG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false);
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at
/// the log file before exiting
fn install_sigint_hook(log_path: &Path) {
update_sigint_log_path(log_path);
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
// SAFETY: installing a signal handler; the handler itself is best-effort
// (it performs non async-signal-safe operations, acceptable here because
// it immediately exits afterwards).
unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
}
}
/// Point the sigint handler at the current log file location
fn update_sigint_log_path(log_path: &Path) {
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf());
}
extern "C" fn on_sigint(_sig: libc::c_int) {
// Best-effort cleanup: clear leftover widget lines and show the cursor
let _ = execute!(
std::io::stdout(),
Clear(ClearType::FromCursorDown),
cursor::Show
);
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
&& let Some(path) = guard.as_ref()
{
eprintln!("\nInterrupted — full log: {}", path.display());
}
// Run the registered cleanup hooks (currently: unmount and remove the
// ephemeral build chroot, see `deb::ephemeral::sigint_cleanup_chroot`),
// then exit with the conventional 130 status.
//
// Like the terminal restoration above, this is NOT strictly
// async-signal-safe: it locks a mutex, spawns subprocesses and does I/O.
// That is a deliberate tradeoff, no worse than the rest of this handler:
// exiting immediately would skip all destructors and leak the chroot
// together with its bind-mounted /proc and overlay mounts. The hooks are
// self-contained (they only touch stored paths and spawn umount/rm
// directly), so they cannot deadlock on a lock the interrupted thread
// might have held; the hook registry itself is only ever taken with
// try_lock plus a bounded retry for the same reason. Note that SIGINT
// stays blocked for the duration of the handler, so a second Ctrl-C will
// not interrupt a slow cleanup — send SIGTERM/SIGKILL if it ever hangs.
crate::deb::ephemeral::run_cleanup_hooks();
// SAFETY: raw exit bypassing destructors, intended in a signal handler
unsafe {
libc::_exit(130);
}
}