deb: change ui/ux of pkh deb
CI / build (push) Successful in 2m54s
CI / test (push) Skipped
CI / snap (push) Failing after 12s

This commit is contained in:
2026-08-22 22:26:20 +02:00
parent e2d201d815
commit e5adf600c3
16 changed files with 1899 additions and 199 deletions
+62 -6
View File
@@ -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<std::process::ExitStatus>;
/// 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<dyn LineSink>,
) -> io::Result<std::process::ExitStatus> {
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<String>,
env: Vec<(String, String)>,
cwd: Option<String>,
sink: Option<Arc<dyn LineSink>>,
}
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<dyn LineSink>) -> &mut Self {
self.sink = Some(sink);
self
}
/// Run command and obtain exit status
pub fn status(&mut self) -> io::Result<std::process::ExitStatus> {
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
+125
View File
@@ -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<Regex> = 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<R: Read>(mut reader: R, stream: Stream, sink: &dyn LineSink) {
let mut buf: Vec<u8> = 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<u8> = 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<Vec<(Stream, String)>>);
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()),
]
);
}
}
+49 -2
View File
@@ -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<dyn LineSink>,
) -> io::Result<std::process::ExitStatus> {
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,
+2 -1
View File
@@ -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;
+87 -76
View File
@@ -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<String> {
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<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];
}
(actual_program, actual_args)
}
}
impl ContextDriver for SchrootDriver {
@@ -119,47 +184,26 @@ impl ContextDriver for SchrootDriver {
cwd: Option<&str>,
) -> io::Result<std::process::ExitStatus> {
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<dyn LineSink>,
) -> io::Result<std::process::ExitStatus> {
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<std::process::Output> {
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<String> {
+52 -1
View File
@@ -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<u16>) -> io::Result<ssh2::Session> {
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<dyn LineSink>,
) -> io::Result<std::process::ExitStatus> {
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,
+17 -1
View File
@@ -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<dyn LineSink>,
) -> io::Result<std::process::ExitStatus> {
// 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,
+39 -5
View File
@@ -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<Context>,
ui: Option<Arc<DebUi>>,
) -> Result<Self, Box<dyn Error>> {
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<context::Context>,
ui: &Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
// 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<context::Context>,
ui: &Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
// 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<dyn Error>> {
// 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(())
}
+117 -55
View File
@@ -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<Arc<dyn LineSink>>,
) -> &'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<Context>,
ui: Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
// Environment
let mut env = HashMap::<String, String>::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<String, String>,
ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
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<String, String>,
ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
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<String> = 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());
}
+125 -40
View File
@@ -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<Arc<Context>>,
) -> Result<(), Box<dyn Error>> {
ui: Option<Arc<DebUi>>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
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<BuildMode>,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
ui: &Option<Arc<DebUi>>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
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<PathBuf> = 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)");
+3
View File
@@ -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;
+30 -9
View File
@@ -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 <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::<bool>("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, &current_series) {
match pkh::ui::select_series(&series_list, &current_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::<bool>("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)) => {
+126 -3
View File
@@ -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 `<cache>/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<Artifact
}
}
// Build logs live in a dedicated subdirectory of the cache dir
if let Some(cache) = cache_dir {
let logs_dir = cache.join("logs");
if let Ok(entries) = fs::read_dir(&logs_dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.is_file() && name.starts_with("deb-") && name.ends_with(".log") {
artifacts.push(Artifact::LogFile(path));
}
}
}
}
artifacts
}
/// Compute the set of build log paths that should be removed for the given
/// options: all of them with `--all`, otherwise every log except the
/// [`KEEP_LOGS`] newest ones (filenames embed timestamps, so lexicographic
/// order is chronological).
fn removable_logs(artifacts: &[Artifact], all: bool) -> 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]
+10
View File
@@ -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) + '_) {
+545
View File
@@ -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<dyn Classifier> {
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<dyn Classifier>,
lines: VecDeque<(Kind, String)>,
errors: Vec<String>,
last_draw: Instant,
bar_total: u64,
}
/// State shared between [`DebUi`] and its sinks
struct Shared {
top: ProgressBar,
pane: ProgressBar,
state: Mutex<Pipeline>,
tee: Mutex<Option<File>>,
log_path: Mutex<PathBuf>,
timestamp: String,
enabled: bool,
/// Set once the widget has been removed from the terminal; afterwards all
/// rendering is skipped so late events cannot redraw stale frames.
suspended: AtomicBool,
started: Instant,
}
/// Live build view for `pkh deb`
///
/// Create one per build (disabled automatically when stdout is not a TTY or
/// when the user requests verbose output), pass it down as
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`].
pub struct DebUi {
shared: Arc<Shared>,
}
impl DebUi {
/// Create a live view attached to `multi`
///
/// When stdout is not a TTY, the widget is disabled: no bars are drawn,
/// but captured output is still tee'd to the log file.
pub fn new(multi: &MultiProgress) -> Self {
let enabled = is_stdout_tty();
let top = if enabled {
let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_style(spinner_style());
pb.set_prefix("Building package");
pb.set_message("(starting…)");
pb
} else {
ProgressBar::hidden()
};
let pane = if enabled {
let pb = multi.add(ProgressBar::new(0));
pb.enable_steady_tick(Duration::from_millis(150));
// No template margin: multi-line messages are only prefixed by
// the template on their first line, which would misalign the
// pane; each rendered line carries its own indent instead.
pb.set_style(
ProgressStyle::default_bar()
.template("{msg}")
.expect("valid template"),
);
pb.set_message(" │ (starting…)");
pb
} else {
ProgressBar::hidden()
};
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
let log_path = default_log_path(&timestamp);
let ui = Self {
shared: Arc::new(Shared {
top,
pane,
state: Mutex::new(Pipeline {
classifier: Box::new(GenericClassifier::new()),
lines: VecDeque::new(),
errors: Vec::new(),
last_draw: Instant::now(),
bar_total: 0,
}),
tee: Mutex::new(None),
log_path: Mutex::new(log_path.clone()),
timestamp,
enabled,
suspended: AtomicBool::new(false),
started: Instant::now(),
}),
};
if ui.shared.enabled {
install_sigint_hook(&log_path);
}
ui
}
/// Identify the 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<dyn Classifier>) {
{
let mut st = self.shared.state.lock().unwrap();
st.classifier = classifier;
st.lines.clear();
st.bar_total = 0;
st.last_draw = Instant::now();
}
if self.shared.enabled {
self.shared.top.set_style(spinner_style());
self.shared.top.set_message(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<Self>) -> Arc<dyn LineSink> {
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::<Vec<_>>()
.iter()
.rev()
{
eprintln!(" {}", err);
}
}
let log_path = self.shared.log_path.lock().unwrap().clone();
if log_path.exists() {
eprintln!("Full log: {}", log_path.display());
}
}
/// 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<Shared>,
}
impl LineSink for Sink {
fn line(&self, stream: Stream, line: &str) {
// Tee the raw line first: nothing should be lost, even when the
// widget is disabled.
{
let mut tee = self.shared.tee.lock().unwrap();
if let Some(file) = tee.as_mut() {
let _ = writeln!(file, "{line}");
}
}
if !self.shared.enabled || self.shared.suspended.load(Ordering::SeqCst) {
return;
}
let mut st = self.shared.state.lock().unwrap();
for action in st.classifier.feed(stream, line) {
match action {
Action::Hidden => {}
Action::Progress { pos, total } => {
apply_progress(&self.shared.top, &mut st, pos, total)
}
Action::Shown(text) => push_line(&self.shared, &mut st, Kind::Normal, text),
Action::Warning(text) => push_line(&self.shared, &mut st, Kind::Warning, text),
Action::Error(text) => {
if st.errors.len() < 100 {
st.errors.push(text.clone());
}
push_line(&self.shared, &mut st, Kind::Error, text);
}
}
}
}
}
/// Update the determinate progress bar, restyling it when needed
fn apply_progress(top: &ProgressBar, st: &mut Pipeline, pos: u64, total: u64) {
if total == 0 {
return;
}
if st.bar_total != total {
st.bar_total = total;
top.set_style(determinate_style());
top.set_length(total);
}
top.set_position(pos.min(total));
}
/// Push a line into the rolling pane and redraw (throttled)
///
/// When the pane is full, the oldest non-error line is dropped first so
/// errors stay visible longer.
fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) {
st.lines.push_back((kind, text));
while st.lines.len() > PANE_LINES {
let drop_idx = st
.lines
.iter()
.position(|(k, _)| *k != Kind::Error)
.unwrap_or(0);
st.lines.remove(drop_idx);
}
let now = Instant::now();
if now.duration_since(st.last_draw) >= REDRAW_INTERVAL {
st.last_draw = now;
shared.pane.set_message(render_pane(&st.lines));
}
}
/// Render the pane content with per-kind colors
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
lines
.iter()
.map(|(kind, text)| match kind {
Kind::Normal => format!("{text}"),
Kind::Warning => format!("{}", text.as_str().yellow()),
Kind::Error => format!("{}", text.as_str().red()),
})
.collect::<Vec<_>>()
.join("\n")
}
/// Status bar style while no determinate progress is known
///
/// The target lives on the first line and the current phase/message on its own
/// line below, so narrow terminals are not overflowed.
fn spinner_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg}")
.expect("valid template")
}
/// Status bar style with a determinate progress bar
///
/// Same two-line layout as [`spinner_style`], plus the bar on a third line.
fn determinate_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("> {spinner:.blue} {prefix}\n {msg} [{bar:24.cyan/blue}] {pos}/{len}")
.expect("valid template")
.progress_chars("=> ")
}
/// Whether stdout is a terminal
fn is_stdout_tty() -> bool {
// SAFETY: isatty only inspects the file descriptor
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
}
/// Default 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<Option<PathBuf>> = Mutex::new(None);
static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false);
/// Install a best-effort Ctrl+C handler clearing the widget and pointing at
/// the log file before exiting
fn install_sigint_hook(log_path: &Path) {
update_sigint_log_path(log_path);
if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
// SAFETY: installing a signal handler; the handler itself is best-effort
// (it performs non async-signal-safe operations, acceptable here because
// it immediately exits afterwards).
unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as usize);
}
}
/// Point the sigint handler at the current log file location
fn update_sigint_log_path(log_path: &Path) {
*SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf());
}
extern "C" fn on_sigint(_sig: libc::c_int) {
// Best-effort cleanup: clear leftover widget lines and show the cursor
let _ = execute!(
std::io::stdout(),
Clear(ClearType::FromCursorDown),
cursor::Show
);
if let Ok(guard) = SIGINT_LOG_PATH.try_lock()
&& let Some(path) = guard.as_ref()
{
eprintln!("\nInterrupted — full log: {}", path.display());
}
// SAFETY: raw exit bypassing destructors, intended in a signal handler
unsafe {
libc::_exit(130);
}
}
+510
View File
@@ -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<Action>;
}
/// 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<Action> {
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<Action> {
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<Action> {
static SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
let summary_re = SUMMARY_RE.get_or_init(|| {
Regex::new(r"(\d+) (?:upgraded|newly installed|re-installed)").unwrap()
});
static UNPACK_RE: OnceLock<Regex> = OnceLock::new();
let unpack_re =
UNPACK_RE.get_or_init(|| Regex::new(r"^Unpacking ([^ ]+) \(([^)]+)\)").unwrap());
static SETUP_RE: OnceLock<Regex> = 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::<u64>().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<Action> {
static APPLYING_RE: OnceLock<Regex> = 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<Action> {
static PERCENT_RE: OnceLock<Regex> = 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<Action> {
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<Action> {
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<Action> {
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");
}
}