diff --git a/src/context/api.rs b/src/context/api.rs index ba2570f..493bd8f 100644 --- a/src/context/api.rs +++ b/src/context/api.rs @@ -5,6 +5,25 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; +/// Stream from which a captured output line originates +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stream { + /// Standard output of the subprocess + Stdout, + /// Standard error of the subprocess + Stderr, +} + +/// Receiver for lines captured from a subprocess +/// +/// Implementations receive every decoded line (ANSI escapes stripped, +/// carriage-return progress fragments collapsed) as soon as it is produced, +/// allowing live UIs to display and rewrite subprocess output while it runs. +pub trait LineSink: Send + Sync { + /// Called for each captured line + fn line(&self, stream: Stream, line: &str); +} + use super::local::LocalDriver; use super::schroot::SchrootDriver; use super::ssh::SshDriver; @@ -22,6 +41,23 @@ pub trait ContextDriver { env: &[(String, String)], cwd: Option<&str>, ) -> io::Result; + /// Run a command, capturing its output line by line into `sink` + /// + /// Implementations should pipe the subprocess standard streams and forward + /// each decoded line to `sink` instead of letting it inherit the terminal. + /// The default implementation ignores the sink and behaves like + /// [`ContextDriver::run`]. + fn run_captured( + &self, + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + sink: Arc, + ) -> io::Result { + let _ = sink; + self.run(program, args, env, cwd) + } fn run_output( &self, program: &str, @@ -140,6 +176,7 @@ impl Context { args: Vec::new(), env: Vec::new(), cwd: None, + sink: None, } } @@ -250,6 +287,7 @@ pub struct ContextCommand<'a> { args: Vec, env: Vec<(String, String)>, cwd: Option, + sink: Option>, } impl<'a> ContextCommand<'a> { @@ -303,15 +341,33 @@ impl<'a> ContextCommand<'a> { self } + /// Enable line-wise capture of the command output into `sink` + /// + /// When a sink is set, [`ContextCommand::status`] pipes the subprocess + /// standard streams and forwards each decoded line to the sink instead of + /// letting the child inherit the terminal. Without a sink, behavior is + /// unchanged. + pub fn capture(&mut self, sink: Arc) -> &mut Self { + self.sink = Some(sink); + self + } + /// Run command and obtain exit status pub fn status(&mut self) -> io::Result { let program = self.program.clone(); - self.context - .driver() - .as_ref() - .unwrap() - .run(&self.program, &self.args, &self.env, self.cwd.as_deref()) - .map_err(|e| contextualize_spawn_error(&program, e)) + let driver_guard = self.context.driver(); + let driver = driver_guard.as_ref().unwrap(); + let result = match &self.sink { + Some(sink) => driver.run_captured( + &self.program, + &self.args, + &self.env, + self.cwd.as_deref(), + sink.clone(), + ), + None => driver.run(&self.program, &self.args, &self.env, self.cwd.as_deref()), + }; + result.map_err(|e| contextualize_spawn_error(&program, e)) } /// Run command, capturing output diff --git a/src/context/capture.rs b/src/context/capture.rs new file mode 100644 index 0000000..7aa30fb --- /dev/null +++ b/src/context/capture.rs @@ -0,0 +1,125 @@ +//! Shared helpers for capturing subprocess output line by line +//! +//! Used by the context drivers implementing +//! [`ContextDriver::run_captured`](super::api::ContextDriver::run_captured): +//! raw bytes are read incrementally, split into lines, cleaned up (ANSI escape +//! stripping, carriage-return progress collapsing) and forwarded to a +//! [`LineSink`](super::api::LineSink). + +use std::io::Read; +use std::sync::OnceLock; + +use super::api::{LineSink, Stream}; +use regex::Regex; + +/// Precompiled regex matching ANSI escape sequences (CSI and simple escapes) +fn ansi_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])").unwrap()) +} + +/// Strip ANSI escape sequences from a line +pub(crate) fn strip_ansi(line: &str) -> String { + ansi_regex().replace_all(line, "").to_string() +} + +/// Read `reader` to EOF, forwarding each decoded line to `sink` +/// +/// Lines are split on `\n`; when a line contains carriage returns (e.g. apt's +/// `0% [Working]` progress fragments), only the last `\r`-segment is kept so +/// progress updates replace each other instead of accumulating. Empty lines +/// are dropped. +pub(crate) fn pump(mut reader: R, stream: Stream, sink: &dyn LineSink) { + let mut buf: Vec = Vec::with_capacity(8192); + let mut chunk = [0u8; 4096]; + + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(_) => break, + } + + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let line: Vec = buf.drain(..=pos).collect(); + emit(&line[..line.len() - 1], stream, sink); + } + } + + // Flush a trailing line without newline, if any + if !buf.is_empty() { + emit(&buf, stream, sink); + } +} + +/// Clean up and forward one raw line to the sink +fn emit(raw: &[u8], stream: Stream, sink: &dyn LineSink) { + let mut line = String::from_utf8_lossy(raw).to_string(); + + // Strip a trailing carriage return left over from CRLF line endings + if line.ends_with('\r') { + line.pop(); + } + + // Carriage-return progress: keep only the last segment of the line + if let Some(idx) = line.rfind('\r') { + line = line[idx + 1..].to_string(); + } + + let line = strip_ansi(&line); + let trimmed = line.trim_end(); + if trimmed.trim().is_empty() { + return; + } + + sink.line(stream, trimmed); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[derive(Default)] + struct CollectingSink(Mutex>); + + impl LineSink for CollectingSink { + fn line(&self, stream: Stream, line: &str) { + self.0.lock().unwrap().push((stream, line.to_string())); + } + } + + #[test] + fn test_pump_splits_lines_and_merges_cr_fragments() { + let sink = CollectingSink::default(); + let data = + b"Get:1 http://x InRelease [1 kB]\r0% [Working]\r\nHit:2 http://y Release\npartial"; + pump(&data[..], Stream::Stdout, &sink); + + let lines = sink.0.lock().unwrap().clone(); + assert_eq!( + lines, + vec![ + (Stream::Stdout, "0% [Working]".to_string()), + (Stream::Stdout, "Hit:2 http://y Release".to_string()), + (Stream::Stdout, "partial".to_string()), + ] + ); + } + + #[test] + fn test_pump_strips_ansi_and_skips_empty_lines() { + let sink = CollectingSink::default(); + let data = b"\x1b[1mSetting up foo\x1b[0m\n\n \nE: boom\n"; + pump(&data[..], Stream::Stderr, &sink); + + let lines = sink.0.lock().unwrap().clone(); + assert_eq!( + lines, + vec![ + (Stream::Stderr, "Setting up foo".to_string()), + (Stream::Stderr, "E: boom".to_string()), + ] + ); + } +} diff --git a/src/context/local.rs b/src/context/local.rs index d115e16..6e161a8 100644 --- a/src/context/local.rs +++ b/src/context/local.rs @@ -1,10 +1,12 @@ /// Local context: execute commands locally /// Context driver: Does nothing -use super::api::ContextDriver; +use super::api::{ContextDriver, LineSink, Stream}; +use super::capture::pump; use std::io; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::sync::Arc; use std::time::SystemTime; pub struct LocalDriver; @@ -80,6 +82,51 @@ impl ContextDriver for LocalDriver { cmd.status() } + fn run_captured( + &self, + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + sink: Arc, + ) -> io::Result { + let mut cmd = Command::new(program); + cmd.args(args).envs(env.iter().map(|(k, v)| (k, v))); + // Best-effort: ask children not to emit ANSI colors; captured lines are + // stripped anyway. + cmd.env("NO_COLOR", "1"); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()); + + let mut child = cmd.spawn()?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + // One reader thread per stream; lines are forwarded to the sink as + // they arrive so the UI stays live. + let out_sink = sink.clone(); + let t_out = + stdout.map(|r| std::thread::spawn(move || pump(r, Stream::Stdout, out_sink.as_ref()))); + let err_sink = sink.clone(); + let t_err = + stderr.map(|r| std::thread::spawn(move || pump(r, Stream::Stderr, err_sink.as_ref()))); + + let status = child.wait(); + + if let Some(t) = t_out { + let _ = t.join(); + } + if let Some(t) = t_err { + let _ = t.join(); + } + + status + } + fn run_output( &self, program: &str, diff --git a/src/context/mod.rs b/src/context/mod.rs index 3d8db4f..77643ab 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1,11 +1,12 @@ mod api; +mod capture; mod local; mod manager; mod schroot; mod ssh; mod unshare; -pub use api::{Context, ContextCommand, ContextConfig}; +pub use api::{Context, ContextCommand, ContextConfig, LineSink, Stream}; pub use manager::ContextManager; use std::sync::Arc; diff --git a/src/context/schroot.rs b/src/context/schroot.rs index a490d2a..88d5a26 100644 --- a/src/context/schroot.rs +++ b/src/context/schroot.rs @@ -1,6 +1,6 @@ /// Schroot context: execute commands in a schroot session /// Not tested, will need more work! -use super::api::ContextDriver; +use super::api::{ContextDriver, LineSink}; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -72,6 +72,71 @@ impl SchrootDriver { .trim() .to_string()) } + + /// Build the `schroot` argument list executing `(program, args)` inside + /// the given session. + /// + /// When `preserve_env` is set, `-p` is passed so the host environment is + /// preserved (historical behavior of `run()`, while `run_output()` does + /// not preserve it). + fn schroot_args( + session_id: &str, + preserve_env: bool, + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + ) -> Vec { + let mut command_args = Vec::new(); + if preserve_env { + command_args.push("-p".to_string()); + } + command_args.extend([ + "-r".to_string(), + "-c".to_string(), + session_id.to_string(), + "--".to_string(), + ]); + + let (actual_program, actual_args) = Self::wrap_command(program, args, env, cwd); + command_args.push(actual_program); + command_args.extend(actual_args); + command_args + } + + /// Wrap `(program, args)` in `sh -c` when a working directory or + /// environment variables are needed. + fn wrap_command( + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + ) -> (String, Vec) { + let mut actual_program = program.to_string(); + let mut actual_args = args.to_vec(); + + if cwd.is_some() || !env.is_empty() { + let mut shell_cmd = String::new(); + + if let Some(dir) = cwd { + shell_cmd.push_str(&format!("cd {} && ", dir)); + } + + if !env.is_empty() { + shell_cmd.push_str("env "); + for (k, v) in env { + shell_cmd.push_str(&format!("{}={} ", k, v)); + } + } + + shell_cmd.push_str(&format!("{} {}", program, args.join(" "))); + + actual_program = "sh".to_string(); + actual_args = vec!["-c".to_string(), shell_cmd]; + } + + (actual_program, actual_args) + } } impl ContextDriver for SchrootDriver { @@ -119,47 +184,26 @@ impl ContextDriver for SchrootDriver { cwd: Option<&str>, ) -> io::Result { let session_id = self.ensure_session()?; + let cmd_args = Self::schroot_args(&session_id, true, program, args, env, cwd); + self.parent().command("schroot").args(cmd_args).status() + } - // Construct the schroot command - // schroot -p -r -c session_id -- program args... - // If cwd is specified, we wrap in sh -c "cd cwd && ..." - - let mut command_args = vec![ - "-p".to_string(), - "-r".to_string(), - "-c".to_string(), - session_id, - "--".to_string(), - ]; - - let mut actual_program = program.to_string(); - let mut actual_args = args.to_vec(); - - // Simplest: Wrap everything in `sh -c` if CWD or ENV is needed. - if cwd.is_some() || !env.is_empty() { - let mut shell_cmd = String::new(); - - if let Some(dir) = cwd { - shell_cmd.push_str(&format!("cd {} && ", dir)); - } - - if !env.is_empty() { - shell_cmd.push_str("env "); - for (k, v) in env { - shell_cmd.push_str(&format!("{}={} ", k, v)); - } - } - - shell_cmd.push_str(&format!("{} {}", program, args.join(" "))); - - actual_program = "sh".to_string(); - actual_args = vec!["-c".to_string(), shell_cmd]; - } - - command_args.push(actual_program); - command_args.extend(actual_args); - - self.parent().command("schroot").args(command_args).status() + fn run_captured( + &self, + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + sink: Arc, + ) -> io::Result { + let session_id = self.ensure_session()?; + let cmd_args = Self::schroot_args(&session_id, true, program, args, env, cwd); + // Forward the sink to the wrapping command so capture chains through + // the parent context driver (e.g. schroot over ssh). + let parent = self.parent(); + let mut cmd = parent.command("schroot"); + cmd.args(cmd_args).capture(sink); + cmd.status() } fn run_output( @@ -170,41 +214,8 @@ impl ContextDriver for SchrootDriver { cwd: Option<&str>, ) -> io::Result { let session_id = self.ensure_session()?; - - let mut command_args = vec![ - "-r".to_string(), - "-c".to_string(), - session_id, - "--".to_string(), - ]; - - let mut actual_program = program.to_string(); - let mut actual_args = args.to_vec(); - - if cwd.is_some() || !env.is_empty() { - let mut shell_cmd = String::new(); - - if let Some(dir) = cwd { - shell_cmd.push_str(&format!("cd {} && ", dir)); - } - - if !env.is_empty() { - shell_cmd.push_str("env "); - for (k, v) in env { - shell_cmd.push_str(&format!("{}={} ", k, v)); - } - } - - shell_cmd.push_str(&format!("{} {}", program, args.join(" "))); - - actual_program = "sh".to_string(); - actual_args = vec!["-c".to_string(), shell_cmd]; - } - - command_args.push(actual_program); - command_args.extend(actual_args); - - self.parent().command("schroot").args(command_args).output() + let cmd_args = Self::schroot_args(&session_id, false, program, args, env, cwd); + self.parent().command("schroot").args(cmd_args).output() } fn create_temp_dir(&self) -> io::Result { diff --git a/src/context/ssh.rs b/src/context/ssh.rs index 382f58f..b33d671 100644 --- a/src/context/ssh.rs +++ b/src/context/ssh.rs @@ -1,6 +1,7 @@ /// SSH context: execute commands over an SSH connection /// Context driver: Copies over SFTP with ssh2, executes commands over ssh2 channels -use super::api::ContextDriver; +use super::api::{ContextDriver, LineSink, Stream}; +use super::capture::pump; use log::debug; use ssh2; use std::fs; @@ -11,6 +12,7 @@ use std::net::TcpStream; use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; use std::process::ExitStatus; +use std::sync::Arc; pub fn connect_ssh(host: &str, user: Option<&str>, port: Option) -> io::Result { let port = port.unwrap_or(22); @@ -139,6 +141,55 @@ impl ContextDriver for SshDriver { Ok(ExitStatus::from_raw(code)) } + fn run_captured( + &self, + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + sink: Arc, + ) -> io::Result { + let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?; + let mut channel = sess.channel_session().map_err(io::Error::other)?; + + // Construct command line with env vars (same escaping as `run`) + let mut cmd_line = String::new(); + for (key, value) in env { + cmd_line.push_str(&format!( + "export {}='{}'; ", + key, + value.replace("'", "'\\''") + )); + } + if let Some(dir) = cwd { + cmd_line.push_str(&format!("cd {} && ", dir)); + } + cmd_line.push_str(program); + for arg in args { + cmd_line.push(' '); + cmd_line.push_str(arg); // TODO: escape + } + + debug!("Executing SSH command (captured): {}", cmd_line); + + // Keep the PTY: it keeps tool output line-buffered (better for live + // display) and merges stdout/stderr into a single ordered stream, + // which ssh2 cannot read concurrently anyway. + channel + .request_pty("xterm", None, None) + .map_err(|e| io::Error::other(format!("Failed to request PTY: {}", e)))?; + + channel.exec(&cmd_line).map_err(io::Error::other)?; + + let mut stdout_stream = channel.stream(0); + pump(&mut stdout_stream, Stream::Stdout, sink.as_ref()); + + channel.wait_close().map_err(io::Error::other)?; + + let code = channel.exit_status().unwrap_or(-1); + Ok(ExitStatus::from_raw(code)) + } + fn run_output( &self, program: &str, diff --git a/src/context/unshare.rs b/src/context/unshare.rs index d516bcd..31937cc 100644 --- a/src/context/unshare.rs +++ b/src/context/unshare.rs @@ -1,4 +1,4 @@ -use super::api::{Context, ContextCommand, ContextDriver}; +use super::api::{Context, ContextCommand, ContextDriver, LineSink}; use log::debug; use std::fs; use std::io; @@ -259,6 +259,22 @@ impl ContextDriver for UnshareDriver { self.command(program, args, env, cwd).status() } + fn run_captured( + &self, + program: &str, + args: &[String], + env: &[(String, String)], + cwd: Option<&str>, + sink: Arc, + ) -> io::Result { + // Forward the sink to the wrapping command: the parent context driver + // is responsible for the actual capture (and may chain further, e.g. + // unshare over ssh). + let mut cmd = self.command(program, args, env, cwd); + cmd.capture(sink); + cmd.status() + } + fn run_output( &self, program: &str, diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index bc0f43c..15cf213 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -1,4 +1,5 @@ use crate::context::{self, Context, ContextConfig}; +use crate::ui::deb::{DebUi, Phase}; use directories::ProjectDirs; use std::error::Error; use std::fs; @@ -28,6 +29,7 @@ impl EphemeralContextGuard { series: &str, arch: Option<&str>, base_ctx: Arc, + ui: Option>, ) -> Result> { let current_context_name = context::manager().current_name(); @@ -43,7 +45,8 @@ impl EphemeralContextGuard { ); // Download and extract the chroot tarball - Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone()).await?; + Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui) + .await?; // Switch to an ephemeral context to build the package in the chroot context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare { @@ -64,6 +67,7 @@ impl EphemeralContextGuard { arch: Option<&str>, chroot_path: &PathBuf, ctx: Arc, + ui: &Option>, ) -> Result<(), Box> { // Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it let ctx_for_devices = ctx.clone(); @@ -119,7 +123,10 @@ impl EphemeralContextGuard { series, arch ); - Self::download_chroot_tarball(series, arch, &tarball_path, ctx).await?; + if let Some(u) = ui { + u.phase(Phase::PreparingChroot); + } + Self::download_chroot_tarball(series, arch, &tarball_path, ctx, ui).await?; } else { log::debug!( "Using cached chroot tarball for {} (arch: {:?})", @@ -130,10 +137,16 @@ impl EphemeralContextGuard { // Extract tarball to chroot directory log::debug!("Extracting chroot tarball to {}...", chroot_path.display()); - Self::extract_tarball(&tarball_path, chroot_path)?; + if let Some(u) = ui { + u.phase(Phase::ExtractingChroot); + } + Self::extract_tarball(&tarball_path, chroot_path, ui.as_deref())?; // Create device nodes in the chroot log::debug!("Creating device nodes in chroot..."); + if let Some(u) = ui { + u.phase(Phase::FinalizingChroot); + } Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?; // Bind mount /proc from host into chroot (before entering unshare namespace) @@ -149,6 +162,7 @@ impl EphemeralContextGuard { arch: Option<&str>, tarball_path: &Path, ctx: Arc, + ui: &Option>, ) -> Result<(), Box> { // Create a lock file to make sure that noone tries to use the file while it's not fully downloaded let lockfile_path = tarball_path.with_extension("lock"); @@ -182,6 +196,10 @@ impl EphemeralContextGuard { cmd.arg(series) .arg(tarball_path.to_string_lossy().to_string()); + if let Some(u) = ui { + cmd.capture(u.sink()); + } + let status = cmd.status()?; if !status.success() { @@ -216,6 +234,7 @@ impl EphemeralContextGuard { fn extract_tarball( tarball_path: &PathBuf, chroot_path: &PathBuf, + ui: Option<&DebUi>, ) -> Result<(), Box> { // Create the chroot directory fs::create_dir_all(chroot_path)?; @@ -225,8 +244,23 @@ impl EphemeralContextGuard { let xz_decoder = XzDecoder::new(tarball_file); let mut archive = Archive::new(xz_decoder); - // Extract all files to the chroot directory - archive.unpack(chroot_path)?; + // Extract entries one by one so progress can be reported (a full + // second decompression pass just to count entries upfront would be + // too expensive for multi-hundred-MB chroot tarballs) + let mut count = 0usize; + for entry in archive.entries()? { + let mut entry = entry?; + entry.unpack_in(chroot_path)?; + count += 1; + if count.is_multiple_of(100) + && let Some(u) = ui + { + u.progress_message(&format!("Extracting chroot… ({count} files)")); + } + } + if let Some(u) = ui { + u.progress_message(&format!("Extracting chroot… ({count} files)")); + } Ok(()) } diff --git a/src/deb/local.rs b/src/deb/local.rs index d4c19a3..7af2ced 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -1,7 +1,9 @@ /// Local binary package building /// Directly calling 'debian/rules' in current context -use crate::context::Context; +use crate::context::{Context, ContextCommand, LineSink}; use crate::deb::find_dsc_file; +use crate::ui::deb::{DebUi, Phase}; +use crate::ui::logfmt::QuiltClassifier; use log::warn; use std::collections::HashMap; use std::error::Error; @@ -11,6 +13,17 @@ use std::sync::Arc; use crate::apt; use crate::deb::cross; +/// Attach the capture sink to a command when the live UI is active +fn cap<'a>( + cmd: &'a mut ContextCommand<'a>, + sink: &Option>, +) -> &'a mut ContextCommand<'a> { + if let Some(s) = sink { + cmd.capture(s.clone()); + } + cmd +} + #[allow(clippy::too_many_arguments)] pub async fn build( package: &str, @@ -23,7 +36,10 @@ pub async fn build( ppa: Option<&[&str]>, inject_packages: Option<&[&str]>, ctx: Arc, + ui: Option>, ) -> Result<(), Box> { + let sink: Option> = ui.as_ref().map(|u| u.sink()); + // Environment let mut env = HashMap::::new(); env.insert("LANG".to_string(), "C".to_string()); @@ -155,19 +171,22 @@ pub async fn build( // Update package lists log::debug!("Updating package lists for local build..."); - let status = ctx - .command("apt-get") - .envs(env.clone()) - .arg("update") - .status() - .map_err(|e| { - format!( - "Failed to run 'apt-get update' inside the build context: {}. \ + if let Some(u) = &ui { + u.phase(Phase::UpdatingPackageLists); + } + let status = cap( + ctx.command("apt-get").envs(env.clone()).arg("update"), + &sink, + ) + .status() + .map_err(|e| { + format!( + "Failed to run 'apt-get update' inside the build context: {}. \ If this is a local build, make sure apt-get is available and \ try executing with sudo.", - e - ) - })?; + e + ) + })?; if !status.success() { return Err("apt-get update failed inside the build context. \ If this is a local build, try executing with sudo, \ @@ -193,7 +212,10 @@ pub async fn build( cmd.arg(format!("libc6:{arch}")); cmd.arg(format!("libc6-dev:{arch}")); } - let status = cmd.status()?; + if let Some(u) = &ui { + u.phase(Phase::InstallingEssentials); + } + let status = cap(&mut cmd, &sink).status()?; if !status.success() { return Err("Could not install essential packages for the build".into()); } @@ -206,15 +228,18 @@ pub async fn build( .ok_or("Invalid package directory path")?; // Apply quilt patches if the package provides a patch series - apply_quilt_patches(package_dir_str, &env, ctx.clone())?; + apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?; // Install injected packages if specified if let Some(packages) = inject_packages { - install_injected_packages(packages, &env, ctx.clone())?; + install_injected_packages(packages, &env, ctx.clone(), &ui, &sink)?; } // Install arch-specific build dependencies log::debug!("Installing arch-specific build dependencies..."); + if let Some(u) = &ui { + u.phase(Phase::InstallingBuildDeps); + } let mut cmd = ctx.command("apt-get"); cmd.current_dir(package_dir_str) .envs(env.clone()) @@ -224,51 +249,69 @@ pub async fn build( cmd.arg(format!("--host-architecture={arch}")); } cmd.arg("--arch-only"); - let status = cmd.arg("./").status()?; + let status = cap(&mut cmd, &sink).arg("./").status()?; // If build-dep fails, we try to explain the failure using dose-debcheck if !status.success() { + if let Some(u) = &ui { + u.suspend(); + } dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?; return Err("Could not install build-dependencies for the build".into()); } // Install arch-independant build dependencies log::debug!("Installing arch-independant build dependencies..."); - let status = ctx - .command("apt-get") - .current_dir(package_dir_str) - .envs(env.clone()) - .arg("-y") - .arg("build-dep") - .arg("./") - .status()?; + let status = cap( + ctx.command("apt-get") + .current_dir(package_dir_str) + .envs(env.clone()) + .arg("-y") + .arg("build-dep") + .arg("./"), + &sink, + ) + .status()?; // If build-dep fails, we try to explain the failure using dose-debcheck if !status.success() { + if let Some(u) = &ui { + u.suspend(); + } dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?; return Err("Could not install build-dependencies for the build".into()); } // Run the build step log::debug!("Building (debian/rules build) package..."); - let status = ctx - .command("debian/rules") - .current_dir(package_dir_str) - .envs(env.clone()) - .arg("build") - .status()?; + if let Some(u) = &ui { + u.phase(Phase::Building); + } + let status = cap( + ctx.command("debian/rules") + .current_dir(package_dir_str) + .envs(env.clone()) + .arg("build"), + &sink, + ) + .status()?; if !status.success() { return Err("Error while building the package".into()); } // Run the 'binary' step to produce deb - let status = ctx - .command("fakeroot") - .current_dir(package_dir_str) - .envs(env.clone()) - .arg("debian/rules") - .arg("binary") - .status()?; + if let Some(u) = &ui { + u.phase(Phase::ProducingBinaries); + } + let status = cap( + ctx.command("fakeroot") + .current_dir(package_dir_str) + .envs(env.clone()) + .arg("debian/rules") + .arg("binary"), + &sink, + ) + .status()?; if !status.success() { return Err( "Error while building the binary artifacts (.deb) from the built package".into(), @@ -284,6 +327,8 @@ fn apply_quilt_patches( package_dir: &str, env: &HashMap, ctx: Arc, + ui: &Option>, + sink: &Option>, ) -> Result<(), Box> { let series_path = Path::new(package_dir).join("debian/patches/series"); if !ctx.exists(&series_path)? { @@ -296,9 +341,11 @@ fn apply_quilt_patches( // Skip patch application if the series file contains no patches let series_content = ctx.read_file(&series_path)?; - let has_patches = series_content + let total_patches = series_content .lines() - .any(|line| !line.trim().is_empty() && !line.trim().starts_with('#')); + .filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#')) + .count(); + let has_patches = total_patches > 0; if !has_patches { log::debug!( "'{}' contains no patches, skipping quilt patch application", @@ -309,28 +356,37 @@ fn apply_quilt_patches( // Make sure quilt is available in the build context log::debug!("Installing quilt for patch application..."); - let status = ctx - .command("apt-get") - .envs(env.clone()) - .arg("-y") - .arg("install") - .arg("quilt") - .status()?; + let status = cap( + ctx.command("apt-get") + .envs(env.clone()) + .arg("-y") + .arg("install") + .arg("quilt"), + sink, + ) + .status()?; if !status.success() { return Err("Could not install 'quilt', required to apply patches".into()); } // Apply all patches listed in the series - log::info!("Applying quilt patches from debian/patches/series..."); + if let Some(u) = ui { + u.phase_with( + Phase::ApplyingPatches, + Box::new(QuiltClassifier::new(total_patches)), + ); + } let mut patch_env = env.clone(); patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string()); - let status = ctx - .command("quilt") - .current_dir(package_dir) - .envs(patch_env) - .arg("push") - .arg("-a") - .status()?; + let status = cap( + ctx.command("quilt") + .current_dir(package_dir) + .envs(patch_env) + .arg("push") + .arg("-a"), + sink, + ) + .status()?; if !status.success() { return Err("Failed to apply quilt patches ('quilt push -a')".into()); } @@ -360,9 +416,15 @@ fn install_injected_packages( packages: &[&str], env: &HashMap, ctx: Arc, + ui: &Option>, + sink: &Option>, ) -> Result<(), Box> { log::info!("Installing injected packages: {:?}", packages); + if let Some(u) = ui { + u.phase(Phase::InjectingPackages); + } + // Separate .deb files from package names let mut deb_files: Vec = Vec::new(); let mut package_names: Vec<&str> = Vec::new(); @@ -400,7 +462,7 @@ fn install_injected_packages( if !package_names.is_empty() { cmd.args(&package_names); } - let status = cmd.status()?; + let status = cap(&mut cmd, sink).status()?; if !status.success() { return Err(format!("Could not install injected packages: {:?}", deb_files).into()); } diff --git a/src/deb/mod.rs b/src/deb/mod.rs index e502559..7edbbfd 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -3,6 +3,7 @@ mod ephemeral; mod local; use crate::context::{self, Context}; +use crate::ui::deb::{DebUi, Phase}; use std::error::Error; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -15,6 +16,11 @@ pub enum BuildMode { } /// Build package in 'cwd' to a .deb +/// +/// Returns the list of produced .deb files retrieved locally. When `ui` is +/// set, a live view (status bar + rolling log pane) is displayed and all +/// subprocess output is captured through it; on failure the widget is cleared +/// and a summary of captured errors is printed. #[allow(clippy::too_many_arguments)] pub async fn build_binary_package( arch: Option<&str>, @@ -26,7 +32,43 @@ pub async fn build_binary_package( ppa: Option<&[&str]>, inject_packages: Option<&[&str]>, ctx: Option>, -) -> Result<(), Box> { + ui: Option>, +) -> Result, Box> { + let result = build_binary_package_impl( + arch, + series, + pocket, + cwd, + cross, + mode, + ppa, + inject_packages, + ctx, + &ui, + ) + .await; + + if let (Some(u), Err(_)) = (&ui, &result) { + u.finish_failure(); + } + + result +} + +/// Implementation of [`build_binary_package`], without failure handling +#[allow(clippy::too_many_arguments)] +async fn build_binary_package_impl( + arch: Option<&str>, + series: Option<&str>, + pocket: Option<&str>, + cwd: Option<&Path>, + cross: bool, + mode: Option, + ppa: Option<&[&str]>, + inject_packages: Option<&[&str]>, + ctx: Option>, + ui: &Option>, +) -> Result, Box> { let cwd = cwd.unwrap_or_else(|| Path::new(".")); // Parse changelog to get package name, version and series @@ -61,12 +103,22 @@ pub async fn build_binary_package( // Use provided context or get current let base_ctx = ctx.unwrap_or_else(context::current); + // Identify the target in the live UI once the changelog is parsed, so + // even the chroot download output is attributed and tee'd + if let Some(u) = ui { + u.set_target(&package, &version, series, arch); + } + + // Create an ephemeral unshare context for all Local builds. It is kept in + // this scope so it outlives the guarded section below and is only dropped + // once the live view has been cleared. let mut guard = if mode == BuildMode::Local { Some( ephemeral::EphemeralContextGuard::new_with_context( series, chroot_arch, base_ctx.clone(), + ui.clone(), ) .await?, ) @@ -74,59 +126,91 @@ pub async fn build_binary_package( None }; - // Get the build context - either the ephemeral context or the base context - let build_ctx = if mode == BuildMode::Local { - context::current() - } else { - base_ctx.clone() - }; + let result = async { + // Get the build context - either the ephemeral context or the base context + let build_ctx = if mode == BuildMode::Local { + context::current() + } else { + base_ctx.clone() + }; - // Prepare build directory - let build_root = build_ctx.create_temp_dir()?; + // Prepare build directory + let build_root = build_ctx.create_temp_dir()?; - // Ensure availability of all needed files for the build - let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?; - build_ctx.ensure_available(parent_dir, &build_root)?; - let parent_dir_name = parent_dir - .file_name() - .ok_or("Cannot find parent directory name")?; - let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap()); + // Ensure availability of all needed files for the build + let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?; + build_ctx.ensure_available(parent_dir, &build_root)?; + let parent_dir_name = parent_dir + .file_name() + .ok_or("Cannot find parent directory name")?; + let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap()); - // Run the build using target build mode - match mode { - BuildMode::Local => { - local::build( - &package, - &version, - arch, - series, - pocket, - &build_root, - cross, - ppa, - inject_packages, - build_ctx.clone(), - ) - .await? + // Run the build using target build mode + match mode { + BuildMode::Local => { + local::build( + &package, + &version, + arch, + series, + pocket, + &build_root, + cross, + ppa, + inject_packages, + build_ctx.clone(), + ui.clone(), + ) + .await? + } } - } - // Retrieve produced .deb files - let remote_files = build_ctx.list_files(Path::new(&build_root))?; - for remote_file in remote_files { - if remote_file.extension().is_some_and(|ext| ext == "deb") { + // Retrieve produced .deb files + if let Some(u) = ui { + u.phase(Phase::RetrievingArtifacts); + } + let remote_files = build_ctx.list_files(Path::new(&build_root))?; + let deb_files: Vec = remote_files + .into_iter() + .filter(|f| f.extension().is_some_and(|ext| ext == "deb")) + .collect(); + let total_debs = deb_files.len(); + + let mut artifacts = Vec::with_capacity(total_debs); + for (idx, remote_file) in deb_files.iter().enumerate() { let file_name = remote_file.file_name().ok_or("Invalid remote filename")?; let local_dest = parent_dir.join(file_name); - build_ctx.retrieve_path(&remote_file, &local_dest)?; + build_ctx.retrieve_path(remote_file, &local_dest)?; + artifacts.push(local_dest); + + if let Some(u) = ui { + u.count_progress("Retrieving artifacts", idx + 1, total_debs); + } } + + if let Some(u) = ui { + u.finish_success(&artifacts, u.elapsed()); + } + + Ok(artifacts) + } + .await; + + // Clear the live view before returning: the ephemeral guard is dropped at + // the end of this function and its cleanup commands (umount, rm -rf of + // the chroot) inherit the terminal, so they must not fight the widget. + if let Some(u) = ui { + u.suspend(); } // Mark build as successful to trigger chroot cleanup - if let Some(ref mut g) = guard { + if result.is_ok() + && let Some(ref mut g) = guard + { g.mark_build_successful(); } - Ok(()) + result } /// Find the current package directory by trying both patterns: @@ -321,6 +405,7 @@ mod tests { None, None, Some(ctx), + None, ) .await .expect("Cannot build binary package (deb)"); diff --git a/src/lib.rs b/src/lib.rs index 288378b..d82d265 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,9 @@ pub mod pull; /// Handle package-specific quirks and workarounds pub mod quirks; +/// Terminal UI helpers (progress bars, live build views, prompts) +pub mod ui; + /// Handle context for .deb building: locally, over ssh, in a chroot... pub mod context; diff --git a/src/main.rs b/src/main.rs index ed3fea1..4f3a834 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,6 @@ use pkh::changelog::generate_entry; use indicatif_log_bridge::LogWrapper; use log::{error, info}; -mod ui; - /// Obtain the current working directory, exiting with a helpful message on failure. fn current_dir_or_exit() -> std::path::PathBuf { match std::env::current_dir() { @@ -75,7 +73,9 @@ fn main() { .arg(arg!(--cross "Cross-compile for target architecture (instead of qemu-binfmt)") .long_help("Cross-compile for target architecture (instead of using qemu-binfmt)\nNote that most packages cannot be cross-compiled").required(false)) .arg(arg!(--mode "Change build mode [local]").required(false) - .long_help("Change build mode [local]\nDefault will chose depending on other parameters, don't provide if unsure")), + .long_help("Change build mode [local]\nDefault will chose depending on other parameters, don't provide if unsure")) + .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false) + .long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs.")), ) .subcommand( Command::new("context") @@ -139,7 +139,7 @@ fn main() { .unwrap_or(""); let archive = sub_matches.get_one::("archive").unwrap_or(&false); - let (pb, progress_callback) = ui::create_progress_bar(&multi); + let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi); // Convert PPA to base URL if provided let base_url = ppa.and_then(|ppa_str| { @@ -194,7 +194,7 @@ fn main() { pkh::distro_info::get_ordered_series_name(&dist).await }) { Ok(series_list) => { - match ui::select_series(&series_list, ¤t_series) { + match pkh::ui::select_series(&series_list, ¤t_series) { Ok(selected) => Some(selected), Err(e) => { error!( @@ -284,8 +284,20 @@ fn main() { Some("local") => Some(pkh::deb::BuildMode::Local), _ => None, }; + let verbose = sub_matches + .get_one::("verbose") + .copied() + .unwrap_or(false); - if let Err(e) = rt.block_on(async { + // 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))) + }; + + let result = rt.block_on(async { pkh::deb::build_binary_package( arch, series, @@ -296,11 +308,20 @@ fn main() { ppa, inject_packages, None, + ui.clone(), ) .await - }) { - error!("{}", e); - std::process::exit(1); + }); + + match result { + Ok(artifacts) => { + let _ = artifacts; + info!("Done."); + } + Err(e) => { + error!("{}", e); + std::process::exit(1); + } } } Some(("context", sub_matches)) => { diff --git a/src/prune.rs b/src/prune.rs index d0fc287..8f2737d 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -13,11 +13,16 @@ //! **stale download lockfiles** (`~/.cache/pkh/*.lock`). //! - **The shared apt keyring directory** (`/tmp/pkh-keyrings`), used by //! mmdebstrap runs. +//! - **Build logs** (`~/.cache/pkh/logs/deb-*.log`) written by `pkh deb`. By +//! default only logs beyond a small retention window (the newest +//! [`KEEP_LOGS`] are kept) are removed; pass [`PruneOptions::all`] to remove +//! them all. //! //! The [`prune()`] function discovers and removes all of the above. By //! default it removes everything that is cheap to regenerate (residual -//! directories, keyrings, stale lockfiles); pass [`PruneOptions::all`] to also -//! discard the cached chroot tarballs, which are expensive to re-download. +//! directories, keyrings, stale lockfiles, old build logs); pass +//! [`PruneOptions::all`] to also discard the cached chroot tarballs and all +//! build logs, which are expensive to re-create. use std::fs; use std::path::{Path, PathBuf}; @@ -54,6 +59,9 @@ impl PruneReport { } } +/// Number of newest build logs kept when `--all` is not passed. +pub(crate) const KEEP_LOGS: usize = 10; + /// One discovered artifact that prune can act on. #[derive(Debug, Clone)] enum Artifact { @@ -65,6 +73,8 @@ enum Artifact { LockFile(PathBuf), /// A cached chroot tarball. Tarball(PathBuf), + /// A build log file under `/logs`. + LogFile(PathBuf), } impl Artifact { @@ -73,7 +83,8 @@ impl Artifact { Artifact::TempDir(p) | Artifact::KeyringDir(p) | Artifact::LockFile(p) - | Artifact::Tarball(p) => p, + | Artifact::Tarball(p) + | Artifact::LogFile(p) => p, } } @@ -83,6 +94,7 @@ impl Artifact { Artifact::KeyringDir(_) => "apt keyring cache", Artifact::LockFile(_) => "stale lockfile", Artifact::Tarball(_) => "cached chroot tarball", + Artifact::LogFile(_) => "build log", } } } @@ -197,9 +209,45 @@ fn discover_artifacts(temp_dir: &Path, cache_dir: Option<&Path>) -> Vec std::collections::HashSet<&Path> { + let mut logs: Vec<&Path> = artifacts + .iter() + .filter_map(|a| match a { + Artifact::LogFile(p) => Some(p.as_path()), + _ => None, + }) + .collect(); + logs.sort_unstable(); + + if all { + return logs.into_iter().collect(); + } + + let keep = KEEP_LOGS.min(logs.len()); + logs[..logs.len() - keep].iter().copied().collect() +} + /// Remove a path, first attempting a direct removal and only escalating to a /// privileged `rm -rf` when the direct attempt fails. /// @@ -284,6 +332,8 @@ pub fn prune_in( ..Default::default() }; + let removable_logs = removable_logs(&artifacts, options.all); + for artifact in &artifacts { // Cached tarballs are only removed when --all is requested: they are // expensive to re-download. @@ -291,6 +341,13 @@ pub fn prune_in( continue; } + // Build logs follow the retention policy computed above. + if let Artifact::LogFile(p) = artifact + && !removable_logs.contains(p.as_path()) + { + continue; + } + let path = artifact.path(); log::info!("{}: {}", artifact.kind(), path.display()); @@ -411,24 +468,90 @@ none /tmp/other proc rw 0 0 fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap(); fs::write(cache_path.join("stray.txt"), "ignore me").unwrap(); + // Build logs. + let logs_dir = cache_path.join("logs"); + fs::create_dir_all(&logs_dir).unwrap(); + fs::write(logs_dir.join("deb-hello-20260101T000000.log"), "log").unwrap(); + fs::write(logs_dir.join("not-a-build.txt"), "ignore me").unwrap(); + let artifacts = discover_artifacts(temp_path, Some(cache_path)); let mut temp_dirs = 0; let mut keyring = false; let mut locks = 0; let mut tarballs = 0; + let mut logs = 0; for a in &artifacts { match a { Artifact::TempDir(_) => temp_dirs += 1, Artifact::KeyringDir(_) => keyring = true, Artifact::LockFile(_) => locks += 1, Artifact::Tarball(_) => tarballs += 1, + Artifact::LogFile(_) => logs += 1, } } assert_eq!(temp_dirs, 2); assert!(keyring); assert_eq!(locks, 1); assert_eq!(tarballs, 2); + assert_eq!(logs, 1); + } + + #[test] + fn test_prune_log_retention() { + let temp = tempdir().unwrap(); + let cache = tempdir().unwrap(); + let cache_path = cache.path(); + let logs_dir = cache_path.join("logs"); + fs::create_dir_all(&logs_dir).unwrap(); + + // Create KEEP_LOGS + 3 logs; the 3 oldest should be pruned by default + let total = KEEP_LOGS + 3; + for i in 0..total { + fs::write( + logs_dir.join(format!("deb-pkg-20260101T{:06}.log", i)), + "log", + ) + .unwrap(); + } + + let report = prune_in( + temp.path(), + Some(cache_path), + PruneOptions { + dry_run: false, + all: false, + }, + ) + .unwrap(); + + let removed_logs = report + .removed + .iter() + .filter(|p| p.starts_with(&logs_dir)) + .count(); + assert_eq!(removed_logs, 3); + + let remaining = fs::read_dir(&logs_dir).unwrap().count(); + assert_eq!(remaining, KEEP_LOGS); + + // --all removes every remaining log + let report = prune_in( + temp.path(), + Some(cache_path), + PruneOptions { + dry_run: false, + all: true, + }, + ) + .unwrap(); + let removed_logs = report + .removed + .iter() + .filter(|p| p.starts_with(&logs_dir)) + .count(); + assert_eq!(removed_logs, KEEP_LOGS); + assert_eq!(fs::read_dir(&logs_dir).unwrap().count(), 0); } #[test] diff --git a/src/ui.rs b/src/ui.rs index fb89a9d..71af7d2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,11 @@ +//! Terminal UI helpers: progress bars, interactive prompts and live build +//! views. + +/// Live build view for `pkh deb` (status bar + rolling log pane) +pub mod deb; +/// Line classifiers rewriting raw subprocess output for the live views +pub mod logfmt; + use crossterm::{ cursor, event, execute, style::{self, Color, Print, SetForegroundColor}, @@ -7,6 +15,8 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::io::{self, Write}; use std::time::Duration; +/// Create a spinner-style progress bar attached to `multi`, returning the bar +/// and a callback compatible with [`crate::ProgressCallback`] pub fn create_progress_bar( multi: &MultiProgress, ) -> (ProgressBar, impl Fn(&str, &str, usize, usize) + '_) { diff --git a/src/ui/deb.rs b/src/ui/deb.rs new file mode 100644 index 0000000..334d406 --- /dev/null +++ b/src/ui/deb.rs @@ -0,0 +1,545 @@ +//! 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"). +//! +//! Subprocess output is captured through a [`LineSink`] implementation, +//! rewritten by classifiers ([`crate::ui::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::ui::logfmt::{ + Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, + MakeClassifier, MmdebstrapClassifier, QuiltClassifier, +}; + +/// Number of lines displayed in the rolling pane +const PANE_LINES: usize = 4; + +/// Minimum interval between pane redraws +const REDRAW_INTERVAL: Duration = Duration::from_millis(50); + +/// Build phases of `pkh deb`, shown in the status bar +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + /// Downloading the chroot tarball (mmdebstrap) + PreparingChroot, + /// Extracting the chroot tarball + ExtractingChroot, + /// Device nodes, /proc bind mount, etc. + FinalizingChroot, + /// apt-get update + UpdatingPackageLists, + /// Installing build-essential & co + InstallingEssentials, + /// quilt push -a + ApplyingPatches, + /// --inject packages + InjectingPackages, + /// apt-get build-dep + InstallingBuildDeps, + /// debian/rules build + Building, + /// fakeroot debian/rules binary + ProducingBinaries, + /// Retrieving produced .deb files + RetrievingArtifacts, +} + +impl Phase { + /// Human-readable label displayed in the status bar + pub fn label(&self) -> &'static str { + match self { + Phase::PreparingChroot => "Preparing chroot", + Phase::ExtractingChroot => "Extracting chroot", + Phase::FinalizingChroot => "Finalizing chroot", + Phase::UpdatingPackageLists => "Updating package lists", + Phase::InstallingEssentials => "Installing essential packages", + Phase::ApplyingPatches => "Applying patches", + Phase::InjectingPackages => "Injecting packages", + Phase::InstallingBuildDeps => "Installing build dependencies", + Phase::Building => "Building package", + Phase::ProducingBinaries => "Producing binary packages", + Phase::RetrievingArtifacts => "Retrieving artifacts", + } + } +} + +/// Default classifier used for a given phase +fn default_classifier(phase: Phase) -> Box { + match phase { + Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()), + Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()), + Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()), + Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")), + Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)), + Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")), + Phase::InstallingBuildDeps => { + Box::new(AptInstallClassifier::new("Installing build dependencies")) + } + Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()), + Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()), + } +} + +/// 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 { + top: ProgressBar, + pane: ProgressBar, + 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` +/// +/// Create one per build (disabled automatically when stdout is not a TTY or +/// when the user requests verbose output), pass it down as +/// `Option>`, and feed subprocess output through [`DebUi::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.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(×tamp); + + 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 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}" + )); + } + + // Rename the log file to include the package identity (best-effort), + // then open it so subsequent captured lines are tee'd. + 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", + 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 deb {} ({}) for {}/{} started {}", + package, + version, + series, + arch, + 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 a phase, installing its default classifier + pub fn phase(&self, phase: Phase) { + self.phase_with(phase, 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) { + { + 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(phase.label()); + self.shared.pane.set_message(""); + } + } + + /// Update the status bar message directly (for in-process work such as + /// tarball extraction that has no subprocess output) + pub fn progress_message(&self, msg: &str) { + if self.active() { + self.shared.top.set_message(msg.to_string()); + } + } + + /// Drive the determinate progress bar directly (e.g. artifact retrieval) + pub fn count_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()); + } + + /// Whether the widget is enabled and still drawn + fn active(&self) -> bool { + self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst) + } + + /// Obtain a sink feeding this view; pass it to `ContextCommand::capture` + pub fn sink(self: &Arc) -> Arc { + Arc::new(Sink { + shared: self.shared.clone(), + }) + } + + /// Remove 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. + pub 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(); + } + + /// Clear the widget and print a success summary with the artifacts + pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) { + self.suspend(); + if self.shared.enabled && !artifacts.is_empty() { + for artifact in artifacts { + println!(" → {}", artifact.display()); + } + println!(" ✔ Built in {}s", elapsed.as_secs()); + } + } + + /// Clear the widget and print a failure summary (recent captured errors + /// and the path to the full log) + pub fn finish_failure(&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()); + } + } + + /// Time elapsed since the view was created + pub fn elapsed(&self) -> Duration { + self.shared.started.elapsed() + } + + /// Path of the full build log file + pub fn log_path(&self) -> PathBuf { + self.shared.log_path.lock().unwrap().clone() + } +} + +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; + 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::>() + .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 log file path for a given timestamp +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")) +} + +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()); + } + // SAFETY: raw exit bypassing destructors, intended in a signal handler + unsafe { + libc::_exit(130); + } +} diff --git a/src/ui/logfmt.rs b/src/ui/logfmt.rs new file mode 100644 index 0000000..f6f3738 --- /dev/null +++ b/src/ui/logfmt.rs @@ -0,0 +1,510 @@ +//! 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; +} + +/// 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 { + 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 { + 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 { + static SUMMARY_RE: OnceLock = OnceLock::new(); + let summary_re = SUMMARY_RE.get_or_init(|| { + Regex::new(r"(\d+) (?:upgraded|newly installed|re-installed)").unwrap() + }); + static UNPACK_RE: OnceLock = OnceLock::new(); + let unpack_re = + UNPACK_RE.get_or_init(|| Regex::new(r"^Unpacking ([^ ]+) \(([^)]+)\)").unwrap()); + static SETUP_RE: OnceLock = 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::().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 { + static APPLYING_RE: OnceLock = 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 { + static PERCENT_RE: OnceLock = 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 `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 { + 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 { + 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 { + 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_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"); + } +}