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::Arc;
|
||||||
use std::sync::Mutex;
|
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::local::LocalDriver;
|
||||||
use super::schroot::SchrootDriver;
|
use super::schroot::SchrootDriver;
|
||||||
use super::ssh::SshDriver;
|
use super::ssh::SshDriver;
|
||||||
@@ -22,6 +41,23 @@ pub trait ContextDriver {
|
|||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
cwd: Option<&str>,
|
cwd: Option<&str>,
|
||||||
) -> io::Result<std::process::ExitStatus>;
|
) -> 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(
|
fn run_output(
|
||||||
&self,
|
&self,
|
||||||
program: &str,
|
program: &str,
|
||||||
@@ -140,6 +176,7 @@ impl Context {
|
|||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
|
sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +287,7 @@ pub struct ContextCommand<'a> {
|
|||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
env: Vec<(String, String)>,
|
env: Vec<(String, String)>,
|
||||||
cwd: Option<String>,
|
cwd: Option<String>,
|
||||||
|
sink: Option<Arc<dyn LineSink>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ContextCommand<'a> {
|
impl<'a> ContextCommand<'a> {
|
||||||
@@ -303,15 +341,33 @@ impl<'a> ContextCommand<'a> {
|
|||||||
self
|
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
|
/// Run command and obtain exit status
|
||||||
pub fn status(&mut self) -> io::Result<std::process::ExitStatus> {
|
pub fn status(&mut self) -> io::Result<std::process::ExitStatus> {
|
||||||
let program = self.program.clone();
|
let program = self.program.clone();
|
||||||
self.context
|
let driver_guard = self.context.driver();
|
||||||
.driver()
|
let driver = driver_guard.as_ref().unwrap();
|
||||||
.as_ref()
|
let result = match &self.sink {
|
||||||
.unwrap()
|
Some(sink) => driver.run_captured(
|
||||||
.run(&self.program, &self.args, &self.env, self.cwd.as_deref())
|
&self.program,
|
||||||
.map_err(|e| contextualize_spawn_error(&program, e))
|
&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
|
/// Run command, capturing output
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! 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()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-2
@@ -1,10 +1,12 @@
|
|||||||
/// Local context: execute commands locally
|
/// Local context: execute commands locally
|
||||||
/// Context driver: Does nothing
|
/// Context driver: Does nothing
|
||||||
use super::api::ContextDriver;
|
use super::api::{ContextDriver, LineSink, Stream};
|
||||||
|
use super::capture::pump;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
pub struct LocalDriver;
|
pub struct LocalDriver;
|
||||||
@@ -80,6 +82,52 @@ impl ContextDriver for LocalDriver {
|
|||||||
cmd.status()
|
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(
|
fn run_output(
|
||||||
&self,
|
&self,
|
||||||
program: &str,
|
program: &str,
|
||||||
|
|||||||
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
mod api;
|
mod api;
|
||||||
|
mod capture;
|
||||||
mod local;
|
mod local;
|
||||||
mod manager;
|
mod manager;
|
||||||
mod schroot;
|
mod schroot;
|
||||||
mod ssh;
|
mod ssh;
|
||||||
mod unshare;
|
mod unshare;
|
||||||
|
|
||||||
pub use api::{Context, ContextCommand, ContextConfig};
|
pub use api::{Context, ContextCommand, ContextConfig, LineSink, Stream};
|
||||||
pub use manager::ContextManager;
|
pub use manager::ContextManager;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
+87
-75
@@ -1,6 +1,6 @@
|
|||||||
/// Schroot context: execute commands in a schroot session
|
/// Schroot context: execute commands in a schroot session
|
||||||
/// Not tested, will need more work!
|
/// Not tested, will need more work!
|
||||||
use super::api::ContextDriver;
|
use super::api::{ContextDriver, LineSink};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -72,6 +72,71 @@ impl SchrootDriver {
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string())
|
.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 {
|
impl ContextDriver for SchrootDriver {
|
||||||
@@ -119,47 +184,26 @@ impl ContextDriver for SchrootDriver {
|
|||||||
cwd: Option<&str>,
|
cwd: Option<&str>,
|
||||||
) -> io::Result<std::process::ExitStatus> {
|
) -> io::Result<std::process::ExitStatus> {
|
||||||
let session_id = self.ensure_session()?;
|
let session_id = self.ensure_session()?;
|
||||||
|
let cmd_args = Self::schroot_args(&session_id, true, program, args, env, cwd);
|
||||||
// Construct the schroot command
|
self.parent().command("schroot").args(cmd_args).status()
|
||||||
// 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() {
|
fn run_captured(
|
||||||
shell_cmd.push_str("env ");
|
&self,
|
||||||
for (k, v) in env {
|
program: &str,
|
||||||
shell_cmd.push_str(&format!("{}={} ", k, v));
|
args: &[String],
|
||||||
}
|
env: &[(String, String)],
|
||||||
}
|
cwd: Option<&str>,
|
||||||
|
sink: Arc<dyn LineSink>,
|
||||||
shell_cmd.push_str(&format!("{} {}", program, args.join(" ")));
|
) -> io::Result<std::process::ExitStatus> {
|
||||||
|
let session_id = self.ensure_session()?;
|
||||||
actual_program = "sh".to_string();
|
let cmd_args = Self::schroot_args(&session_id, true, program, args, env, cwd);
|
||||||
actual_args = vec!["-c".to_string(), shell_cmd];
|
// Forward the sink to the wrapping command so capture chains through
|
||||||
}
|
// the parent context driver (e.g. schroot over ssh).
|
||||||
|
let parent = self.parent();
|
||||||
command_args.push(actual_program);
|
let mut cmd = parent.command("schroot");
|
||||||
command_args.extend(actual_args);
|
cmd.args(cmd_args).capture(sink);
|
||||||
|
cmd.status()
|
||||||
self.parent().command("schroot").args(command_args).status()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_output(
|
fn run_output(
|
||||||
@@ -170,41 +214,9 @@ impl ContextDriver for SchrootDriver {
|
|||||||
cwd: Option<&str>,
|
cwd: Option<&str>,
|
||||||
) -> io::Result<std::process::Output> {
|
) -> io::Result<std::process::Output> {
|
||||||
let session_id = self.ensure_session()?;
|
let session_id = self.ensure_session()?;
|
||||||
|
let cmd_args =
|
||||||
let mut command_args = vec![
|
Self::schroot_args(&session_id, false, program, args, env, cwd);
|
||||||
"-r".to_string(),
|
self.parent().command("schroot").args(cmd_args).output()
|
||||||
"-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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_temp_dir(&self) -> io::Result<String> {
|
fn create_temp_dir(&self) -> io::Result<String> {
|
||||||
|
|||||||
+52
-1
@@ -1,6 +1,7 @@
|
|||||||
/// SSH context: execute commands over an SSH connection
|
/// SSH context: execute commands over an SSH connection
|
||||||
/// Context driver: Copies over SFTP with ssh2, executes commands over ssh2 channels
|
/// 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 log::debug;
|
||||||
use ssh2;
|
use ssh2;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -11,6 +12,7 @@ use std::net::TcpStream;
|
|||||||
use std::os::unix::process::ExitStatusExt;
|
use std::os::unix::process::ExitStatusExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::ExitStatus;
|
use std::process::ExitStatus;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn connect_ssh(host: &str, user: Option<&str>, port: Option<u16>) -> io::Result<ssh2::Session> {
|
pub fn connect_ssh(host: &str, user: Option<&str>, port: Option<u16>) -> io::Result<ssh2::Session> {
|
||||||
let port = port.unwrap_or(22);
|
let port = port.unwrap_or(22);
|
||||||
@@ -139,6 +141,55 @@ impl ContextDriver for SshDriver {
|
|||||||
Ok(ExitStatus::from_raw(code))
|
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(
|
fn run_output(
|
||||||
&self,
|
&self,
|
||||||
program: &str,
|
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 log::debug;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -259,6 +259,22 @@ impl ContextDriver for UnshareDriver {
|
|||||||
self.command(program, args, env, cwd).status()
|
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(
|
fn run_output(
|
||||||
&self,
|
&self,
|
||||||
program: &str,
|
program: &str,
|
||||||
|
|||||||
+39
-5
@@ -1,4 +1,5 @@
|
|||||||
use crate::context::{self, Context, ContextConfig};
|
use crate::context::{self, Context, ContextConfig};
|
||||||
|
use crate::ui::deb::{DebUi, Phase};
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -28,6 +29,7 @@ impl EphemeralContextGuard {
|
|||||||
series: &str,
|
series: &str,
|
||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
base_ctx: Arc<Context>,
|
base_ctx: Arc<Context>,
|
||||||
|
ui: Option<Arc<DebUi>>,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> Result<Self, Box<dyn Error>> {
|
||||||
let current_context_name = context::manager().current_name();
|
let current_context_name = context::manager().current_name();
|
||||||
|
|
||||||
@@ -43,7 +45,8 @@ impl EphemeralContextGuard {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Download and extract the chroot tarball
|
// 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
|
// Switch to an ephemeral context to build the package in the chroot
|
||||||
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
|
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
|
||||||
@@ -64,6 +67,7 @@ impl EphemeralContextGuard {
|
|||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
chroot_path: &PathBuf,
|
chroot_path: &PathBuf,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
|
ui: &Option<Arc<DebUi>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
||||||
let ctx_for_devices = ctx.clone();
|
let ctx_for_devices = ctx.clone();
|
||||||
@@ -119,7 +123,10 @@ impl EphemeralContextGuard {
|
|||||||
series,
|
series,
|
||||||
arch
|
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 {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Using cached chroot tarball for {} (arch: {:?})",
|
"Using cached chroot tarball for {} (arch: {:?})",
|
||||||
@@ -130,10 +137,16 @@ impl EphemeralContextGuard {
|
|||||||
|
|
||||||
// Extract tarball to chroot directory
|
// Extract tarball to chroot directory
|
||||||
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
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
|
// Create device nodes in the chroot
|
||||||
log::debug!("Creating device nodes in 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())?;
|
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
|
||||||
|
|
||||||
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
||||||
@@ -149,6 +162,7 @@ impl EphemeralContextGuard {
|
|||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
tarball_path: &Path,
|
tarball_path: &Path,
|
||||||
ctx: Arc<context::Context>,
|
ctx: Arc<context::Context>,
|
||||||
|
ui: &Option<Arc<DebUi>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
// 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");
|
let lockfile_path = tarball_path.with_extension("lock");
|
||||||
@@ -182,6 +196,10 @@ impl EphemeralContextGuard {
|
|||||||
cmd.arg(series)
|
cmd.arg(series)
|
||||||
.arg(tarball_path.to_string_lossy().to_string());
|
.arg(tarball_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
if let Some(u) = ui {
|
||||||
|
cmd.capture(u.sink());
|
||||||
|
}
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
@@ -216,6 +234,7 @@ impl EphemeralContextGuard {
|
|||||||
fn extract_tarball(
|
fn extract_tarball(
|
||||||
tarball_path: &PathBuf,
|
tarball_path: &PathBuf,
|
||||||
chroot_path: &PathBuf,
|
chroot_path: &PathBuf,
|
||||||
|
ui: Option<&DebUi>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
// Create the chroot directory
|
// Create the chroot directory
|
||||||
fs::create_dir_all(chroot_path)?;
|
fs::create_dir_all(chroot_path)?;
|
||||||
@@ -225,8 +244,23 @@ impl EphemeralContextGuard {
|
|||||||
let xz_decoder = XzDecoder::new(tarball_file);
|
let xz_decoder = XzDecoder::new(tarball_file);
|
||||||
let mut archive = Archive::new(xz_decoder);
|
let mut archive = Archive::new(xz_decoder);
|
||||||
|
|
||||||
// Extract all files to the chroot directory
|
// Extract entries one by one so progress can be reported (a full
|
||||||
archive.unpack(chroot_path)?;
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-28
@@ -1,7 +1,9 @@
|
|||||||
/// Local binary package building
|
/// Local binary package building
|
||||||
/// Directly calling 'debian/rules' in current context
|
/// 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::deb::find_dsc_file;
|
||||||
|
use crate::ui::deb::{DebUi, Phase};
|
||||||
|
use crate::ui::logfmt::QuiltClassifier;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -11,6 +13,17 @@ use std::sync::Arc;
|
|||||||
use crate::apt;
|
use crate::apt;
|
||||||
use crate::deb::cross;
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn build(
|
pub async fn build(
|
||||||
package: &str,
|
package: &str,
|
||||||
@@ -23,7 +36,10 @@ pub async fn build(
|
|||||||
ppa: Option<&[&str]>,
|
ppa: Option<&[&str]>,
|
||||||
inject_packages: Option<&[&str]>,
|
inject_packages: Option<&[&str]>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
|
ui: Option<Arc<DebUi>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
|
||||||
|
|
||||||
// Environment
|
// Environment
|
||||||
let mut env = HashMap::<String, String>::new();
|
let mut env = HashMap::<String, String>::new();
|
||||||
env.insert("LANG".to_string(), "C".to_string());
|
env.insert("LANG".to_string(), "C".to_string());
|
||||||
@@ -155,10 +171,13 @@ pub async fn build(
|
|||||||
|
|
||||||
// Update package lists
|
// Update package lists
|
||||||
log::debug!("Updating package lists for local build...");
|
log::debug!("Updating package lists for local build...");
|
||||||
let status = ctx
|
if let Some(u) = &ui {
|
||||||
.command("apt-get")
|
u.phase(Phase::UpdatingPackageLists);
|
||||||
.envs(env.clone())
|
}
|
||||||
.arg("update")
|
let status = cap(
|
||||||
|
ctx.command("apt-get").envs(env.clone()).arg("update"),
|
||||||
|
&sink,
|
||||||
|
)
|
||||||
.status()
|
.status()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
@@ -193,7 +212,10 @@ pub async fn build(
|
|||||||
cmd.arg(format!("libc6:{arch}"));
|
cmd.arg(format!("libc6:{arch}"));
|
||||||
cmd.arg(format!("libc6-dev:{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() {
|
if !status.success() {
|
||||||
return Err("Could not install essential packages for the build".into());
|
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")?;
|
.ok_or("Invalid package directory path")?;
|
||||||
|
|
||||||
// Apply quilt patches if the package provides a patch series
|
// 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
|
// Install injected packages if specified
|
||||||
if let Some(packages) = inject_packages {
|
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
|
// Install arch-specific build dependencies
|
||||||
log::debug!("Installing 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");
|
let mut cmd = ctx.command("apt-get");
|
||||||
cmd.current_dir(package_dir_str)
|
cmd.current_dir(package_dir_str)
|
||||||
.envs(env.clone())
|
.envs(env.clone())
|
||||||
@@ -224,50 +249,68 @@ pub async fn build(
|
|||||||
cmd.arg(format!("--host-architecture={arch}"));
|
cmd.arg(format!("--host-architecture={arch}"));
|
||||||
}
|
}
|
||||||
cmd.arg("--arch-only");
|
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 build-dep fails, we try to explain the failure using dose-debcheck
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
|
if let Some(u) = &ui {
|
||||||
|
u.suspend();
|
||||||
|
}
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
||||||
return Err("Could not install build-dependencies for the build".into());
|
return Err("Could not install build-dependencies for the build".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install arch-independant build dependencies
|
// Install arch-independant build dependencies
|
||||||
log::debug!("Installing arch-independant build dependencies...");
|
log::debug!("Installing arch-independant build dependencies...");
|
||||||
let status = ctx
|
let status = cap(
|
||||||
.command("apt-get")
|
ctx.command("apt-get")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
.envs(env.clone())
|
.envs(env.clone())
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("build-dep")
|
.arg("build-dep")
|
||||||
.arg("./")
|
.arg("./"),
|
||||||
|
&sink,
|
||||||
|
)
|
||||||
.status()?;
|
.status()?;
|
||||||
|
|
||||||
// If build-dep fails, we try to explain the failure using dose-debcheck
|
// If build-dep fails, we try to explain the failure using dose-debcheck
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
|
if let Some(u) = &ui {
|
||||||
|
u.suspend();
|
||||||
|
}
|
||||||
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
|
||||||
return Err("Could not install build-dependencies for the build".into());
|
return Err("Could not install build-dependencies for the build".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the build step
|
// Run the build step
|
||||||
log::debug!("Building (debian/rules build) package...");
|
log::debug!("Building (debian/rules build) package...");
|
||||||
let status = ctx
|
if let Some(u) = &ui {
|
||||||
.command("debian/rules")
|
u.phase(Phase::Building);
|
||||||
|
}
|
||||||
|
let status = cap(
|
||||||
|
ctx.command("debian/rules")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
.envs(env.clone())
|
.envs(env.clone())
|
||||||
.arg("build")
|
.arg("build"),
|
||||||
|
&sink,
|
||||||
|
)
|
||||||
.status()?;
|
.status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Error while building the package".into());
|
return Err("Error while building the package".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the 'binary' step to produce deb
|
// Run the 'binary' step to produce deb
|
||||||
let status = ctx
|
if let Some(u) = &ui {
|
||||||
.command("fakeroot")
|
u.phase(Phase::ProducingBinaries);
|
||||||
|
}
|
||||||
|
let status = cap(
|
||||||
|
ctx.command("fakeroot")
|
||||||
.current_dir(package_dir_str)
|
.current_dir(package_dir_str)
|
||||||
.envs(env.clone())
|
.envs(env.clone())
|
||||||
.arg("debian/rules")
|
.arg("debian/rules")
|
||||||
.arg("binary")
|
.arg("binary"),
|
||||||
|
&sink,
|
||||||
|
)
|
||||||
.status()?;
|
.status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -284,6 +327,8 @@ fn apply_quilt_patches(
|
|||||||
package_dir: &str,
|
package_dir: &str,
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
|
ui: &Option<Arc<DebUi>>,
|
||||||
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let series_path = Path::new(package_dir).join("debian/patches/series");
|
let series_path = Path::new(package_dir).join("debian/patches/series");
|
||||||
if !ctx.exists(&series_path)? {
|
if !ctx.exists(&series_path)? {
|
||||||
@@ -296,9 +341,11 @@ fn apply_quilt_patches(
|
|||||||
|
|
||||||
// Skip patch application if the series file contains no patches
|
// Skip patch application if the series file contains no patches
|
||||||
let series_content = ctx.read_file(&series_path)?;
|
let series_content = ctx.read_file(&series_path)?;
|
||||||
let has_patches = series_content
|
let total_patches = series_content
|
||||||
.lines()
|
.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 {
|
if !has_patches {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"'{}' contains no patches, skipping quilt patch application",
|
"'{}' contains no patches, skipping quilt patch application",
|
||||||
@@ -309,27 +356,36 @@ fn apply_quilt_patches(
|
|||||||
|
|
||||||
// Make sure quilt is available in the build context
|
// Make sure quilt is available in the build context
|
||||||
log::debug!("Installing quilt for patch application...");
|
log::debug!("Installing quilt for patch application...");
|
||||||
let status = ctx
|
let status = cap(
|
||||||
.command("apt-get")
|
ctx.command("apt-get")
|
||||||
.envs(env.clone())
|
.envs(env.clone())
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
.arg("install")
|
.arg("install")
|
||||||
.arg("quilt")
|
.arg("quilt"),
|
||||||
|
sink,
|
||||||
|
)
|
||||||
.status()?;
|
.status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Could not install 'quilt', required to apply patches".into());
|
return Err("Could not install 'quilt', required to apply patches".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply all patches listed in the series
|
// 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();
|
let mut patch_env = env.clone();
|
||||||
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
|
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
|
||||||
let status = ctx
|
let status = cap(
|
||||||
.command("quilt")
|
ctx.command("quilt")
|
||||||
.current_dir(package_dir)
|
.current_dir(package_dir)
|
||||||
.envs(patch_env)
|
.envs(patch_env)
|
||||||
.arg("push")
|
.arg("push")
|
||||||
.arg("-a")
|
.arg("-a"),
|
||||||
|
sink,
|
||||||
|
)
|
||||||
.status()?;
|
.status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err("Failed to apply quilt patches ('quilt push -a')".into());
|
return Err("Failed to apply quilt patches ('quilt push -a')".into());
|
||||||
@@ -360,9 +416,15 @@ fn install_injected_packages(
|
|||||||
packages: &[&str],
|
packages: &[&str],
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
ctx: Arc<Context>,
|
ctx: Arc<Context>,
|
||||||
|
ui: &Option<Arc<DebUi>>,
|
||||||
|
sink: &Option<Arc<dyn LineSink>>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
log::info!("Installing injected packages: {:?}", packages);
|
log::info!("Installing injected packages: {:?}", packages);
|
||||||
|
|
||||||
|
if let Some(u) = ui {
|
||||||
|
u.phase(Phase::InjectingPackages);
|
||||||
|
}
|
||||||
|
|
||||||
// Separate .deb files from package names
|
// Separate .deb files from package names
|
||||||
let mut deb_files: Vec<String> = Vec::new();
|
let mut deb_files: Vec<String> = Vec::new();
|
||||||
let mut package_names: Vec<&str> = Vec::new();
|
let mut package_names: Vec<&str> = Vec::new();
|
||||||
@@ -400,7 +462,7 @@ fn install_injected_packages(
|
|||||||
if !package_names.is_empty() {
|
if !package_names.is_empty() {
|
||||||
cmd.args(&package_names);
|
cmd.args(&package_names);
|
||||||
}
|
}
|
||||||
let status = cmd.status()?;
|
let status = cap(&mut cmd, sink).status()?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(format!("Could not install injected packages: {:?}", deb_files).into());
|
return Err(format!("Could not install injected packages: {:?}", deb_files).into());
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-6
@@ -3,6 +3,7 @@ mod ephemeral;
|
|||||||
mod local;
|
mod local;
|
||||||
|
|
||||||
use crate::context::{self, Context};
|
use crate::context::{self, Context};
|
||||||
|
use crate::ui::deb::{DebUi, Phase};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -15,6 +16,11 @@ pub enum BuildMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build package in 'cwd' to a .deb
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn build_binary_package(
|
pub async fn build_binary_package(
|
||||||
arch: Option<&str>,
|
arch: Option<&str>,
|
||||||
@@ -26,7 +32,34 @@ pub async fn build_binary_package(
|
|||||||
ppa: Option<&[&str]>,
|
ppa: Option<&[&str]>,
|
||||||
inject_packages: Option<&[&str]>,
|
inject_packages: Option<&[&str]>,
|
||||||
ctx: Option<Arc<Context>>,
|
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("."));
|
let cwd = cwd.unwrap_or_else(|| Path::new("."));
|
||||||
|
|
||||||
// Parse changelog to get package name, version and series
|
// Parse changelog to get package name, version and series
|
||||||
@@ -61,12 +94,22 @@ pub async fn build_binary_package(
|
|||||||
// Use provided context or get current
|
// Use provided context or get current
|
||||||
let base_ctx = ctx.unwrap_or_else(context::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 {
|
let mut guard = if mode == BuildMode::Local {
|
||||||
Some(
|
Some(
|
||||||
ephemeral::EphemeralContextGuard::new_with_context(
|
ephemeral::EphemeralContextGuard::new_with_context(
|
||||||
series,
|
series,
|
||||||
chroot_arch,
|
chroot_arch,
|
||||||
base_ctx.clone(),
|
base_ctx.clone(),
|
||||||
|
ui.clone(),
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
@@ -74,6 +117,7 @@ pub async fn build_binary_package(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let result = async {
|
||||||
// Get the build context - either the ephemeral context or the base context
|
// Get the build context - either the ephemeral context or the base context
|
||||||
let build_ctx = if mode == BuildMode::Local {
|
let build_ctx = if mode == BuildMode::Local {
|
||||||
context::current()
|
context::current()
|
||||||
@@ -106,27 +150,58 @@ pub async fn build_binary_package(
|
|||||||
ppa,
|
ppa,
|
||||||
inject_packages,
|
inject_packages,
|
||||||
build_ctx.clone(),
|
build_ctx.clone(),
|
||||||
|
ui.clone(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve produced .deb files
|
// 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 remote_files = build_ctx.list_files(Path::new(&build_root))?;
|
||||||
for remote_file in remote_files {
|
let deb_files: Vec<PathBuf> = remote_files
|
||||||
if remote_file.extension().is_some_and(|ext| ext == "deb") {
|
.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 file_name = remote_file.file_name().ok_or("Invalid remote filename")?;
|
||||||
let local_dest = parent_dir.join(file_name);
|
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
|
// 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();
|
g.mark_build_successful();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the current package directory by trying both patterns:
|
/// Find the current package directory by trying both patterns:
|
||||||
@@ -321,6 +396,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(ctx),
|
Some(ctx),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Cannot build binary package (deb)");
|
.expect("Cannot build binary package (deb)");
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub mod pull;
|
|||||||
/// Handle package-specific quirks and workarounds
|
/// Handle package-specific quirks and workarounds
|
||||||
pub mod quirks;
|
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...
|
/// Handle context for .deb building: locally, over ssh, in a chroot...
|
||||||
pub mod context;
|
pub mod context;
|
||||||
|
|
||||||
|
|||||||
+25
-7
@@ -12,8 +12,6 @@ use pkh::changelog::generate_entry;
|
|||||||
use indicatif_log_bridge::LogWrapper;
|
use indicatif_log_bridge::LogWrapper;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
|
|
||||||
mod ui;
|
|
||||||
|
|
||||||
/// Obtain the current working directory, exiting with a helpful message on failure.
|
/// Obtain the current working directory, exiting with a helpful message on failure.
|
||||||
fn current_dir_or_exit() -> std::path::PathBuf {
|
fn current_dir_or_exit() -> std::path::PathBuf {
|
||||||
match std::env::current_dir() {
|
match std::env::current_dir() {
|
||||||
@@ -75,7 +73,9 @@ fn main() {
|
|||||||
.arg(arg!(--cross "Cross-compile for target architecture (instead of qemu-binfmt)")
|
.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))
|
.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)
|
.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(
|
.subcommand(
|
||||||
Command::new("context")
|
Command::new("context")
|
||||||
@@ -139,7 +139,7 @@ fn main() {
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let archive = sub_matches.get_one::<bool>("archive").unwrap_or(&false);
|
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
|
// Convert PPA to base URL if provided
|
||||||
let base_url = ppa.and_then(|ppa_str| {
|
let base_url = ppa.and_then(|ppa_str| {
|
||||||
@@ -194,7 +194,7 @@ fn main() {
|
|||||||
pkh::distro_info::get_ordered_series_name(&dist).await
|
pkh::distro_info::get_ordered_series_name(&dist).await
|
||||||
}) {
|
}) {
|
||||||
Ok(series_list) => {
|
Ok(series_list) => {
|
||||||
match ui::select_series(&series_list, ¤t_series) {
|
match pkh::ui::select_series(&series_list, ¤t_series) {
|
||||||
Ok(selected) => Some(selected),
|
Ok(selected) => Some(selected),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
@@ -284,8 +284,17 @@ fn main() {
|
|||||||
Some("local") => Some(pkh::deb::BuildMode::Local),
|
Some("local") => Some(pkh::deb::BuildMode::Local),
|
||||||
_ => None,
|
_ => 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(
|
pkh::deb::build_binary_package(
|
||||||
arch,
|
arch,
|
||||||
series,
|
series,
|
||||||
@@ -296,13 +305,22 @@ fn main() {
|
|||||||
ppa,
|
ppa,
|
||||||
inject_packages,
|
inject_packages,
|
||||||
None,
|
None,
|
||||||
|
ui.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}) {
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(artifacts) => {
|
||||||
|
let _ = artifacts;
|
||||||
|
info!("Done.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Some(("context", sub_matches)) => {
|
Some(("context", sub_matches)) => {
|
||||||
let mgr = pkh::context::manager();
|
let mgr = pkh::context::manager();
|
||||||
|
|
||||||
|
|||||||
+129
-3
@@ -13,11 +13,16 @@
|
|||||||
//! **stale download lockfiles** (`~/.cache/pkh/*.lock`).
|
//! **stale download lockfiles** (`~/.cache/pkh/*.lock`).
|
||||||
//! - **The shared apt keyring directory** (`/tmp/pkh-keyrings`), used by
|
//! - **The shared apt keyring directory** (`/tmp/pkh-keyrings`), used by
|
||||||
//! mmdebstrap runs.
|
//! 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
|
//! The [`prune()`] function discovers and removes all of the above. By
|
||||||
//! default it removes everything that is cheap to regenerate (residual
|
//! default it removes everything that is cheap to regenerate (residual
|
||||||
//! directories, keyrings, stale lockfiles); pass [`PruneOptions::all`] to also
|
//! directories, keyrings, stale lockfiles, old build logs); pass
|
||||||
//! discard the cached chroot tarballs, which are expensive to re-download.
|
//! [`PruneOptions::all`] to also discard the cached chroot tarballs and all
|
||||||
|
//! build logs, which are expensive to re-create.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
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.
|
/// One discovered artifact that prune can act on.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Artifact {
|
enum Artifact {
|
||||||
@@ -65,6 +73,8 @@ enum Artifact {
|
|||||||
LockFile(PathBuf),
|
LockFile(PathBuf),
|
||||||
/// A cached chroot tarball.
|
/// A cached chroot tarball.
|
||||||
Tarball(PathBuf),
|
Tarball(PathBuf),
|
||||||
|
/// A build log file under `<cache>/logs`.
|
||||||
|
LogFile(PathBuf),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Artifact {
|
impl Artifact {
|
||||||
@@ -73,7 +83,8 @@ impl Artifact {
|
|||||||
Artifact::TempDir(p)
|
Artifact::TempDir(p)
|
||||||
| Artifact::KeyringDir(p)
|
| Artifact::KeyringDir(p)
|
||||||
| Artifact::LockFile(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::KeyringDir(_) => "apt keyring cache",
|
||||||
Artifact::LockFile(_) => "stale lockfile",
|
Artifact::LockFile(_) => "stale lockfile",
|
||||||
Artifact::Tarball(_) => "cached chroot tarball",
|
Artifact::Tarball(_) => "cached chroot tarball",
|
||||||
|
Artifact::LogFile(_) => "build log",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,9 +209,48 @@ 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
|
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
|
/// Remove a path, first attempting a direct removal and only escalating to a
|
||||||
/// privileged `rm -rf` when the direct attempt fails.
|
/// privileged `rm -rf` when the direct attempt fails.
|
||||||
///
|
///
|
||||||
@@ -284,6 +335,8 @@ pub fn prune_in(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let removable_logs = removable_logs(&artifacts, options.all);
|
||||||
|
|
||||||
for artifact in &artifacts {
|
for artifact in &artifacts {
|
||||||
// Cached tarballs are only removed when --all is requested: they are
|
// Cached tarballs are only removed when --all is requested: they are
|
||||||
// expensive to re-download.
|
// expensive to re-download.
|
||||||
@@ -291,6 +344,13 @@ pub fn prune_in(
|
|||||||
continue;
|
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();
|
let path = artifact.path();
|
||||||
log::info!("{}: {}", artifact.kind(), path.display());
|
log::info!("{}: {}", artifact.kind(), path.display());
|
||||||
|
|
||||||
@@ -411,24 +471,90 @@ none /tmp/other proc rw 0 0
|
|||||||
fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap();
|
fs::write(cache_path.join("noble-buildd.tar.lock"), "lock").unwrap();
|
||||||
fs::write(cache_path.join("stray.txt"), "ignore me").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 artifacts = discover_artifacts(temp_path, Some(cache_path));
|
||||||
|
|
||||||
let mut temp_dirs = 0;
|
let mut temp_dirs = 0;
|
||||||
let mut keyring = false;
|
let mut keyring = false;
|
||||||
let mut locks = 0;
|
let mut locks = 0;
|
||||||
let mut tarballs = 0;
|
let mut tarballs = 0;
|
||||||
|
let mut logs = 0;
|
||||||
for a in &artifacts {
|
for a in &artifacts {
|
||||||
match a {
|
match a {
|
||||||
Artifact::TempDir(_) => temp_dirs += 1,
|
Artifact::TempDir(_) => temp_dirs += 1,
|
||||||
Artifact::KeyringDir(_) => keyring = true,
|
Artifact::KeyringDir(_) => keyring = true,
|
||||||
Artifact::LockFile(_) => locks += 1,
|
Artifact::LockFile(_) => locks += 1,
|
||||||
Artifact::Tarball(_) => tarballs += 1,
|
Artifact::Tarball(_) => tarballs += 1,
|
||||||
|
Artifact::LogFile(_) => logs += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(temp_dirs, 2);
|
assert_eq!(temp_dirs, 2);
|
||||||
assert!(keyring);
|
assert!(keyring);
|
||||||
assert_eq!(locks, 1);
|
assert_eq!(locks, 1);
|
||||||
assert_eq!(tarballs, 2);
|
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]
|
#[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::{
|
use crossterm::{
|
||||||
cursor, event, execute,
|
cursor, event, execute,
|
||||||
style::{self, Color, Print, SetForegroundColor},
|
style::{self, Color, Print, SetForegroundColor},
|
||||||
@@ -7,6 +15,8 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
|||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::time::Duration;
|
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(
|
pub fn create_progress_bar(
|
||||||
multi: &MultiProgress,
|
multi: &MultiProgress,
|
||||||
) -> (ProgressBar, impl Fn(&str, &str, usize, usize) + '_) {
|
) -> (ProgressBar, impl Fn(&str, &str, usize, usize) + '_) {
|
||||||
|
|||||||
+535
@@ -0,0 +1,535 @@
|
|||||||
|
//! 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,498 @@
|
|||||||
|
//! 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