deb: change ui/ux of pkh deb
This commit is contained in:
+62
-6
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)");
|
||||
|
||||
@@ -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
@@ -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, ¤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::<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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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(×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<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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user