//! 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 unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 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, lines: VecDeque<(Kind, String)>, errors: Vec, last_draw: Instant, bar_total: u64, } /// State shared between [`DebUi`] and its sinks struct Shared { multi: MultiProgress, top: ProgressBar, /// The rolling pane bar, created on demand: flows without subprocess /// output (e.g. `pkh put`) never show it at all pane: Mutex>, state: Mutex, tee: Mutex>, log_path: Mutex, 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, } 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 } else { ProgressBar::hidden() }; let pane = Mutex::new(None); let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string(); let log_path = default_log_path(×tamp); let ui = Self { shared: Arc::new(Shared { multi: multi.clone(), 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) { { 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()); drop_pane(&self.shared); } } /// 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(); drop_pane(&self.shared); self.shared.top.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::>() .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) { 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> { 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, } 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; if let Some(pane) = ensure_pane(shared) { pane.set_message(render_pane(&st.lines)); } } } /// The pane bar, added to the terminal on the first call and reused after /// /// Returns `None` once the widget is suspended: a line racing the suspend /// must not re-add a bar the cleanup just cleared. fn ensure_pane(shared: &Shared) -> Option { let mut pane = shared.pane.lock().unwrap(); if let Some(pb) = pane.as_ref() { return Some(pb.clone()); } if shared.suspended.load(Ordering::SeqCst) { return None; } let pb = shared.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"), ); *pane = Some(pb.clone()); Some(pb) } /// Take the pane bar off the terminal; the next pushed line re-creates it fn drop_pane(shared: &Shared) { if let Some(pb) = shared.pane.lock().unwrap().take() { pb.disable_steady_tick(); pb.finish_and_clear(); shared.multi.remove(&pb); } } /// Render the pane content with per-kind colors, ellipsizing lines that are /// wider than the terminal so they do not overflow onto a wrapped line fn render_pane(lines: &VecDeque<(Kind, String)>) -> String { let max_width = terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH)); render_pane_with_width(lines, max_width) } /// [`render_pane`] with the available pane width injected (in display /// columns); `None` means the terminal size is unknown and lines are kept whole fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option) -> String { lines .iter() .map(|(kind, text)| { let text = match max_width { Some(width) => ellipsize(text, width), None => text.clone(), }; match kind { Kind::Normal => format!(" │ {text}"), Kind::Warning => format!(" │ {}", text.as_str().yellow()), Kind::Error => format!(" │ {}", text.as_str().red()), } }) .collect::>() .join("\n") } /// Display width of the ` │ ` prefix rendered before each pane line const PANE_PREFIX_WIDTH: usize = 4; /// Width of the terminal in columns, or `None` when it cannot be determined fn terminal_width() -> Option { crossterm::terminal::size() .ok() .map(|(cols, _)| cols as usize) } /// Ellipsize `text` to at most `max_width` display columns, keeping its head /// and appending `…` when it does not fit fn ellipsize(text: &str, max_width: usize) -> String { if UnicodeWidthStr::width(text) <= max_width { return text.to_string(); } // Reserve one column for the ellipsis itself let budget = max_width.saturating_sub(1); let mut out = String::new(); let mut width = 0; for ch in text.chars() { let w = UnicodeWidthChar::width(ch).unwrap_or(0); if width + w > budget { break; } out.push(ch); width += w; } out.push('…'); out } /// 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> = 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); } } #[cfg(test)] mod tests { use super::*; #[test] fn ellipsize_keeps_short_lines() { assert_eq!(ellipsize("short", 10), "short"); assert_eq!(ellipsize("exactly10!", 10), "exactly10!"); } #[test] fn ellipsize_truncates_long_lines_to_the_width_budget() { let out = ellipsize("a very long build line that overflows", 20); assert_eq!(UnicodeWidthStr::width(out.as_str()), 20); assert!(out.ends_with('…')); assert!(out.starts_with("a very long build")); } #[test] fn ellipsize_never_exceeds_the_budget_with_wide_characters() { let out = ellipsize("wíth émojis 🎉 and 文字 mixing", 12); assert!(UnicodeWidthStr::width(out.as_str()) <= 12); assert!(out.ends_with('…')); } #[test] fn ellipsize_degenerate_width_still_terminates() { assert_eq!(ellipsize("overflowing", 0), "…"); assert_eq!(ellipsize("overflowing", 1), "…"); } #[test] fn pane_lines_are_ellipsized_but_keep_their_prefix_and_color() { let mut lines = VecDeque::new(); lines.push_back(( Kind::Normal, "gcc -DHAVE_CONFIG_H -I. -I.. -g -O2 -c hello.c".to_string(), )); lines.push_back(( Kind::Error, "an error much too long for the pane".to_string(), )); let rendered = render_pane_with_width(&lines, Some(20)); let rendered = rendered.lines().collect::>(); assert_eq!(rendered.len(), 2); // The injected budget is the text width; every rendered line stays // within the simulated terminal width (prefix + budget) for line in &rendered { let plain = strip_ansi(line); assert!( UnicodeWidthStr::width(plain.as_str()) <= 20 + PANE_PREFIX_WIDTH, "{plain}" ); } assert!(strip_ansi(rendered[0]).starts_with(" │ gcc -DHAVE_CONFIG_H")); // The error keeps its color wrapping around the ellipsized text assert!(rendered[1].contains('\x1b'), "{rendered:?}"); assert!(strip_ansi(rendered[1]).starts_with(" │ an error much too l…")); } #[test] fn pane_lines_are_kept_whole_without_a_known_terminal_size() { let mut lines = VecDeque::new(); lines.push_back(( Kind::Normal, "a line that would overflow a narrow pane".to_string(), )); let rendered = render_pane_with_width(&lines, None); assert!(rendered.contains("a line that would overflow a narrow pane")); } /// A fresh view has no pane at all: flows without subprocess output /// (`pkh put`) must not render anything until a line arrives, and a /// dropped or suspended pane stays gone #[test] fn pane_is_created_lazily_and_dropped_cleanly() { let multi = MultiProgress::new(); let shared = Shared { multi: multi.clone(), top: multi.add(ProgressBar::new(0)), pane: Mutex::new(None), 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(std::env::temp_dir().join("pkh-pane-test.log")), timestamp: String::new(), enabled: true, suspended: AtomicBool::new(false), started: Instant::now(), }; assert!(shared.pane.lock().unwrap().is_none()); ensure_pane(&shared).unwrap(); assert!(shared.pane.lock().unwrap().is_some()); // Later lines hit the stored bar instead of stacking another one ensure_pane(&shared).unwrap(); assert!(shared.pane.lock().unwrap().is_some()); drop_pane(&shared); assert!(shared.pane.lock().unwrap().is_none()); // A line racing the suspend must not re-add the cleared bar shared.suspended.store(true, Ordering::SeqCst); assert!(ensure_pane(&shared).is_none()); } /// Best-effort ANSI escape stripper, enough for the assertions above fn strip_ansi(line: &str) -> String { let mut out = String::new(); let mut chars = line.chars(); while let Some(ch) = chars.next() { if ch == '\x1b' { for esc in chars.by_ref() { if esc.is_ascii_alphabetic() { break; } } } else { out.push(ch); } } out } }