diff --git a/src/build/mod.rs b/src/build/mod.rs index fec81d6..b2b1b29 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -14,7 +14,6 @@ pub mod env; use std::collections::{BTreeMap, HashMap}; use std::error::Error; -use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; @@ -24,8 +23,8 @@ use crate::context::{LineSink, Stream}; use crate::debian::{ ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs, }; -use crate::ui::deb::DebUi; -use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier}; +use crate::logfmt::{DpkgSourceClassifier, GenericClassifier}; +use crate::report::{BuildView, Prompter}; /// Whether the upload distributes the upstream orig tarballs (`--orig`), /// mirroring the `dpkg-genchanges` source styles. @@ -74,105 +73,108 @@ pub struct SourceBuildOutput { pub signed: bool, } +impl SourceBuildOutput { + /// All produced artifacts in distribution order: dsc → tarballs → + /// buildinfo → changes. + pub fn artifacts(&self) -> Vec { + let mut artifacts = Vec::with_capacity(3 + self.tarballs.len()); + artifacts.push(self.dsc.clone()); + artifacts.extend(self.tarballs.iter().cloned()); + artifacts.push(self.buildinfo.clone()); + artifacts.push(self.changes.clone()); + artifacts + } +} + +/// Parameters of one [`build_source_package`] call: where to build, the +/// domain options, and the reporting ports (view and prompter). +pub struct BuildSourceOptions<'a> { + /// Source tree to package. When unset, the process's current working + /// directory is used (resolved to an absolute path). + pub source: Option, + /// Domain options (signing, orig-tarball inclusion, ...). + pub options: SourceBuildOptions, + /// Where build events (target, phases, messages, outcome) are reported. + pub view: &'a dyn BuildView, + /// Who answers the questions the flow may ask (e.g. the re-vendor + /// retry offer). + pub prompter: &'a dyn Prompter, +} + +impl Default for BuildSourceOptions<'_> { + fn default() -> Self { + static QUIET: crate::report::Quiet = crate::report::Quiet; + BuildSourceOptions { + source: None, + options: SourceBuildOptions::default(), + view: &QUIET, + prompter: &QUIET, + } + } +} + /// Build a Debian source package (to a .dsc) using the native pipeline. /// -/// When `ui` is set, subprocess output is captured into a live view (status -/// bar + rolling pane) and tee'd to a log file; on failure the view prints a -/// summary of the last captured errors. Without a UI, commands inherit the -/// terminal as before. +/// Subprocess output is captured into the view (status line + rolling +/// pane for the terminal adapter) and tee'd to a log file; on failure the +/// view prints a summary of the last captured errors. Headless callers use +/// [`crate::report::Quiet`], in which case commands still run with captured +/// output in test builds. /// /// A `dpkg-source -b` failure is classified (see /// [`classify_dpkg_source_failure`]); when the vendored rust dependencies -/// diverged from the orig-vendor component and a terminal is attached, the -/// flow offers to re-vendor, recreate the component and retry the build +/// diverged from the orig-vendor component, the flow asks the prompter +/// whether to re-vendor, recreates the component and retries the build /// exactly once. +/// +/// On success the produced artifacts are reported through the view and +/// returned. pub fn build_source_package( - cwd: Option<&Path>, - opts: SourceBuildOptions, - ui: Option>, -) -> Result<(), Box> { + opts: BuildSourceOptions<'_>, +) -> Result> { // Default to the process's current working directory, resolved to an // absolute path: the output directory is derived from `cwd.parent()` // downstream, which only yields a real directory for an absolute `cwd` // (the parent of "." is the empty path). - let cwd = match cwd { - Some(p) => p.to_path_buf(), + let cwd = match opts.source { + Some(ref p) => p.clone(), None => std::env::current_dir() .map_err(|e| format!("cannot determine the current working directory: {e}"))?, }; - let output = match run_source_build(&cwd, &opts, ui.clone()) { + let output = match run_source_build(&cwd, &opts.options, opts.view) { Ok(output) => output, Err(e) if e.downcast_ref::().is_some() => { - return retry_after_revendor(&cwd, ui, opts, e); + return retry_after_revendor(&cwd, opts.view, opts.prompter, &opts.options, e); } Err(e) => { - if let Some(u) = &ui { - u.finish_failure(); - } + opts.view.finish_failure(); return Err(e); } }; - // Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes. - let mut artifacts = Vec::with_capacity(3 + output.tarballs.len()); - artifacts.push(output.dsc.clone()); - artifacts.extend(output.tarballs.iter().cloned()); - artifacts.push(output.buildinfo.clone()); - artifacts.push(output.changes.clone()); - - // The live view lists the artifacts itself when it renders; otherwise - // (verbose mode or non-TTY stdout) print them as plain lines. - let listed = match &ui { - Some(u) if u.is_enabled() => { - u.finish_success(&artifacts, u.elapsed()); - true - } - _ => false, - }; - if !listed { - for artifact in &artifacts { - println!(" {}", crate::ui::display_path(artifact)); - } - } - - if output.signed { - println!("Package built and signed successfully!"); - } else { - println!("Package built successfully (unsigned)."); - } - Ok(()) + opts.view.finish_success(&output.artifacts()); + Ok(output) } -/// The re-vendor retry hook for a [`VendorDriftError`]: on an interactive -/// terminal, offer to re-run the vendoring step (the same helper the rust -/// template uses at scaffold time), recreate the `orig-vendor` component -/// from the fresh `vendor/` tree and retry the source build exactly once. -/// Without a terminal (or on a declined offer) the original error is -/// returned untouched. +/// The re-vendor retry hook for a [`VendorDriftError`]: offer to re-run the +/// vendoring step through the prompter (the same helper the rust template +/// uses at scaffold time), recreate the `orig-vendor` component from the +/// fresh `vendor/` tree and retry the source build exactly once. Without an +/// accepting answer (headless prompters answer with the default, `false`) +/// the original error is returned untouched. fn retry_after_revendor( cwd: &Path, - ui: Option>, - opts: SourceBuildOptions, + view: &dyn BuildView, + prompter: &dyn Prompter, + opts: &SourceBuildOptions, original: Box, -) -> Result<(), Box> { - let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); - if !interactive { - if let Some(u) = &ui { - u.finish_failure(); - } - return Err(original); - } - +) -> Result> { log::error!("{original}"); - let retry = crate::ui::prompt::confirm( + if !prompter.confirm( "Re-vendor the Cargo dependencies and retry the build?", false, - ) - .unwrap_or(false); - if !retry { - if let Some(u) = &ui { - u.finish_failure(); - } + ) { + view.finish_failure(); return Err(original); } @@ -189,9 +191,7 @@ fn retry_after_revendor( std::fs::remove_file(&config)?; } if !crate::new::templates::rust::vendor_dependencies(cwd)? { - if let Some(u) = &ui { - u.finish_failure(); - } + view.finish_failure(); return Err( "Re-vendoring did not complete: the tree is unchanged, fix the \ vendoring by hand and build again." @@ -218,7 +218,7 @@ fn retry_after_revendor( crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?; // 3. One retry, with the options of the original attempt. - run_source_build(cwd, &opts, ui).map(|_| ()) + run_source_build(cwd, opts, view) } /// Run the full native source-build pipeline in `cwd`. @@ -237,14 +237,12 @@ fn retry_after_revendor( pub fn run_source_build( cwd: &Path, opts: &SourceBuildOptions, - ui: Option>, + view: &dyn BuildView, ) -> Result> { - // Without a live UI, test runs still capture command output into the - // per-test log file instead of letting it inherit the terminal - let sink: Option> = ui - .as_ref() - .map(|u| u.sink()) - .or_else(crate::test_support::subprocess_sink); + // The view consumes the captured lines itself (live view + tee log); + // without one, test runs still capture command output into the per-test + // log file instead of letting it inherit the terminal + let sink: Option> = view.sink().or_else(crate::test_support::subprocess_sink); // ------------------------------------------------------------------ // 1. Sanity checks // ------------------------------------------------------------------ @@ -296,9 +294,7 @@ pub fn run_source_build( let ctrl = ControlInfo::parse(&control_path)?; - if let Some(u) = &ui { - u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution); - } + view.target(&entry.source, &entry.version.full(), &entry.distribution); let source_display = entry.source.clone(); @@ -357,9 +353,7 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 5. dpkg-source lifecycle: before-build + source build // ------------------------------------------------------------------ - if let Some(u) = &ui { - u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new())); - } + view.phase("Applying patches", Box::new(DpkgSourceClassifier::new())); run_command( cwd, "dpkg-source", @@ -372,9 +366,7 @@ pub fn run_source_build( // dpkg-buildpackage skips it entirely for source-only builds unless // forced with -D; unsatisfied dependencies abort with exit status 3. if opts.force_dep_check { - if let Some(u) = &ui { - u.progress_message("Checking build dependencies"); - } + view.message("Checking build dependencies"); let check_opts = crate::debian::deps::CheckOpts { host_arch: arch_vars .get("DEB_HOST_ARCH") @@ -396,12 +388,10 @@ pub fn run_source_build( } } - if let Some(u) = &ui { - u.phase_custom( - "Building source package", - Box::new(DpkgSourceClassifier::new()), - ); - } + view.phase( + "Building source package", + Box::new(DpkgSourceClassifier::new()), + ); if let Err(failure) = run_command_capturing( cwd, "dpkg-source", @@ -427,9 +417,7 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 6. .buildinfo generation (native dpkg-genbuildinfo equivalent) // ------------------------------------------------------------------ - if let Some(u) = &ui { - u.progress_message("Generating .buildinfo"); - } + view.message("Generating .buildinfo"); // What the .buildinfo itself records: like dpkg-genbuildinfo, only the // referenced .dsc — not the tarballs, and never the buildinfo itself. let mut buildinfo_checksums = FileChecksums::new(); @@ -479,9 +467,7 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 7. .changes generation (native dpkg-genchanges equivalent) // ------------------------------------------------------------------ - if let Some(u) = &ui { - u.progress_message("Generating .changes"); - } + view.message("Generating .changes"); // What the .changes distributes: the .dsc (with its recorded digests), // the tarballs listed in it (below), and the .buildinfo (last, as // dpkg-genchanges does when it consumes debian/files). @@ -538,13 +524,11 @@ pub fn run_source_build( if opts.orig_source == OrigSourceMode::Never && !has_debian_part { log::warn!("ignoring --orig never for a native Debian package"); } - if let Some(u) = &ui { - u.progress_message(if strip_origs { - "Not including original source code in upload" - } else { - "Including full source code in upload" - }); - } + view.message(if strip_origs { + "Not including original source code in upload" + } else { + "Including full source code in upload" + }); // Stripped orig tarballs (and their detached .asc signatures) are not // distributed at all: not hashed, not required on disk, like // dpkg-genchanges. @@ -634,9 +618,7 @@ pub fn run_source_build( // ------------------------------------------------------------------ // 8. dpkg-source after-build (unapplies quilt patches it applied) // ------------------------------------------------------------------ - if let Some(u) = &ui { - u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new())); - } + view.phase("Restoring patches", Box::new(DpkgSourceClassifier::new())); run_command( cwd, "dpkg-source", @@ -652,9 +634,7 @@ pub fn run_source_build( if let Some(keyid) = signing_key.filter(|_| do_sign) { crate::utils::gpg::validate_key_id(&keyid)?; - if let Some(u) = &ui { - u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new())); - } + view.phase("Signing artifacts", Box::new(GenericClassifier::new())); log::info!("Signing {}", dsc_name); crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?; @@ -1039,7 +1019,7 @@ mod tests { .expect("write control"); std::fs::write(tree.join("debian/rules"), "#!/usr/bin/make -f\n").expect("write rules"); - let err = run_source_build(&tree, &SourceBuildOptions::default(), None) + let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet) .expect_err("binary-only entries must not build a source package"); let err = err.to_string(); assert!(err.contains("binary-only"), "{err}"); @@ -1575,7 +1555,8 @@ mod differential_tests { OrigSourceMode::Never => &["-sd"], }; run_dpkg(&golden_tree, source_style); - run_source_build(&ours_tree, opts, None).expect("native source pipeline failed"); + run_source_build(&ours_tree, opts, &crate::report::Quiet) + .expect("native source pipeline failed"); let entry = crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap(); @@ -2160,7 +2141,7 @@ Provides: virtual-thing (= 2.0), plain-virtual ); // Ours: the native pipeline refuses likewise. - let err = run_source_build(&tree, &SourceBuildOptions::default(), None) + let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet) .expect_err("native source build must refuse a binary-only entry"); assert!(err.to_string().contains("binary-only"), "{err}"); } diff --git a/src/deb/local.rs b/src/deb/local.rs index 8a5249b..5c5f6b3 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -2,8 +2,8 @@ /// Directly calling 'debian/rules' in current context use crate::context::{Context, ContextCommand, LineSink}; use crate::deb::find_dsc_file; +use crate::logfmt::QuiltClassifier; use crate::ui::deb::{DebUi, Phase}; -use crate::ui::logfmt::QuiltClassifier; use log::warn; use std::collections::{BTreeMap, HashMap}; use std::error::Error; diff --git a/src/lib.rs b/src/lib.rs index b9a92ab..e022e15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,15 @@ pub mod put; /// Handle package-specific quirks and workarounds pub mod quirks; +/// Line classifiers rewriting raw subprocess output into display actions +/// and countable progress (pure logic, shared by build views) +pub mod logfmt; + +/// Reporting ports: environment-agnostic build observation ([`BuildView`]) +/// and question answering ([`Prompter`]), implemented by terminal views, +/// server bridges or the inert [`Quiet`] +pub mod report; + /// Terminal UI helpers (progress bars, live build views, prompts) pub mod ui; diff --git a/src/ui/logfmt.rs b/src/logfmt.rs similarity index 100% rename from src/ui/logfmt.rs rename to src/logfmt.rs diff --git a/src/main.rs b/src/main.rs index c7005d5..6c7469a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -490,13 +490,12 @@ fn main() { .copied() .unwrap_or(false); - // 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))) - }; + // Live build view, unless --verbose (DebUi additionally disables + // itself when stdout is not a terminal) + let quiet = pkh::report::Quiet; + let live = pkh::ui::deb::DebUi::new(&multi); + let view: &dyn pkh::report::BuildView = if verbose { &quiet } else { &live }; + let prompter = pkh::ui::prompt::TerminalPrompter; let orig_source = match sub_matches.get_one::("orig").map(String::as_str) { Some("always") => pkh::build::OrigSourceMode::Always, @@ -504,23 +503,41 @@ fn main() { _ => pkh::build::OrigSourceMode::Auto, }; - if let Err(e) = pkh::build::build_source_package( - Some(&cwd), - pkh::build::SourceBuildOptions { + match pkh::build::build_source_package(pkh::build::BuildSourceOptions { + source: Some(cwd), + options: pkh::build::SourceBuildOptions { orig_source, ..Default::default() }, - ui, - ) { - error!("{}", e); - // Unmet build dependencies/conflicts exit with status 3, - // like dpkg-buildpackage does. - if e.downcast_ref::() - .is_some() - { - std::process::exit(3); + view, + prompter: &prompter, + }) { + Ok(output) => { + // The live view lists the artifacts itself when it + // renders; otherwise (verbose mode or non-TTY stdout) + // print them as plain lines. + if !view.is_enabled() { + for artifact in output.artifacts() { + println!(" {}", pkh::ui::display_path(&artifact)); + } + } + if output.signed { + info!("Package built and signed successfully!"); + } else { + info!("Package built successfully (unsigned)."); + } + } + Err(e) => { + error!("{}", e); + // Unmet build dependencies/conflicts exit with status 3, + // like dpkg-buildpackage does. + if e.downcast_ref::() + .is_some() + { + std::process::exit(3); + } + std::process::exit(1); } - std::process::exit(1); } } Some(("put", sub_matches)) => { diff --git a/src/new/mod.rs b/src/new/mod.rs index 55b09cb..18e9cc9 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -819,7 +819,7 @@ mod tests { let output = crate::build::run_source_build( &source, &crate::build::SourceBuildOptions::default(), - None, + &crate::report::Quiet, ) .unwrap(); let dsc = std::fs::read_to_string(&output.dsc).unwrap(); @@ -885,7 +885,7 @@ mod tests { let output = crate::build::run_source_build( &dir.path().join("mytool"), &crate::build::SourceBuildOptions::default(), - None, + &crate::report::Quiet, ) .unwrap(); @@ -916,7 +916,7 @@ mod tests { let output = crate::build::run_source_build( &dir.path().join("mytool"), &crate::build::SourceBuildOptions::default(), - None, + &crate::report::Quiet, ) .unwrap(); assert!(output.dsc.exists(), "{:?} missing", output.dsc); diff --git a/src/new/questions.rs b/src/new/questions.rs index 2993fca..4cb0618 100644 --- a/src/new/questions.rs +++ b/src/new/questions.rs @@ -593,12 +593,13 @@ pub async fn offer_verification( return; } - let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi))); - if let Err(e) = crate::build::build_source_package( - Some(&tree), - crate::build::SourceBuildOptions::default(), - ui, - ) { + let ui = std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)); + if let Err(e) = crate::build::build_source_package(crate::build::BuildSourceOptions { + source: Some(tree.clone()), + options: crate::build::SourceBuildOptions::default(), + view: &*ui, + prompter: &crate::ui::prompt::TerminalPrompter, + }) { log::error!("Verification source build failed: {e}"); log::info!( "The scaffolded tree is intact. Inspect it, then retry with \ diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..31a3efa --- /dev/null +++ b/src/report.rs @@ -0,0 +1,95 @@ +//! Environment-agnostic reporting ports. +//! +//! The core flows report progress and ask questions exclusively through the +//! traits in this module, so the same pipeline can drive a terminal live +//! view, a headless library consumer, or a remote frontend (e.g. a builder +//! server forwarding build events to a web UI over server-sent events): every +//! event carries plain data — strings, numbers, paths — with no terminal, +//! styling or locale assumptions. Adapters decide how events reach the user: +//! the terminal live view ([`crate::ui::deb::DebUi`]) renders them in place, +//! while another embedding maps each method onto its own wire format. +//! +//! Every [`BuildView`] method defaults to doing nothing, so implementations +//! only override the events they care about; [`Quiet`] provides the inert +//! implementations used by headless runs and tests. [`Prompter`] is +//! deliberately blocking: an implementation may round-trip each question to +//! a remote user, as long as it eventually answers (or takes the default). + +use std::path::PathBuf; +use std::sync::Arc; + +use crate::context::LineSink; +use crate::logfmt::Classifier; + +/// Observer of a running build: target identification, phases, status +/// messages, progress and the final outcome. +/// +/// Implement this to observe [`crate::build`] flows from any frontend. All +/// events arrive in order from the build thread; long-lived views are +/// expected to be `Send + Sync` because builds may run inside async tasks. +pub trait BuildView: Send + Sync { + /// The build target was identified: `package` at `version`, built for + /// `target` (a distribution series, optionally with an architecture). + fn target(&self, _package: &str, _version: &str, _target: &str) {} + + /// A named phase started (e.g. "Applying patches"). `classifier` + /// rewrites the phase's raw subprocess lines (see [`crate::logfmt`]) + /// into display actions and countable progress; views that do not + /// rewrite lines locally can ignore it and forward raw lines from + /// [`BuildView::sink`] instead. + fn phase(&self, _name: &str, _classifier: Box) {} + + /// A status message about in-process work that produces no subprocess + /// output (e.g. "Generating .changes"). + fn message(&self, _text: &str) {} + + /// Determinate progress within the current phase (e.g. artifact + /// retrieval); `pos` runs from 0 to `total`. + fn progress(&self, _label: &str, _pos: usize, _total: usize) {} + + /// Sink receiving every raw subprocess line while a build command runs, + /// when this view consumes the lines itself (live rewriting, tee to a + /// log file, forwarding over the network). `None` lets the caller fall + /// back to its default handling (e.g. test capture). + fn sink(&self) -> Option> { + None + } + + /// The build succeeded; `artifacts` lists the produced files in + /// distribution order (dsc → tarballs → buildinfo → changes). + fn finish_success(&self, _artifacts: &[PathBuf]) {} + + /// The build failed; the view should release the display and may report + /// diagnostics it collected through [`BuildView::sink`]. + fn finish_failure(&self) {} + + /// Whether this view presents build results to the user by itself; + /// callers use this to fall back to plain-line rendering when it does + /// not (headless views, verbose mode). + fn is_enabled(&self) -> bool { + false + } +} + +/// Answerer of the questions a core flow may ask mid-run (confirmations +/// now, selections and free-text input as more flows migrate). +/// +/// Questions are blocking on purpose: a terminal implementation waits for +/// key presses, and a builder-server implementation may forward the question +/// to a web client and await the answer on a channel. Implementations that +/// cannot ask anyone answer with the question's default. +pub trait Prompter: Send + Sync { + /// Ask a yes/no question. `default` is the answer to take when no user + /// can be reached or the question is cancelled. + fn confirm(&self, _question: &str, default: bool) -> bool { + default + } +} + +/// Inert view and prompter: drops every event and answers every question +/// with its default. The stand-in for headless library runs, verbose mode +/// and tests. +pub struct Quiet; + +impl BuildView for Quiet {} +impl Prompter for Quiet {} diff --git a/src/ui.rs b/src/ui.rs index ba839a5..188502b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,8 +3,6 @@ /// 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; /// Interactive raw-mode prompts: free-text input, option selection and /// yes/no confirmation pub mod prompt; diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 5810c44..0476e2f 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -3,7 +3,7 @@ //! ("a terminal in the terminal"). //! //! Subprocess output is captured through a [`LineSink`] implementation, -//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place +//! rewritten by classifiers ([`crate::logfmt`]) and rendered in place //! with indicatif, so pkh's own log lines keep printing above the widget via //! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log //! file under the pkh cache directory. @@ -21,7 +21,7 @@ use directories::ProjectDirs; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use crate::context::{LineSink, Stream}; -use crate::ui::logfmt::{ +use crate::logfmt::{ Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier, MmdebstrapClassifier, QuiltClassifier, }; @@ -130,8 +130,10 @@ struct Shared { /// Live build view for `pkh deb` / `pkh build` /// /// Create one per build (disabled automatically when stdout is not a TTY or -/// when the user requests verbose output), pass it down as -/// `Option>`, and feed subprocess output through [`DebUi::sink`]. +/// when the user requests verbose output), then either pass it down through +/// the [`crate::report::BuildView`] port (source builds) or as +/// `Option>` (the `pkh deb` flow, until it migrates), and feed +/// subprocess output through [`DebUi::sink`]. pub struct DebUi { shared: Arc, } @@ -213,17 +215,6 @@ impl DebUi { self.open_log("deb", package, version, &format!("for {series}/{arch}")); } - /// Identify the source package being built; names the log file - /// (`build---.log`) and the status bar - pub fn set_build_target(&self, package: &str, version: &str, distribution: &str) { - if self.shared.enabled { - self.shared.top.set_prefix(format!( - "Building source package {package} ({version}) for {distribution}" - )); - } - self.open_log("build", package, version, &format!("for {distribution}")); - } - /// Rename the placeholder log file to include the build identity /// (best-effort), then open it so subsequent captured lines are tee'd fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) { @@ -272,9 +263,10 @@ impl DebUi { self.phase_custom(phase.label(), classifier); } - /// Switch to an arbitrary status label with a custom classifier; used by - /// flows whose phases are not part of [`Phase`] (e.g. source builds) - pub fn phase_custom(&self, label: &str, classifier: Box) { + /// Switch to an arbitrary status label with a custom classifier; the + /// shared implementation behind [`DebUi::phase`], [`DebUi::phase_with`] + /// and the [`BuildView`] port + fn phase_custom(&self, label: &str, classifier: Box) { { let mut st = self.shared.state.lock().unwrap(); st.classifier = classifier; @@ -316,12 +308,6 @@ impl DebUi { self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst) } - /// Whether the widget renders at all (false on non-TTY stdout); callers - /// use this to fall back to plain-line summaries - pub fn is_enabled(&self) -> bool { - self.shared.enabled - } - /// Obtain a sink feeding this view; pass it to `ContextCommand::capture` pub fn sink(self: &Arc) -> Arc { Arc::new(Sink { @@ -351,6 +337,18 @@ impl DebUi { /// Clear the widget and print a success summary with the artifacts, /// rendered relative to the working directory when possible pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) { + self.success_summary(artifacts, elapsed); + } + + /// Clear the widget and print a failure summary (recent captured errors + /// and the path to the full log) + pub fn finish_failure(&self) { + self.failure_summary(); + } + + /// Shared body of the success outcome ([`DebUi::finish_success`] and the + /// [`BuildView`] port) + fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) { self.suspend(); if self.shared.enabled && !artifacts.is_empty() { println!("Built in {}s:", elapsed.as_secs()); @@ -360,9 +358,9 @@ impl DebUi { } } - /// Clear the widget and print a failure summary (recent captured errors - /// and the path to the full log) - pub fn finish_failure(&self) { + /// Shared body of the failure outcome ([`DebUi::finish_failure`] and the + /// [`BuildView`] port) + fn failure_summary(&self) { self.suspend(); let st = self.shared.state.lock().unwrap(); @@ -397,6 +395,52 @@ impl DebUi { } } +/// [`crate::report::BuildView`] port: forwards build events to the live +/// widget, so core flows can drive the view without knowing it is a +/// terminal widget. Where the inherent methods keep their richer signatures +/// (e.g. [`DebUi::finish_success`] takes the elapsed duration explicitly for +/// the `pkh deb` flow), the port bridges them. +impl crate::report::BuildView for DebUi { + fn target(&self, package: &str, version: &str, target: &str) { + if self.shared.enabled { + self.shared.top.set_prefix(format!( + "Building source package {package} ({version}) for {target}" + )); + } + self.open_log("build", package, version, &format!("for {target}")); + } + + fn phase(&self, name: &str, classifier: Box) { + self.phase_custom(name, classifier); + } + + fn message(&self, text: &str) { + self.progress_message(text); + } + + fn progress(&self, label: &str, pos: usize, total: usize) { + self.count_progress(label, pos, total); + } + + fn sink(&self) -> Option> { + Some(Arc::new(Sink { + shared: self.shared.clone(), + })) + } + + fn finish_success(&self, artifacts: &[PathBuf]) { + self.success_summary(artifacts, self.elapsed()); + } + + fn finish_failure(&self) { + self.failure_summary(); + } + + fn is_enabled(&self) -> bool { + self.shared.enabled + } +} + impl Drop for DebUi { fn drop(&mut self) { // Safety net: clear the widget on early returns/unwinds diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 89aef1f..8bdefd0 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -127,6 +127,17 @@ pub fn confirm(label: &str, default: bool) -> Result bool { + confirm(question, default).unwrap_or(default) + } +} + /// Run `prompt` with the terminal in raw mode, always restoring it /// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be /// enabled.