Compare commits

..
11 Commits
Author SHA1 Message Date
vhaudiquet 47bb7c608e deb: resolve cross pkg-config against the target multiarch
CI / build (push) Successful in 2m54s
CI / test (push) Skipped
CI / snap (push) Successful in 4m32s
In-tree tools locate their libraries with the *host* pkg-config during
cross builds (the kernel's tools/build feature checks derive their
cflags/ldflags from 'pkg-config --cflags/--libs'), whose search path
only covers the build architecture's pkgconfig dirs. Cross builds of
linux-riscv died in rtla's Makefile.config: libtraceevent/libtracefs
were reported missing although the riscv64 -dev packages were
installed, because pkg-config never saw their .pc files.

The host-arch -dev packages that used to make those checks pass came
from the unscoped arch-indep build-dep pass, whose native
re-resolution b34e86d correctly scoped to the host arch — removing the
accidental co-install along with the bug it papered over. Export
PKG_CONFIG_LIBDIR for the target multiarch instead, so the checks
resolve target-arch libraries directly: no native build-dep bloat and
no wrong-arch linking.
2026-09-19 13:07:46 +02:00
vhaudiquet 6caedce61a report: reproduce the pre-refactor CLI output through the ports
Instead of carrying raw UI in core, the ports now represent everything
the CLI used to do inline:

- Prompter::present shows context outside of a question (the wizard
  summary screen, the vendoring notice spacing); TerminalPrompter
  prints it on stdout exactly like the println!s it replaces, server
  embeds forward it as a display event.
- generate_entry returns the generated entry (package, versions,
  series, path) instead of printing; the CLI renders the same lines.
- BuildTarget carries a flow-composed display line and a tee_log flag:
  the terminal adapter renders it verbatim ("Building source package
  ...", "Building ... for series/arch", "Uploading ... to ...") and
  uploads open no build log.
- The unmet build-dependency diagnostics are rendered by the CLI from
  the typed error, in the original order (details, then summary).
- --verbose constructs no live view at all (an idle widget used to
  linger), and the re-vendor offer only logs when it is actually
  asked, so headless runs print the error exactly once.
2026-09-19 00:52:31 +02:00
vhaudiquet bd8f814a53 Revert "package_info: drop the ANSI color from the not-found warning"
This reverts commit 4fae02bc85.
2026-09-19 00:39:29 +02:00
vhaudiquet 4fae02bc85 package_info: drop the ANSI color from the not-found warning
The location embedded in the log record was styled with crossterm: a
remote consumer of pkh's log records would receive ANSI codes inside
the message text. Plain text is the logger's business to style.
2026-09-18 20:55:56 +02:00
vhaudiquet c2dae4f3f9 lift main.rs business logic into the library
The chlog target-series resolution becomes changelog::series_candidates
(UNRELEASED pinning, development-series default and fallbacks modeled
by SeriesCandidates), PPA references get package_info::split_ppa
(shared by pull and deb, now also rejecting empty parts), and SSH
endpoints get context::ContextConfig::from_endpoint — so a library
consumer can resolve series, validate PPAs and build context
configurations without reimplementing the CLI's rules. All three carry
unit tests; main.rs keeps only parsing of flags and error handling.
2026-09-18 20:51:36 +02:00
vhaudiquet 0421a91e01 changelog: report through the log instead of printing to stdout
generate_entry's status messages become log lines, and the unmet
build-dependency diagnostics travel inside the UnmetBuildDependencies
error (its Display carries the full report) instead of being printed
to stderr by the library: the caller renders both like any other
outcome.
2026-09-18 20:44:05 +02:00
vhaudiquet 54cb04ba27 put: report through the view and ask the host-key question through the Prompter
put() loses its MultiProgress parameter: the summary, pre-flight and
connection spinners become view messages, the per-file SFTP transfer
reports determinate progress through view.progress (upload_file takes
a byte-count callback instead of an indicatif bar), and the display is
released through view.suspend on every exit path. The hardcoded
trust-on-first-use prompt in the SSH host-key verification becomes the
Prompter::accept_host_key port (fail-closed by default; the terminal
prompter prints the authenticity banner and confirms), so a remote
frontend can surface its own host-key dialog.
2026-09-18 20:42:17 +02:00
vhaudiquet bb76e41908 new: drive the wizard and verification offers through the Prompter port
The interactive half of pkh new no longer touches the terminal prompt
module directly: run() takes a Prompter, picks the wizard or the plain
resolve path through interactive(), and every select/text/confirm
question (including the verification offers) goes through the port.
Cancellations propagate as Err, preserving Ctrl+C-aborts; the summary
and vendoring-notice prints become log lines. A builder-server embed
can now drive the whole scaffold wizard over its own wire format by
implementing Prompter.
2026-09-18 20:38:05 +02:00
vhaudiquet d64e472845 report: grow the Prompter port and move display_path out of the ui module
Prompter gains interactive(), select() and text() (with the Validator
type), and confirm() now propagates cancellation as Err so flows abort
instead of silently taking a default when the user hits Ctrl+C. The
terminal prompter implements the full port; the port also re-exports
the path display helper, which is pure presentation formatting used by
events and messages rather than terminal code.
2026-09-18 20:34:33 +02:00
vhaudiquet 052c02cdc3 deb: drive build_binary_package through the BuildView port
The binary build joins the source build on the reporting ports:
build_binary_package takes a DebBuildOptions struct (replacing eleven
positional arguments), reports target, phases, progress and the
outcome through the environment-agnostic BuildView, and the Phase enum
with its default classifiers moves from the terminal widget into the
deb module (announced through the enter_phase helper). DebUi loses its
inherent event methods and only implements the port; tee logging and
the SIGINT behavior are unchanged.

No behavior change for the CLI; headless consumers pass report::Quiet.
2026-09-18 20:22:45 +02:00
vhaudiquet 27b1083b15 report: add BuildView/Prompter ports and drive pkh build through them
Core flows no longer reach into the terminal UI: build_source_package
takes a BuildSourceOptions struct (source tree, domain options, view,
prompter) and reports phases, messages and outcomes through the
environment-agnostic ports in the new report module. The classifiers
move from ui/logfmt to the core logfmt module, DebUi becomes a
BuildView adapter, the re-vendor retry asks the prompter instead of
checking for a TTY, and artifact/success printing moves to the CLI.

Headless consumers pass report::Quiet; an embedding (e.g. a builder
server forwarding events to a web frontend) implements BuildView and
maps the plain-data events onto its own wire format.
2026-09-18 20:02:56 +02:00
21 changed files with 1344 additions and 841 deletions
+116 -111
View File
@@ -14,7 +14,6 @@ pub mod env;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::error::Error; use std::error::Error;
use std::io::IsTerminal;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
@@ -24,8 +23,8 @@ use crate::context::{LineSink, Stream};
use crate::debian::{ use crate::debian::{
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs, ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
}; };
use crate::ui::deb::DebUi; use crate::logfmt::{DpkgSourceClassifier, GenericClassifier};
use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier}; use crate::report::{BuildTarget, BuildView, Prompter};
/// Whether the upload distributes the upstream orig tarballs (`--orig`), /// Whether the upload distributes the upstream orig tarballs (`--orig`),
/// mirroring the `dpkg-genchanges` source styles. /// mirroring the `dpkg-genchanges` source styles.
@@ -74,105 +73,118 @@ pub struct SourceBuildOutput {
pub signed: bool, pub signed: bool,
} }
impl SourceBuildOutput {
/// All produced artifacts in distribution order: dsc → tarballs →
/// buildinfo → changes.
pub fn artifacts(&self) -> Vec<PathBuf> {
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<PathBuf>,
/// 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. /// 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 /// Subprocess output is captured into the view (status line + rolling
/// bar + rolling pane) and tee'd to a log file; on failure the view prints a /// pane for the terminal adapter) and tee'd to a log file; on failure the
/// summary of the last captured errors. Without a UI, commands inherit the /// view prints a summary of the last captured errors. Headless callers use
/// terminal as before. /// [`crate::report::Quiet`], in which case commands still run with captured
/// output in test builds.
/// ///
/// A `dpkg-source -b` failure is classified (see /// A `dpkg-source -b` failure is classified (see
/// [`classify_dpkg_source_failure`]); when the vendored rust dependencies /// [`classify_dpkg_source_failure`]); when the vendored rust dependencies
/// diverged from the orig-vendor component and a terminal is attached, the /// diverged from the orig-vendor component, the flow asks the prompter
/// flow offers to re-vendor, recreate the component and retry the build /// whether to re-vendor, recreates the component and retries the build
/// exactly once. /// exactly once.
///
/// On success the produced artifacts are reported through the view and
/// returned.
pub fn build_source_package( pub fn build_source_package(
cwd: Option<&Path>, opts: BuildSourceOptions<'_>,
opts: SourceBuildOptions, ) -> Result<SourceBuildOutput, Box<dyn Error>> {
ui: Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
// Default to the process's current working directory, resolved to an // Default to the process's current working directory, resolved to an
// absolute path: the output directory is derived from `cwd.parent()` // absolute path: the output directory is derived from `cwd.parent()`
// downstream, which only yields a real directory for an absolute `cwd` // downstream, which only yields a real directory for an absolute `cwd`
// (the parent of "." is the empty path). // (the parent of "." is the empty path).
let cwd = match cwd { let cwd = match opts.source {
Some(p) => p.to_path_buf(), Some(ref p) => p.clone(),
None => std::env::current_dir() None => std::env::current_dir()
.map_err(|e| format!("cannot determine the current working directory: {e}"))?, .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, Ok(output) => output,
Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => { Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => {
return retry_after_revendor(&cwd, ui, opts, e); return retry_after_revendor(&cwd, opts.view, opts.prompter, &opts.options, e);
} }
Err(e) => { Err(e) => {
if let Some(u) = &ui { opts.view.finish_failure();
u.finish_failure();
}
return Err(e); return Err(e);
} }
}; };
// Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes. opts.view.finish_success(&output.artifacts());
let mut artifacts = Vec::with_capacity(3 + output.tarballs.len()); Ok(output)
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 { /// The re-vendor retry hook for a [`VendorDriftError`]: offer to re-run the
println!("Package built and signed successfully!"); /// vendoring step through the prompter (the same helper the rust template
} else { /// uses at scaffold time), recreate the `orig-vendor` component from the
println!("Package built successfully (unsigned)."); /// fresh `vendor/` tree and retry the source build exactly once. Without an
} /// accepting answer (headless prompters answer with the default, `false`)
Ok(()) /// the original error is returned untouched.
}
/// 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.
fn retry_after_revendor( fn retry_after_revendor(
cwd: &Path, cwd: &Path,
ui: Option<Arc<DebUi>>, view: &dyn BuildView,
opts: SourceBuildOptions, prompter: &dyn Prompter,
opts: &SourceBuildOptions,
original: Box<dyn Error>, original: Box<dyn Error>,
) -> Result<(), Box<dyn Error>> { ) -> Result<SourceBuildOutput, Box<dyn Error>> {
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); // The offer is only made when someone can answer it: headless prompters
if !interactive { // answer with the default (false) without the error being logged here —
if let Some(u) = &ui { // the caller logs the returned error itself, exactly once.
u.finish_failure(); if !prompter.interactive() {
} view.finish_failure();
return Err(original); return Err(original);
} }
log::error!("{original}"); log::error!("{original}");
let retry = crate::ui::prompt::confirm( if !prompter
.confirm(
"Re-vendor the Cargo dependencies and retry the build?", "Re-vendor the Cargo dependencies and retry the build?",
false, false,
) )
.unwrap_or(false); .unwrap_or(false)
if !retry { {
if let Some(u) = &ui { view.finish_failure();
u.finish_failure();
}
return Err(original); return Err(original);
} }
@@ -189,9 +201,7 @@ fn retry_after_revendor(
std::fs::remove_file(&config)?; std::fs::remove_file(&config)?;
} }
if !crate::new::templates::rust::vendor_dependencies(cwd)? { if !crate::new::templates::rust::vendor_dependencies(cwd)? {
if let Some(u) = &ui { view.finish_failure();
u.finish_failure();
}
return Err( return Err(
"Re-vendoring did not complete: the tree is unchanged, fix the \ "Re-vendoring did not complete: the tree is unchanged, fix the \
vendoring by hand and build again." vendoring by hand and build again."
@@ -218,7 +228,7 @@ fn retry_after_revendor(
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?; crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
// 3. One retry, with the options of the original attempt. // 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`. /// Run the full native source-build pipeline in `cwd`.
@@ -237,14 +247,12 @@ fn retry_after_revendor(
pub fn run_source_build( pub fn run_source_build(
cwd: &Path, cwd: &Path,
opts: &SourceBuildOptions, opts: &SourceBuildOptions,
ui: Option<Arc<DebUi>>, view: &dyn BuildView,
) -> Result<SourceBuildOutput, Box<dyn Error>> { ) -> Result<SourceBuildOutput, Box<dyn Error>> {
// Without a live UI, test runs still capture command output into the // The view consumes the captured lines itself (live view + tee log);
// per-test log file instead of letting it inherit the terminal // without one, test runs still capture command output into the per-test
let sink: Option<Arc<dyn LineSink>> = ui // log file instead of letting it inherit the terminal
.as_ref() let sink: Option<Arc<dyn LineSink>> = view.sink().or_else(crate::test_support::subprocess_sink);
.map(|u| u.sink())
.or_else(crate::test_support::subprocess_sink);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 1. Sanity checks // 1. Sanity checks
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -296,9 +304,19 @@ pub fn run_source_build(
let ctrl = ControlInfo::parse(&control_path)?; let ctrl = ControlInfo::parse(&control_path)?;
if let Some(u) = &ui { view.target(BuildTarget {
u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution); package: &entry.source,
} version: &entry.version.full(),
target: &entry.distribution,
display: format!(
"Building source package {} ({}) for {}",
entry.source,
entry.version.full(),
entry.distribution
),
source_only: true,
tee_log: true,
});
let source_display = entry.source.clone(); let source_display = entry.source.clone();
@@ -357,9 +375,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 5. dpkg-source lifecycle: before-build + source build // 5. dpkg-source lifecycle: before-build + source build
// ------------------------------------------------------------------ // ------------------------------------------------------------------
if let Some(u) = &ui { view.phase("Applying patches", Box::new(DpkgSourceClassifier::new()));
u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new()));
}
run_command( run_command(
cwd, cwd,
"dpkg-source", "dpkg-source",
@@ -372,9 +388,7 @@ pub fn run_source_build(
// dpkg-buildpackage skips it entirely for source-only builds unless // dpkg-buildpackage skips it entirely for source-only builds unless
// forced with -D; unsatisfied dependencies abort with exit status 3. // forced with -D; unsatisfied dependencies abort with exit status 3.
if opts.force_dep_check { if opts.force_dep_check {
if let Some(u) = &ui { view.message("Checking build dependencies");
u.progress_message("Checking build dependencies");
}
let check_opts = crate::debian::deps::CheckOpts { let check_opts = crate::debian::deps::CheckOpts {
host_arch: arch_vars host_arch: arch_vars
.get("DEB_HOST_ARCH") .get("DEB_HOST_ARCH")
@@ -389,19 +403,19 @@ pub fn run_source_build(
}; };
let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?; let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?;
if !report.is_ok() { if !report.is_ok() {
eprintln!("{}", report.message()); // The typed error carries the diagnostics (UnmetReport is
// public); the caller renders them and maps the type to exit
// status 3, like dpkg-buildpackage does.
return Err(Box::new(crate::debian::deps::UnmetBuildDependencies( return Err(Box::new(crate::debian::deps::UnmetBuildDependencies(
report, report,
))); )));
} }
} }
if let Some(u) = &ui { view.phase(
u.phase_custom(
"Building source package", "Building source package",
Box::new(DpkgSourceClassifier::new()), Box::new(DpkgSourceClassifier::new()),
); );
}
if let Err(failure) = run_command_capturing( if let Err(failure) = run_command_capturing(
cwd, cwd,
"dpkg-source", "dpkg-source",
@@ -427,9 +441,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent) // 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
if let Some(u) = &ui { view.message("Generating .buildinfo");
u.progress_message("Generating .buildinfo");
}
// What the .buildinfo itself records: like dpkg-genbuildinfo, only the // What the .buildinfo itself records: like dpkg-genbuildinfo, only the
// referenced .dsc — not the tarballs, and never the buildinfo itself. // referenced .dsc — not the tarballs, and never the buildinfo itself.
let mut buildinfo_checksums = FileChecksums::new(); let mut buildinfo_checksums = FileChecksums::new();
@@ -479,9 +491,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 7. .changes generation (native dpkg-genchanges equivalent) // 7. .changes generation (native dpkg-genchanges equivalent)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
if let Some(u) = &ui { view.message("Generating .changes");
u.progress_message("Generating .changes");
}
// What the .changes distributes: the .dsc (with its recorded digests), // What the .changes distributes: the .dsc (with its recorded digests),
// the tarballs listed in it (below), and the .buildinfo (last, as // the tarballs listed in it (below), and the .buildinfo (last, as
// dpkg-genchanges does when it consumes debian/files). // dpkg-genchanges does when it consumes debian/files).
@@ -538,13 +548,11 @@ pub fn run_source_build(
if opts.orig_source == OrigSourceMode::Never && !has_debian_part { if opts.orig_source == OrigSourceMode::Never && !has_debian_part {
log::warn!("ignoring --orig never for a native Debian package"); log::warn!("ignoring --orig never for a native Debian package");
} }
if let Some(u) = &ui { view.message(if strip_origs {
u.progress_message(if strip_origs {
"Not including original source code in upload" "Not including original source code in upload"
} else { } else {
"Including full source code in upload" "Including full source code in upload"
}); });
}
// Stripped orig tarballs (and their detached .asc signatures) are not // Stripped orig tarballs (and their detached .asc signatures) are not
// distributed at all: not hashed, not required on disk, like // distributed at all: not hashed, not required on disk, like
// dpkg-genchanges. // dpkg-genchanges.
@@ -634,9 +642,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 8. dpkg-source after-build (unapplies quilt patches it applied) // 8. dpkg-source after-build (unapplies quilt patches it applied)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
if let Some(u) = &ui { view.phase("Restoring patches", Box::new(DpkgSourceClassifier::new()));
u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new()));
}
run_command( run_command(
cwd, cwd,
"dpkg-source", "dpkg-source",
@@ -652,9 +658,7 @@ pub fn run_source_build(
if let Some(keyid) = signing_key.filter(|_| do_sign) { if let Some(keyid) = signing_key.filter(|_| do_sign) {
crate::utils::gpg::validate_key_id(&keyid)?; crate::utils::gpg::validate_key_id(&keyid)?;
if let Some(u) = &ui { view.phase("Signing artifacts", Box::new(GenericClassifier::new()));
u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new()));
}
log::info!("Signing {}", dsc_name); log::info!("Signing {}", dsc_name);
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?; crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
@@ -1039,7 +1043,7 @@ mod tests {
.expect("write control"); .expect("write control");
std::fs::write(tree.join("debian/rules"), "#!/usr/bin/make -f\n").expect("write rules"); 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"); .expect_err("binary-only entries must not build a source package");
let err = err.to_string(); let err = err.to_string();
assert!(err.contains("binary-only"), "{err}"); assert!(err.contains("binary-only"), "{err}");
@@ -1575,7 +1579,8 @@ mod differential_tests {
OrigSourceMode::Never => &["-sd"], OrigSourceMode::Never => &["-sd"],
}; };
run_dpkg(&golden_tree, source_style); 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 = let entry =
crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap(); crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap();
@@ -2160,7 +2165,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
); );
// Ours: the native pipeline refuses likewise. // 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"); .expect_err("native source build must refuse a binary-only entry");
assert!(err.to_string().contains("binary-only"), "{err}"); assert!(err.to_string().contains("binary-only"), "{err}");
} }
+174 -7
View File
@@ -5,13 +5,30 @@ use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
/// Outcome of a successful [`generate_entry`] call: everything the CLI
/// renders for the user, and everything a library consumer needs to chain
/// further steps.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedEntry {
/// Source package name from the previous changelog entry.
pub package: String,
/// Version the changelog carried before the new entry was prepended.
pub previous_version: String,
/// Version of the freshly added entry.
pub new_version: String,
/// Distribution series the new entry targets.
pub series: String,
/// The changelog file that was updated.
pub path: std::path::PathBuf,
}
/// Automatically generate a changelog entry from a commit history and previous changelog /// Automatically generate a changelog entry from a commit history and previous changelog
pub fn generate_entry( pub fn generate_entry(
changelog_file: &str, changelog_file: &str,
cwd: Option<&Path>, cwd: Option<&Path>,
user_version: Option<&str>, user_version: Option<&str>,
target_series: Option<&str>, target_series: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<GeneratedEntry, Box<dyn std::error::Error>> {
let changelog_path = if let Some(path) = cwd { let changelog_path = if let Some(path) = cwd {
path.join(changelog_file) path.join(changelog_file)
} else { } else {
@@ -19,8 +36,7 @@ pub fn generate_entry(
}; };
// Parse existing changelog to get current (old) version // Parse existing changelog to get current (old) version
let (package, old_version, series) = parse_changelog_header(&changelog_path)?; let (package, old_version, current_series) = parse_changelog_header(&changelog_path)?;
println!("Found package: {}, version: {}", package, old_version);
// Open git repo, and find commits since last version tag // Open git repo, and find commits since last version tag
let repo_path = if let Some(path) = cwd { let repo_path = if let Some(path) = cwd {
@@ -44,7 +60,7 @@ pub fn generate_entry(
}; };
let (maintainer_name, maintainer_email) = get_maintainer_info()?; let (maintainer_name, maintainer_email) = get_maintainer_info()?;
let series = target_series.unwrap_or(&series).to_string(); let series = target_series.unwrap_or(&current_series).to_string();
let new_entry = format_entry( let new_entry = format_entry(
&package, &package,
&new_version, &new_version,
@@ -56,9 +72,13 @@ pub fn generate_entry(
prepend_to_file(&changelog_path, &new_entry)?; prepend_to_file(&changelog_path, &new_entry)?;
println!("Added new changelog entry to {}", changelog_path.display()); Ok(GeneratedEntry {
package,
Ok(()) previous_version: old_version,
new_version,
series,
path: changelog_path,
})
} }
/// Compute the next (most probable) version number of a package, from old version and /// Compute the next (most probable) version number of a package, from old version and
@@ -139,6 +159,77 @@ pub fn parse_changelog_header(
Ok((entry.source, entry.version.full(), entry.distribution)) Ok((entry.source, entry.version.full(), entry.distribution))
} }
/// What a new changelog entry may target as series, derived from the
/// current changelog ([`series_candidates`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeriesCandidates {
/// Offer `options` with `default` preselected; when the selection
/// cannot be made (cancelled, no interactive user) `fallback` — the
/// changelog's current series — is used instead.
Choose {
/// Series names to offer.
options: Vec<String>,
/// Preselected series.
default: String,
/// Series to fall back to when nothing can be selected.
fallback: String,
},
/// Nothing to choose: the series list was unavailable, keep the
/// current series.
Keep(String),
}
/// Derive the candidate series for a new changelog entry from the changelog
/// at `changelog_path`.
///
/// An UNRELEASED entry offers itself as a pinned first option (selecting it
/// keeps the changelog unreleased) on top of the current vendor's series
/// list, defaulting to the development series; any other series resolves
/// through the series list of its own distribution. `None` when the
/// changelog cannot be parsed (no default to derive at all).
pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates> {
let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?;
if crate::distro_info::is_unreleased(&current) {
// Vendors keep original casing ("Ubuntu"), while the series data
// keys are lowercase
let dist = crate::build::env::current_vendor().to_lowercase();
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(series_list) => {
options.extend(series_list);
// Default to the development series (the first real entry),
// not to the pinned UNRELEASED entry itself
let default = if options.len() > 1 {
options[1].clone()
} else {
current.clone()
};
Some(SeriesCandidates::Choose {
options,
default,
fallback: current,
})
}
Err(_) => Some(SeriesCandidates::Keep(current)),
}
} else {
match crate::distro_info::get_dist_from_series(&current).await {
Ok(dist) => match crate::distro_info::get_ordered_series_name(&dist).await {
// Even an empty list goes through the selector: its
// fallback prints and takes the default, like it always has
Ok(options) => Some(SeriesCandidates::Choose {
options,
default: current.clone(),
fallback: current,
}),
Err(_) => Some(SeriesCandidates::Keep(current)),
},
Err(_) => Some(SeriesCandidates::Keep(current)),
}
}
}
/// Parse a changelog file footer to extract maintainer information /// Parse a changelog file footer to extract maintainer information
/// Returns (name, email) tuple from the last modification entry /// Returns (name, email) tuple from the last modification entry
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> { pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
@@ -306,6 +397,82 @@ mod tests {
use std::process::Command; use std::process::Command;
use tempfile::TempDir; use tempfile::TempDir;
/// An UNRELEASED changelog offers UNRELEASED pinned first, the
/// development series as the default, and itself as the fallback.
#[tokio::test]
async fn series_candidates_unreleased_pins_entry_and_defaults_to_dev() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(
&path,
"hello (1.0-1) UNRELEASED; urgency=medium\n\n * Something.\n\n \
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
)
.unwrap();
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
default,
fallback,
}) => {
assert_eq!(options[0], "UNRELEASED");
assert!(options.len() > 1, "the vendor series list is offered");
assert_eq!(default, options[1]);
assert_eq!(fallback, "UNRELEASED");
}
other => panic!("expected Choose, got {other:?}"),
}
}
/// A released changelog offers its distribution's series with the
/// current one preselected. Uses a series of the host vendor so the
/// test only relies on the local distro-info data.
#[tokio::test]
async fn series_candidates_released_defaults_to_current_series() {
let dist = crate::build::env::current_vendor().to_lowercase();
let vendor_series = crate::distro_info::get_ordered_series_name(&dist)
.await
.expect("the host vendor's series data resolves");
// Any released series of the vendor works; the changelog names it.
let current = vendor_series.last().expect("non-empty series list");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(
&path,
format!(
"hello (1.0-1) {current}; urgency=medium\n\n * Something.\n\n \
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n"
),
)
.unwrap();
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
default,
fallback,
}) => {
assert_eq!(options, vendor_series);
assert_eq!(default, *current);
assert_eq!(fallback, *current);
}
other => panic!("expected Choose, got {other:?}"),
}
}
/// Without a parsable changelog there is no candidate at all.
#[tokio::test]
async fn series_candidates_none_without_changelog() {
let dir = tempfile::tempdir().unwrap();
assert!(
series_candidates(&dir.path().join("changelog"))
.await
.is_none()
);
}
fn setup_repo(dir: &Path) { fn setup_repo(dir: &Path) {
Command::new("git") Command::new("git")
.arg("init") .arg("init")
+76
View File
@@ -117,6 +117,28 @@ pub enum ContextConfig {
}, },
} }
impl ContextConfig {
/// Build an SSH context configuration from an endpoint of the form
/// `[ssh://][user@]host[:port]`.
pub fn from_endpoint(endpoint: &str) -> Result<Self, String> {
let re = regex::Regex::new(
r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$",
)
.expect("valid endpoint regex");
let cap = re.captures(endpoint).ok_or_else(|| {
format!("Invalid endpoint format: '{endpoint}'. Expected [ssh://][user@]host[:port]")
})?;
let host = cap.name("host").unwrap().as_str().to_string();
let user = cap.name("user").map(|m| m.as_str().to_string());
let port = cap
.name("port")
.map(|m| m.as_str().parse::<u16>())
.transpose()
.map_err(|_| "Invalid port number".to_string())?;
Ok(ContextConfig::Ssh { host, user, port })
}
}
/// A context, allowing to run commands, read and write files, etc /// A context, allowing to run commands, read and write files, etc
pub struct Context { pub struct Context {
/// Configuration for the context /// Configuration for the context
@@ -455,3 +477,57 @@ fn contextualize_spawn_error(program: &str, e: io::Error) -> io::Error {
io::Error::new(e.kind(), format!("Could not run '{program}': {e}")) io::Error::new(e.kind(), format!("Could not run '{program}': {e}"))
} }
} }
#[cfg(test)]
mod endpoint_tests {
use super::*;
/// Every accepted endpoint spelling maps to the expected config.
#[test]
fn from_endpoint_parses_all_spellings() {
assert_eq!(
ContextConfig::from_endpoint("myhost"),
Ok(ContextConfig::Ssh {
host: "myhost".into(),
user: None,
port: None,
})
);
assert_eq!(
ContextConfig::from_endpoint("admin@myhost"),
Ok(ContextConfig::Ssh {
host: "myhost".into(),
user: Some("admin".into()),
port: None,
})
);
assert_eq!(
ContextConfig::from_endpoint("myhost:2222"),
Ok(ContextConfig::Ssh {
host: "myhost".into(),
user: None,
port: Some(2222),
})
);
assert_eq!(
ContextConfig::from_endpoint("ssh://admin@myhost:22"),
Ok(ContextConfig::Ssh {
host: "myhost".into(),
user: Some("admin".into()),
port: Some(22),
})
);
}
/// Non-numeric ports and extra segments are format errors; a
/// non-u16 numeric port is a port error.
#[test]
fn from_endpoint_rejects_malformed_endpoints() {
for bad in ["", "a/b/c", "host:notaport"] {
let err = ContextConfig::from_endpoint(bad).unwrap_err();
assert!(err.contains("Invalid endpoint format"), "{err}");
}
let err = ContextConfig::from_endpoint("host:99999").unwrap_err();
assert_eq!(err, "Invalid port number");
}
}
+35
View File
@@ -62,6 +62,20 @@ pub fn setup_environment(
.map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?; .map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?;
parse_dpkg_architecture_output(&dpkg_architecture, env); parse_dpkg_architecture_output(&dpkg_architecture, env);
// In-tree tools locate their libraries with the *host* pkg-config during
// cross builds (the kernel's tools/build feature checks derive their
// cflags/ldflags from `pkg-config --cflags/--libs`), whose search path
// only covers the build architecture's pkgconfig dirs. Point it at the
// target's so `libtraceevent` & co resolve to target-arch libraries:
// linux-riscv cross builds die in rtla's Makefile.config otherwise, even
// with the target -dev packages installed.
if let Some(multiarch) = env.get("DEB_HOST_MULTIARCH").cloned() {
env.insert(
"PKG_CONFIG_LIBDIR".to_string(),
format!("/usr/lib/{multiarch}/pkgconfig:/usr/share/pkgconfig"),
);
}
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string()); env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
Ok(()) Ok(())
@@ -289,4 +303,25 @@ mod tests {
); );
assert!(cross_suites("noble", None, "not-a-distro").is_err()); assert!(cross_suites("noble", None, "not-a-distro").is_err());
} }
/// setup_environment exports the target multiarch pkg-config libdir:
/// tools' feature checks run the *host* pkg-config, which must find the
/// target's .pc files (rtla hard-errors on libtraceevent otherwise,
/// failing linux-riscv cross builds despite the target -dev packages
/// being installed).
#[test]
fn test_setup_environment_exports_cross_pkg_config_libdir() {
let mut env = HashMap::new();
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
setup_environment(&mut env, "riscv64", ctx).unwrap();
assert_eq!(
env.get("PKG_CONFIG_LIBDIR").map(String::as_str),
Some("/usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig")
);
assert_eq!(
env.get("DEB_BUILD_PROFILES").map(String::as_str),
Some("cross")
);
}
} }
+17 -26
View File
@@ -1,5 +1,6 @@
use crate::context::{self, Context, ContextConfig}; use crate::context::{self, Context, ContextConfig};
use crate::ui::deb::{DebUi, Phase}; use crate::deb::{Phase, enter_phase};
use crate::report::BuildView;
use directories::ProjectDirs; use directories::ProjectDirs;
use std::any::Any; use std::any::Any;
use std::error::Error; use std::error::Error;
@@ -312,7 +313,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>>, view: &dyn BuildView,
) -> Result<Self, Box<dyn Error>> { ) -> Result<Self, Box<dyn Error>> {
// Save the globally-installed context so Drop can restore exactly // Save the globally-installed context so Drop can restore exactly
// this handle: concurrent builds install their own ephemeral // this handle: concurrent builds install their own ephemeral
@@ -355,7 +356,7 @@ impl EphemeralContextGuard {
// Download and extract the chroot tarball // Download and extract the chroot tarball
if let Err(e) = if let Err(e) =
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui) Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
.await .await
{ {
// The guard (and its Drop) never materializes on this path, so // The guard (and its Drop) never materializes on this path, so
@@ -408,7 +409,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>>, view: &dyn BuildView,
) -> 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();
@@ -464,10 +465,8 @@ impl EphemeralContextGuard {
series, series,
arch arch
); );
if let Some(u) = ui { enter_phase(view, Phase::PreparingChroot);
u.phase(Phase::PreparingChroot); Self::download_chroot_tarball(series, arch, &tarball_path, ctx, view).await?;
}
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: {:?})",
@@ -478,16 +477,12 @@ 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());
if let Some(u) = ui { enter_phase(view, Phase::ExtractingChroot);
u.phase(Phase::ExtractingChroot); Self::extract_tarball(&tarball_path, chroot_path, view)?;
}
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 { enter_phase(view, Phase::FinalizingChroot);
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)
@@ -503,7 +498,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>>, view: &dyn BuildView,
) -> 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");
@@ -537,8 +532,8 @@ 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 { if let Some(s) = view.sink() {
cmd.capture(u.sink()); cmd.capture(s);
} }
let status = cmd.status()?; let status = cmd.status()?;
@@ -575,7 +570,7 @@ impl EphemeralContextGuard {
fn extract_tarball( fn extract_tarball(
tarball_path: &PathBuf, tarball_path: &PathBuf,
chroot_path: &PathBuf, chroot_path: &PathBuf,
ui: Option<&DebUi>, view: &dyn BuildView,
) -> 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)?;
@@ -593,15 +588,11 @@ impl EphemeralContextGuard {
let mut entry = entry?; let mut entry = entry?;
entry.unpack_in(chroot_path)?; entry.unpack_in(chroot_path)?;
count += 1; count += 1;
if count.is_multiple_of(100) if count.is_multiple_of(100) {
&& let Some(u) = ui view.message(&format!("Extracting chroot… ({count} files)"));
{
u.progress_message(&format!("Extracting chroot… ({count} files)"));
} }
} }
if let Some(u) = ui { view.message(&format!("Extracting chroot… ({count} files)"));
u.progress_message(&format!("Extracting chroot… ({count} files)"));
}
Ok(()) Ok(())
} }
+36 -56
View File
@@ -1,9 +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, ContextCommand, LineSink}; use crate::context::{Context, ContextCommand, LineSink};
use crate::deb::find_dsc_file; use crate::deb::{Phase, enter_phase, find_dsc_file};
use crate::ui::deb::{DebUi, Phase}; use crate::logfmt::QuiltClassifier;
use crate::ui::logfmt::QuiltClassifier; use crate::report::BuildView;
use log::warn; use log::warn;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::error::Error; use std::error::Error;
@@ -33,13 +33,13 @@ pub async fn build(
pocket: Option<&str>, pocket: Option<&str>,
build_root: &str, build_root: &str,
cross: bool, cross: bool,
ppa: Option<&[&str]>, ppa: &[String],
inject_packages: Option<&[&str]>, inject_packages: &[String],
ctx: Arc<Context>, ctx: Arc<Context>,
ui: Option<Arc<DebUi>>, view: &dyn BuildView,
jobs: Option<usize>, jobs: Option<usize>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> { ) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink()); let sink: Option<Arc<dyn LineSink>> = view.sink();
// Environment // Environment
let mut env = HashMap::<String, String>::new(); let mut env = HashMap::<String, String>::new();
@@ -83,12 +83,9 @@ pub async fn build(
let mut added_ppas: Vec<(&str, &str)> = Vec::new(); let mut added_ppas: Vec<(&str, &str)> = Vec::new();
// Add PPA repositories if specified // Add PPA repositories if specified
if let Some(ppas) = ppa { for ppa_str in ppa {
for ppa_str in ppas { let (ppa_user, ppa_name) = crate::package_info::split_ppa(ppa_str)?;
// PPA format: user/ppa_name let base_url = crate::package_info::ppa_to_base_url(ppa_user, ppa_name);
let parts: Vec<&str> = ppa_str.split('/').collect();
if parts.len() == 2 {
let base_url = crate::package_info::ppa_to_base_url(parts[0], parts[1]);
// Add new PPA source if not found // Add new PPA source if not found
if !sources.iter().any(|s| s.uri.contains(&base_url)) { if !sources.iter().any(|s| s.uri.contains(&base_url)) {
@@ -119,7 +116,7 @@ pub async fn build(
}; };
sources.push(new_source); sources.push(new_source);
modified = true; modified = true;
added_ppas.push((parts[0], parts[1])); added_ppas.push((ppa_user, ppa_name));
log::info!( log::info!(
"Added PPA: {} for series {} with architectures {:?}", "Added PPA: {} for series {} with architectures {:?}",
ppa_str, ppa_str,
@@ -127,12 +124,6 @@ pub async fn build(
architectures architectures
); );
} }
} else {
return Err(
format!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str).into(),
);
}
}
} }
// UBUNTU: Ensure the 'universe' component is enabled on official // UBUNTU: Ensure the 'universe' component is enabled on official
@@ -193,9 +184,7 @@ 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...");
if let Some(u) = &ui { enter_phase(view, Phase::UpdatingPackageLists);
u.phase(Phase::UpdatingPackageLists);
}
let status = cap( let status = cap(
ctx.command("apt-get").envs(env.clone()).arg("update"), ctx.command("apt-get").envs(env.clone()).arg("update"),
&sink, &sink,
@@ -234,9 +223,7 @@ 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}"));
} }
if let Some(u) = &ui { enter_phase(view, Phase::InstallingEssentials);
u.phase(Phase::InstallingEssentials);
}
let status = cap(&mut cmd, &sink).status()?; 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());
@@ -261,18 +248,16 @@ pub async fn build(
} }
// 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(), &ui, &sink)?; apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?;
// Install injected packages if specified // Install injected packages if specified
if let Some(packages) = inject_packages { if !inject_packages.is_empty() {
install_injected_packages(packages, &env, ctx.clone(), &ui, &sink)?; install_injected_packages(inject_packages, &env, ctx.clone(), view, &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 { enter_phase(view, Phase::InstallingBuildDeps);
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())
@@ -286,9 +271,7 @@ pub async fn build(
// 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 { view.suspend();
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());
} }
@@ -320,9 +303,7 @@ pub async fn build(
// 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 { view.suspend();
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());
} }
@@ -330,9 +311,7 @@ pub async fn build(
// Run the build step // Run the build step
log::debug!("Building (debian/rules build) package..."); log::debug!("Building (debian/rules build) package...");
if let Some(u) = &ui { enter_phase(view, Phase::Building);
u.phase(Phase::Building);
}
let status = cap( let status = cap(
ctx.command("debian/rules") ctx.command("debian/rules")
.current_dir(package_dir_str) .current_dir(package_dir_str)
@@ -346,9 +325,7 @@ pub async fn build(
} }
// Run the 'binary' step to produce deb // Run the 'binary' step to produce deb
if let Some(u) = &ui { enter_phase(view, Phase::ProducingBinaries);
u.phase(Phase::ProducingBinaries);
}
let status = cap( let status = cap(
ctx.command("fakeroot") ctx.command("fakeroot")
.current_dir(package_dir_str) .current_dir(package_dir_str)
@@ -495,7 +472,7 @@ 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>>, view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>, 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");
@@ -555,12 +532,10 @@ fn apply_quilt_patches(
} }
// Apply all patches listed in the series // Apply all patches listed in the series
if let Some(u) = ui { view.phase(
u.phase_with( Phase::ApplyingPatches.label(),
Phase::ApplyingPatches,
Box::new(QuiltClassifier::new(total_patches)), 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 = cap( let status = cap(
@@ -631,17 +606,15 @@ fn pin_pocket(pocket_suite: &str, ctx: &Arc<Context>) -> Result<(), Box<dyn Erro
} }
fn install_injected_packages( fn install_injected_packages(
packages: &[&str], packages: &[String],
env: &HashMap<String, String>, env: &HashMap<String, String>,
ctx: Arc<Context>, ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>, view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>, 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 { enter_phase(view, Phase::InjectingPackages);
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();
@@ -661,7 +634,7 @@ fn install_injected_packages(
); );
deb_files.push(chroot_path.to_string_lossy().to_string()); deb_files.push(chroot_path.to_string_lossy().to_string());
} else { } else {
package_names.push(pkg); package_names.push(pkg.as_str());
} }
} }
@@ -855,6 +828,13 @@ mod tests {
) )
.unwrap(); .unwrap();
apply_quilt_patches(tree.to_str().unwrap(), &HashMap::new(), ctx, &None, &None).unwrap(); apply_quilt_patches(
tree.to_str().unwrap(),
&HashMap::new(),
ctx,
&crate::report::Quiet,
&None,
)
.unwrap();
} }
} }
+185 -102
View File
@@ -5,7 +5,11 @@ pub(crate) mod ephemeral;
mod local; mod local;
use crate::context::{self, Context}; use crate::context::{self, Context};
use crate::ui::deb::{DebUi, Phase}; use crate::logfmt::{
AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier,
MmdebstrapClassifier, QuiltClassifier,
};
use crate::report::{BuildTarget, BuildView};
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;
@@ -17,67 +21,163 @@ pub enum BuildMode {
Local, Local,
} }
/// Phases of a binary build, announced to the [`BuildView`]
#[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 line classifier rewriting a phase's subprocess output
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()),
}
}
/// Enter `phase` on the view with its default line classifier
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase) {
view.phase(phase.label(), default_classifier(phase));
}
/// Parameters of one [`build_binary_package`] call.
pub struct DebBuildOptions<'a> {
/// Target architecture; defaults to the host architecture.
pub arch: Option<String>,
/// Target distribution series; defaults to the changelog series
/// (UNRELEASED resolves to the vendor's development series).
pub series: Option<String>,
/// Distribution pocket to resolve build-dependencies from.
pub pocket: Option<String>,
/// Source tree to build; defaults to the process working directory.
pub cwd: Option<PathBuf>,
/// Cross-compile for the target architecture instead of using
/// qemu-binfmt.
pub cross: bool,
/// Build mode; defaults to [`BuildMode::Local`].
pub mode: Option<BuildMode>,
/// PPAs to add for build-dependencies (`user/ppa_name`).
pub ppa: Vec<String>,
/// Packages to inject into the build environment before build-dep
/// (.deb paths, archive names or PPA packages).
pub inject: Vec<String>,
/// Parallel build jobs; defaults to the core count available in the
/// build context.
pub jobs: Option<usize>,
/// Explicit build context; defaults to the current context.
pub ctx: Option<Arc<Context>>,
/// Where build events (phases, progress, outcome) are reported.
pub view: &'a dyn BuildView,
}
impl Default for DebBuildOptions<'_> {
fn default() -> Self {
static QUIET: crate::report::Quiet = crate::report::Quiet;
DebBuildOptions {
arch: None,
series: None,
pocket: None,
cwd: None,
cross: false,
mode: None,
ppa: Vec::new(),
inject: Vec::new(),
jobs: None,
ctx: None,
view: &QUIET,
}
}
}
/// Build package in 'cwd' to a .deb /// Build package in 'cwd' to a .deb
/// ///
/// Returns the list of produced artifacts (.deb files plus the upload /// Returns the list of produced artifacts (.deb files plus the upload
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from /// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
/// `debian/files` and the native metadata generation rather than by /// `debian/files` and the native metadata generation rather than by
/// globbing the build root (which would surface stale files). When `ui` is /// globbing the build root (which would surface stale files). Subprocess
/// set, a live view (status bar + rolling log pane) is displayed and all /// output is captured through the view's sink (live view + tee log for the
/// subprocess output is captured through it; on failure the widget is /// terminal adapter); on failure the view is cleared and prints a summary
/// cleared and a summary of captured errors is printed. /// of captured errors.
#[allow(clippy::too_many_arguments)]
pub async fn build_binary_package( pub async fn build_binary_package(
arch: Option<&str>, opts: DebBuildOptions<'_>,
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>>,
jobs: Option<usize>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> { ) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let result = build_binary_package_impl( let view = opts.view;
arch, let result = build_binary_package_impl(opts).await;
series,
pocket,
cwd,
cross,
mode,
ppa,
inject_packages,
ctx,
&ui,
jobs,
)
.await;
if let (Some(u), Err(_)) = (&ui, &result) { if result.is_err() {
u.finish_failure(); view.finish_failure();
} }
result result
} }
/// Implementation of [`build_binary_package`], without failure handling /// Implementation of [`build_binary_package`], without failure handling
#[allow(clippy::too_many_arguments)]
async fn build_binary_package_impl( async fn build_binary_package_impl(
arch: Option<&str>, opts: DebBuildOptions<'_>,
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>>,
jobs: Option<usize>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> { ) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let cwd = cwd.unwrap_or_else(|| Path::new(".")); let DebBuildOptions {
ref arch,
ref series,
ref pocket,
ref cwd,
cross,
ref mode,
ref ppa,
ref inject,
ref jobs,
ref ctx,
view,
} = opts;
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
// Parse changelog to get package name, version and series // Parse changelog to get package name, version and series
let changelog_path = cwd.join("debian/changelog"); let changelog_path = cwd.join("debian/changelog");
@@ -101,44 +201,45 @@ async fn build_binary_package_impl(
&package_series &package_series
}; };
let current_arch = crate::get_current_arch(); let current_arch = crate::get_current_arch();
let arch = arch.unwrap_or(&current_arch); let arch = arch.as_deref().unwrap_or(&current_arch);
// Make sure we select a specific mode, either using user-requested // Make sure we select a specific mode, either using user-requested
// or by using default for user-supplied parameters // or by using default for user-supplied parameters
let mode = if let Some(m) = mode { let default_mode = BuildMode::Local;
m let mode = mode.as_ref().unwrap_or(&default_mode);
} else {
// By default, we use local build
BuildMode::Local
};
// Create an ephemeral unshare context for all Local builds // Create an ephemeral unshare context for all Local builds
// Use qemu_binfmt when target architecture differs from host and cross is not requested // Use qemu_binfmt when target architecture differs from host and cross is not requested
let chroot_arch = if mode == BuildMode::Local && arch != current_arch && !cross { let chroot_arch = if mode == &BuildMode::Local && arch != current_arch && !cross {
Some(arch) Some(arch)
} else { } else {
None None
}; };
// 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.clone().unwrap_or_else(context::current);
// Identify the target in the live UI once the changelog is parsed, so // Identify the target in the live view once the changelog is parsed, so
// even the chroot download output is attributed and tee'd // even the chroot download output is attributed and tee'd
if let Some(u) = ui { view.target(BuildTarget {
u.set_target(&package, &version, series, arch); package: &package,
} version: &version,
target: &format!("{series}/{arch}"),
display: format!("Building {package} ({version}) for {series}/{arch}"),
source_only: false,
tee_log: true,
});
// Create an ephemeral unshare context for all Local builds. It is kept in // 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 // this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared. // 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(), view,
) )
.await?, .await?,
) )
@@ -179,14 +280,14 @@ async fn build_binary_package_impl(
&version, &version,
arch, arch,
series, series,
pocket, pocket.as_deref(),
&build_root, &build_root,
cross, cross,
ppa, ppa,
inject_packages, inject,
build_ctx.clone(), build_ctx.clone(),
ui.clone(), view,
jobs, *jobs,
) )
.await? .await?
} }
@@ -194,9 +295,7 @@ async fn build_binary_package_impl(
// Retrieve the produced artifacts (binary packages plus the upload // Retrieve the produced artifacts (binary packages plus the upload
// metadata) to the parent directory. // metadata) to the parent directory.
if let Some(u) = ui { enter_phase(view, Phase::RetrievingArtifacts);
u.phase(Phase::RetrievingArtifacts);
}
let total_debs = remote_files.len(); let total_debs = remote_files.len();
let mut artifacts = Vec::with_capacity(total_debs); let mut artifacts = Vec::with_capacity(total_debs);
@@ -206,14 +305,10 @@ async fn build_binary_package_impl(
build_ctx.retrieve_path(remote_file, &local_dest)?; build_ctx.retrieve_path(remote_file, &local_dest)?;
artifacts.push(local_dest); artifacts.push(local_dest);
if let Some(u) = ui { view.progress("Retrieving artifacts", idx + 1, total_debs);
u.count_progress("Retrieving artifacts", idx + 1, total_debs);
}
} }
if let Some(u) = ui { view.finish_success(&artifacts);
u.finish_success(&artifacts, u.elapsed());
}
Ok(artifacts) Ok(artifacts)
} }
@@ -222,9 +317,7 @@ async fn build_binary_package_impl(
// Clear the live view before returning: the ephemeral guard is dropped at // 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 end of this function and its cleanup commands (umount, rm -rf of
// the chroot) inherit the terminal, so they must not fight the widget. // the chroot) inherit the terminal, so they must not fight the widget.
if let Some(u) = ui { view.suspend();
u.suspend();
}
// Mark build as successful to trigger chroot cleanup // Mark build as successful to trigger chroot cleanup
if result.is_ok() if result.is_ok()
@@ -446,19 +539,14 @@ mod tests {
log::debug!("Package directory: {}", cwd.display()); log::debug!("Package directory: {}", cwd.display());
log::info!("Starting binary package build..."); log::info!("Starting binary package build...");
crate::deb::build_binary_package( crate::deb::build_binary_package(DebBuildOptions {
arch, arch: arch.map(str::to_string),
Some(series), series: Some(series.to_string()),
None,
Some(&cwd),
cross, cross,
None, cwd: Some(cwd.to_path_buf()),
None, ctx: Some(ctx),
None, ..Default::default()
Some(ctx), })
None,
None,
)
.await .await
.expect("Cannot build binary package (deb)"); .expect("Cannot build binary package (deb)");
log::info!("Successfully built binary package"); log::info!("Successfully built binary package");
@@ -620,19 +708,14 @@ mod tests {
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
crate::deb::build_binary_package( crate::deb::build_binary_package(DebBuildOptions {
Some("arm64"), arch: Some("arm64".to_string()),
Some("noble"), series: Some("noble".to_string()),
None, cwd: Some(pkg_dir),
Some(&pkg_dir), cross: true,
true, ctx: Some(ctx),
None, ..Default::default()
None, })
None,
Some(ctx),
None,
None,
)
.await .await
.expect("Cannot cross-build package declaring Build-Depends-Indep"); .expect("Cannot cross-build package declaring Build-Depends-Indep");
+9
View File
@@ -34,6 +34,15 @@ pub mod put;
/// Handle package-specific quirks and workarounds /// Handle package-specific quirks and workarounds
pub mod quirks; 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) /// Terminal UI helpers (progress bars, live build views, prompts)
pub mod ui; pub mod ui;
View File
+114 -119
View File
@@ -321,10 +321,14 @@ fn main() {
// the structural self-checks inside `scaffold` always run), with // the structural self-checks inside `scaffold` always run), with
// the scaffold outcome (e.g. a failed vendoring) shaping the // the scaffold outcome (e.g. a failed vendoring) shaping the
// offer. // offer.
let prompter = pkh::ui::prompt::TerminalPrompter;
if let Err(e) = rt.block_on(async { if let Err(e) = rt.block_on(async {
let opts = pkh::new::questions::run(cli).await?; let opts = pkh::new::questions::run(cli, &prompter).await?;
let outcome = pkh::new::scaffold(opts.clone(), &multi)?; let outcome = pkh::new::scaffold(opts.clone(), &multi)?;
pkh::new::questions::offer_verification(&opts, &outcome, &multi, no_verify).await; pkh::new::questions::offer_verification(
&opts, &outcome, &multi, no_verify, &prompter,
)
.await;
Ok::<(), Box<dyn std::error::Error>>(()) Ok::<(), Box<dyn std::error::Error>>(())
}) { }) {
error!("{}", e); error!("{}", e);
@@ -349,15 +353,14 @@ fn main() {
let (pb, progress_callback) = pkh::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.map(|ppa_str| { let base_url = match ppa.map(pkh::package_info::split_ppa) {
// PPA format: user/ppa_name Some(Ok((user, name))) => Some(pkh::package_info::ppa_to_base_url(user, name)),
let parts: Vec<&str> = ppa_str.split('/').collect(); Some(Err(e)) => {
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { error!("{e}");
error!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str);
std::process::exit(1); std::process::exit(1);
} }
pkh::package_info::ppa_to_base_url(parts[0], parts[1]) None => None,
}); };
// Since pull is async, we need to block on it // Since pull is async, we need to block on it
if let Err(e) = rt.block_on(async { if let Err(e) = rt.block_on(async {
@@ -391,74 +394,47 @@ fn main() {
let target_series = if let Some(s) = cli_series { let target_series = if let Some(s) = cli_series {
Some(s.to_string()) Some(s.to_string())
} else { } else {
// Parse current changelog to determine the default series
let changelog_path = cwd.join("debian/changelog"); let changelog_path = cwd.join("debian/changelog");
match pkh::changelog::parse_changelog_header(&changelog_path) { match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
Ok((_pkg, _ver, current_series)) => { Some(pkh::changelog::SeriesCandidates::Choose {
// UNRELEASED is not a real series: offer it as a options,
// pinned first entry (selecting it keeps the changelog default,
// unreleased) on top of the current vendor's series fallback,
// list, defaulting to the development series. Any }) => match pkh::ui::select_series(&options, &default) {
// other series resolves through the series list of
// its own distribution.
match rt.block_on(async {
if pkh::distro_info::is_unreleased(&current_series) {
// Vendors keep original casing ("Ubuntu"),
// while the series data keys are lowercase
let dist = pkh::build::env::current_vendor().to_lowercase();
let mut series_list =
vec![pkh::distro_info::UNRELEASED.to_string()];
series_list.extend(
pkh::distro_info::get_ordered_series_name(&dist).await?,
);
Ok(series_list)
} else {
let dist =
pkh::distro_info::get_dist_from_series(&current_series).await?;
pkh::distro_info::get_ordered_series_name(&dist).await
}
}) {
Ok(series_list) => {
// Default to the development series (the
// first real entry) when the changelog is
// UNRELEASED, not to the pinned entry itself
let default = if pkh::distro_info::is_unreleased(&current_series)
&& series_list.len() > 1
{
series_list[1].clone()
} else {
current_series.clone()
};
match pkh::ui::select_series(&series_list, &default) {
Ok(selected) => Some(selected), Ok(selected) => Some(selected),
Err(e) => { Err(e) => {
error!( error!(
"Series selection failed: {}. Using current series '{}' instead.", "Series selection failed: {}. Using current series '{}' instead.",
e, current_series e, fallback
); );
Some(current_series) Some(fallback)
} }
} },
} // Could not fetch the series list: use the current series
Err(_) => { Some(pkh::changelog::SeriesCandidates::Keep(current)) => Some(current),
// Could not fetch series list, use current series as default // No parsable changelog: leave the series decision to
Some(current_series) // generate_entry
} None => None,
}
}
Err(_) => None,
} }
}; };
if let Err(e) = generate_entry( let entry = match generate_entry(
"debian/changelog", "debian/changelog",
Some(&cwd), Some(&cwd),
version, version,
target_series.as_deref(), target_series.as_deref(),
) { ) {
Ok(entry) => entry,
Err(e) => {
error!("{}", e); error!("{}", e);
std::process::exit(1); std::process::exit(1);
} }
};
println!(
"Found package: {}, version: {}",
entry.package, entry.previous_version
);
println!("Added new changelog entry to {}", entry.path.display());
let editor = match std::env::var("EDITOR") { let editor = match std::env::var("EDITOR") {
Ok(e) => e, Ok(e) => e,
@@ -490,13 +466,19 @@ fn main() {
.copied() .copied()
.unwrap_or(false); .unwrap_or(false);
// Live build view: disabled by --verbose or when stdout is not a // Live build view, unless --verbose (DebUi additionally disables
// terminal (DebUi handles the non-TTY case itself) // itself when stdout is not a terminal)
let ui = if verbose { let quiet = pkh::report::Quiet;
let live = if verbose {
None None
} else { } else {
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi))) Some(pkh::ui::deb::DebUi::new(&multi))
}; };
let view: &dyn pkh::report::BuildView = live
.as_ref()
.map(|v| v as &dyn pkh::report::BuildView)
.unwrap_or(&quiet);
let prompter = pkh::ui::prompt::TerminalPrompter;
let orig_source = match sub_matches.get_one::<String>("orig").map(String::as_str) { let orig_source = match sub_matches.get_one::<String>("orig").map(String::as_str) {
Some("always") => pkh::build::OrigSourceMode::Always, Some("always") => pkh::build::OrigSourceMode::Always,
@@ -504,14 +486,38 @@ fn main() {
_ => pkh::build::OrigSourceMode::Auto, _ => pkh::build::OrigSourceMode::Auto,
}; };
if let Err(e) = pkh::build::build_source_package( match pkh::build::build_source_package(pkh::build::BuildSourceOptions {
Some(&cwd), source: Some(cwd),
pkh::build::SourceBuildOptions { options: pkh::build::SourceBuildOptions {
orig_source, orig_source,
..Default::default() ..Default::default()
}, },
ui, 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::report::display_path(&artifact));
}
}
if output.signed {
println!("Package built and signed successfully!");
} else {
println!("Package built successfully (unsigned).");
}
}
Err(e) => {
// The unmet-dependency diagnostics first, then the
// summary: the exact rendering the flow used to do.
if let Some(unmet) =
e.downcast_ref::<pkh::debian::deps::UnmetBuildDependencies>()
{
eprintln!("{}", unmet.0.message());
}
error!("{}", e); error!("{}", e);
// Unmet build dependencies/conflicts exit with status 3, // Unmet build dependencies/conflicts exit with status 3,
// like dpkg-buildpackage does. // like dpkg-buildpackage does.
@@ -523,6 +529,7 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
} }
}
Some(("put", sub_matches)) => { Some(("put", sub_matches)) => {
let cwd = current_dir_or_exit(); let cwd = current_dir_or_exit();
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str()); let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
@@ -543,41 +550,38 @@ fn main() {
std::process::exit(1); std::process::exit(1);
}; };
let view = pkh::ui::deb::DebUi::new(&multi);
let prompter = pkh::ui::prompt::TerminalPrompter;
let options = pkh::put::PutOptions { let options = pkh::put::PutOptions {
ppa: ppa.to_string(), ppa: ppa.to_string(),
changes, changes,
force, force,
cwd, cwd,
view: &view,
prompter: &prompter,
}; };
if let Err(e) = rt.block_on(async { pkh::put::put(&options, &multi).await }) { if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) {
error!("{}", e); error!("{}", e);
std::process::exit(1); std::process::exit(1);
} }
} }
Some(("deb", sub_matches)) => { Some(("deb", sub_matches)) => {
let cwd = current_dir_or_exit(); let cwd = current_dir_or_exit();
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str()); let series = sub_matches.get_one::<String>("series").cloned();
let pocket = sub_matches.get_one::<String>("pocket").map(|s| s.as_str()); let pocket = sub_matches.get_one::<String>("pocket").cloned();
let arch = sub_matches.get_one::<String>("arch").map(|s| s.as_str()); let arch = sub_matches.get_one::<String>("arch").cloned();
let cross = sub_matches.get_one::<bool>("cross").unwrap_or(&false); let cross = sub_matches
let ppa: Vec<&str> = sub_matches .get_one::<bool>("cross")
.copied()
.unwrap_or(false);
let ppa: Vec<String> = sub_matches
.get_many::<String>("ppa") .get_many::<String>("ppa")
.map(|v| v.map(|s| s.as_str()).collect()) .map(|v| v.cloned().collect())
.unwrap_or_default(); .unwrap_or_default();
let ppa = if ppa.is_empty() { let inject: Vec<String> = sub_matches
None
} else {
Some(ppa.as_slice())
};
let inject_packages: Vec<&str> = sub_matches
.get_many::<String>("inject") .get_many::<String>("inject")
.map(|v| v.map(|s| s.as_str()).collect()) .map(|v| v.cloned().collect())
.unwrap_or_default(); .unwrap_or_default();
let inject_packages = if inject_packages.is_empty() {
None
} else {
Some(inject_packages.as_slice())
};
let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str()); let mode: Option<&str> = sub_matches.get_one::<String>("mode").map(|s| s.as_str());
let mode: Option<pkh::deb::BuildMode> = match mode { let mode: Option<pkh::deb::BuildMode> = match mode {
Some("local") => Some(pkh::deb::BuildMode::Local), Some("local") => Some(pkh::deb::BuildMode::Local),
@@ -596,36 +600,38 @@ fn main() {
}) })
}); });
// Live build view: disabled by --verbose or when stdout is not a // Live build view, unless --verbose (DebUi additionally disables
// terminal (DebUi handles the non-TTY case itself) // itself when stdout is not a terminal)
let ui = if verbose { let quiet = pkh::report::Quiet;
let live = if verbose {
None None
} else { } else {
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi))) Some(pkh::ui::deb::DebUi::new(&multi))
}; };
let view: &dyn pkh::report::BuildView = live
.as_ref()
.map(|v| v as &dyn pkh::report::BuildView)
.unwrap_or(&quiet);
let result = rt.block_on(async { let result = rt.block_on(async {
pkh::deb::build_binary_package( pkh::deb::build_binary_package(pkh::deb::DebBuildOptions {
arch, arch,
series, series,
pocket, pocket,
Some(cwd.as_path()), cwd: Some(cwd.clone()),
*cross, cross,
mode, mode,
ppa, ppa,
inject_packages, inject,
None,
ui.clone(),
jobs, jobs,
) view,
..Default::default()
})
.await .await
}); });
match result { match result {
Ok(artifacts) => { Ok(_) => info!("Done."),
let _ = artifacts;
info!("Done.");
}
Err(e) => { Err(e) => {
error!("{}", e); error!("{}", e);
std::process::exit(1); std::process::exit(1);
@@ -655,24 +661,13 @@ fn main() {
std::process::exit(1); std::process::exit(1);
}); });
// Parse host, user, port from endpoint match pkh::context::ContextConfig::from_endpoint(endpoint) {
// Formats: [ssh://][user@]host[:port] Ok(config) => config,
let endpoint_re = regex::Regex::new(r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$").unwrap(); Err(e) => {
let endpoint_cap = endpoint_re.captures(endpoint).unwrap_or_else(|| { error!("{e}");
error!("Invalid endpoint format: '{}'. Expected [ssh://][user@]host[:port]", endpoint);
std::process::exit(1); std::process::exit(1);
}); }
}
let host = endpoint_cap.name("host").unwrap().as_str().to_string();
let user = endpoint_cap.name("user").map(|m| m.as_str().to_string());
let port = endpoint_cap.name("port").map(|m| {
m.as_str().parse::<u16>().unwrap_or_else(|_| {
error!("Invalid port number");
std::process::exit(1);
})
});
ContextConfig::Ssh { host, user, port }
} }
_ => { _ => {
error!("Unknown context type: {}", type_str); error!("Unknown context type: {}", type_str);
+1 -1
View File
@@ -436,7 +436,7 @@ pub fn create_orig_tarball_excluding(
log::info!( log::info!(
"Created orig tarball {}", "Created orig tarball {}",
crate::ui::display_path(&tarball_path) crate::report::display_path(&tarball_path)
); );
Ok(tarball_path) Ok(tarball_path)
} }
+4 -4
View File
@@ -251,7 +251,7 @@ fn print_success(opts: &NewOptions, outcome: &ScaffoldOutcome) {
// `display_path` yields an empty string when the target is the cwd // `display_path` yields an empty string when the target is the cwd
// itself (Here mode): `Created .` would be cryptic, so spell the // itself (Here mode): `Created .` would be cryptic, so spell the
// location out; the skeleton/path modes keep the `<dir>` display. // location out; the skeleton/path modes keep the `<dir>` display.
let display = crate::ui::display_path(&target); let display = crate::report::display_path(&target);
let location = if display.is_empty() { let location = if display.is_empty() {
"package in the current directory".to_string() "package in the current directory".to_string()
} else { } else {
@@ -819,7 +819,7 @@ mod tests {
let output = crate::build::run_source_build( let output = crate::build::run_source_build(
&source, &source,
&crate::build::SourceBuildOptions::default(), &crate::build::SourceBuildOptions::default(),
None, &crate::report::Quiet,
) )
.unwrap(); .unwrap();
let dsc = std::fs::read_to_string(&output.dsc).unwrap(); let dsc = std::fs::read_to_string(&output.dsc).unwrap();
@@ -885,7 +885,7 @@ mod tests {
let output = crate::build::run_source_build( let output = crate::build::run_source_build(
&dir.path().join("mytool"), &dir.path().join("mytool"),
&crate::build::SourceBuildOptions::default(), &crate::build::SourceBuildOptions::default(),
None, &crate::report::Quiet,
) )
.unwrap(); .unwrap();
@@ -916,7 +916,7 @@ mod tests {
let output = crate::build::run_source_build( let output = crate::build::run_source_build(
&dir.path().join("mytool"), &dir.path().join("mytool"),
&crate::build::SourceBuildOptions::default(), &crate::build::SourceBuildOptions::default(),
None, &crate::report::Quiet,
) )
.unwrap(); .unwrap();
assert!(output.dsc.exists(), "{:?} missing", output.dsc); assert!(output.dsc.exists(), "{:?} missing", output.dsc);
+4 -4
View File
@@ -183,7 +183,7 @@ pub fn create_vendor_component(
log::info!( log::info!(
"Created vendored-dependencies component {}", "Created vendored-dependencies component {}",
crate::ui::display_path(&component_path) crate::report::display_path(&component_path)
); );
Ok(component_path) Ok(component_path)
} }
@@ -283,7 +283,7 @@ fn git_archive_tarball(
log::info!( log::info!(
"Created orig tarball from git archive of {tag}: {}", "Created orig tarball from git archive of {tag}: {}",
crate::ui::display_path(&dest) crate::report::display_path(&dest)
); );
Ok(dest) Ok(dest)
} }
@@ -311,7 +311,7 @@ fn download_release(
Ok(path) => { Ok(path) => {
log::info!( log::info!(
"Created orig tarball from the release download of {tag}: {}", "Created orig tarball from the release download of {tag}: {}",
crate::ui::display_path(&path) crate::report::display_path(&path)
); );
Ok(path) Ok(path)
} }
@@ -352,7 +352,7 @@ fn fetch_and_repack(
log::info!( log::info!(
"Created orig tarball from {}: {}", "Created orig tarball from {}: {}",
source, source,
crate::ui::display_path(&dest) crate::report::display_path(&dest)
); );
Ok(dest) Ok(dest)
} }
+91 -83
View File
@@ -1,11 +1,11 @@
//! The `pkh new` interactive wizard. //! The `pkh new` interactive wizard.
//! //!
//! [`run`] is the single entry point: on an interactive terminal it asks the //! [`run`] is the single entry point: when the prompter can interact it asks
//! questions of the spec's "Proposed UX" transcript, fills a //! the questions of the spec's "Proposed UX" transcript, fills a
//! [`NewCli`] with the answers (explicit flags are never re-asked), and //! [`NewCli`] with the answers (explicit flags are never re-asked), and
//! reuses [`options::resolve`] as the single source of truth for defaults, //! reuses [`options::resolve`] as the single source of truth for defaults,
//! detection and validation — so the non-interactive and interactive paths //! detection and validation — so the non-interactive and interactive paths
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes //! cannot drift apart. Headless (or with `--defaults`) it goes
//! straight through [`options::resolve`], whose error lists every missing //! straight through [`options::resolve`], whose error lists every missing
//! answer. //! answer.
//! //!
@@ -13,11 +13,10 @@
//! verification builds of the spec ([`offer_verification`]); a failed //! verification builds of the spec ([`offer_verification`]); a failed
//! verification never undoes the scaffold. //! verification never undoes the scaffold.
//! //!
//! The prompt calls live in `run_wizard` and `offer_verification` only; //! The prompter calls live in `run_wizard` and `offer_verification` only;
//! everything else in this module is pure and unit-tested. //! everything else in this module is pure and unit-tested.
use std::error::Error; use std::error::Error;
use std::io::IsTerminal;
use std::path::PathBuf; use std::path::PathBuf;
use indicatif::MultiProgress; use indicatif::MultiProgress;
@@ -28,7 +27,7 @@ use crate::new::licenses;
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId}; use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
use crate::new::origin::GitOrigin; use crate::new::origin::GitOrigin;
use crate::new::templates::{self, ProbeResult, ScaffoldOutcome}; use crate::new::templates::{self, ProbeResult, ScaffoldOutcome};
use crate::ui::prompt; use crate::report::{Prompter, Validator};
/// Answer of the "where is the source code?" question: fresh skeleton. /// Answer of the "where is the source code?" question: fresh skeleton.
const SOURCE_SKELETON: &str = "Create a new project skeleton here"; const SOURCE_SKELETON: &str = "Create a new project skeleton here";
@@ -40,8 +39,8 @@ const SOURCE_PATH: &str = "Package the sources in another directory…";
/// The "everything else" entry of the license menu. /// The "everything else" entry of the license menu.
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)"; const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
/// Labels of the interactive `select` questions. `prompt::select` renders /// Labels of the interactive `select` questions. The prompter renders
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no /// `> <label><answer>` verbatim — unlike [`Prompter::text`], it appends no
/// formatting of its own — so each label carries its own separator: /// formatting of its own — so each label carries its own separator:
/// field-style prompts end with `": "`, question-style ones with `"? "`. /// field-style prompts end with `": "`, question-style ones with `"? "`.
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? "; const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
@@ -62,20 +61,13 @@ const SELECT_LABELS: [&str; 6] = [
ORIG_LABEL, ORIG_LABEL,
]; ];
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain /// Run the `pkh new` flow: the wizard when the prompter can interact,
/// [`options::resolve`] otherwise (and with `--defaults`). /// plain [`options::resolve`] otherwise (and with `--defaults`).
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> { pub async fn run(cli: NewCli, prompter: &dyn Prompter) -> Result<NewOptions, Box<dyn Error>> {
if cli.defaults || !is_interactive() { if cli.defaults || !prompter.interactive() {
return Ok(options::resolve(cli).await?); return Ok(options::resolve(cli).await?);
} }
run_wizard(cli).await run_wizard(cli, prompter).await
}
/// Whether both ends of the terminal are interactive; the wizard and the
/// verification offers only run when this holds (the prompts' non-TTY
/// fallbacks would otherwise silently take defaults).
fn is_interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
} }
/// The wizard question flow (spec "Proposed UX"), in order: /// The wizard question flow (spec "Proposed UX"), in order:
@@ -87,7 +79,10 @@ fn is_interactive() -> bool {
/// (`empty` template only), git init — then the summary screen and the /// (`empty` template only), git init — then the summary screen and the
/// final `Generate?` confirmation. Every question with an explicit flag /// final `Generate?` confirmation. Every question with an explicit flag
/// answer is skipped (flag > detected/probe > default merge order). /// answer is skipped (flag > detected/probe > default merge order).
async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> { async fn run_wizard(
mut cli: NewCli,
prompter: &dyn Prompter,
) -> Result<NewOptions, Box<dyn Error>> {
let cwd = std::env::current_dir()?; let cwd = std::env::current_dir()?;
let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone()); let mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
let (detection, mut probe) = detect_and_probe(&detect_dir); let (detection, mut probe) = detect_and_probe(&detect_dir);
@@ -101,7 +96,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// basename of the current directory. // basename of the current directory.
if cli.name.is_none() { if cli.name.is_none() {
let default = default_package_name(&cwd, probe.as_ref()); let default = default_package_name(&cwd, probe.as_ref());
let answer = ask_text("Package name", &default, options::validate_source_name)?; let answer = ask_text(
prompter,
"Package name",
&default,
options::validate_source_name,
)?;
cli.name = Some(answer); cli.name = Some(answer);
} }
@@ -142,7 +142,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.unwrap_or(TemplateId::EMPTY) .unwrap_or(TemplateId::EMPTY)
.display_name() .display_name()
.to_string(); .to_string();
let id = select_template(&menu, &default)?; let id = select_template(prompter, &menu, &default)?;
cli.lang = Some(id.as_str().to_string()); cli.lang = Some(id.as_str().to_string());
} }
LanguageChoice::Ambiguous(candidates) => { LanguageChoice::Ambiguous(candidates) => {
@@ -157,7 +157,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.join(", ") .join(", ")
); );
let menu = language_menu(&candidates); let menu = language_menu(&candidates);
let id = select_template(&menu, &menu[0])?; let id = select_template(prompter, &menu, &menu[0])?;
cli.lang = Some(id.as_str().to_string()); cli.lang = Some(id.as_str().to_string());
} }
} }
@@ -193,14 +193,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else { } else {
SOURCE_HERE SOURCE_HERE
}; };
let answer = select_from(SOURCE_LABEL, &options, default, |answer| { let answer = select_from(prompter, SOURCE_LABEL, &options, default, |answer| {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
if answer == SOURCE_HERE { if answer == SOURCE_HERE {
cli.source = Some(cwd.clone()); cli.source = Some(cwd.clone());
} else if answer == SOURCE_PATH { } else if answer == SOURCE_PATH {
let validator = |path: &str| validate_directory_answer(path); let validator = |path: &str| validate_directory_answer(path);
let path = prompt::text("Source directory", "", Some(&validator))?; let path = prompter.text("Source directory", "", Some(&validator))?;
cli.source = Some(PathBuf::from(path)); cli.source = Some(PathBuf::from(path));
} }
// SOURCE_SKELETON: cli.source stays unset (the name decides). // SOURCE_SKELETON: cli.source stays unset (the name decides).
@@ -266,7 +266,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.unwrap_or_else(|| "0.1.0".to_string()); .unwrap_or_else(|| "0.1.0".to_string());
let revision = cli.revision.unwrap_or(1); let revision = cli.revision.unwrap_or(1);
let validate = move |version: &str| options::validate_upstream_version(version, revision); let validate = move |version: &str| options::validate_upstream_version(version, revision);
let answer = ask_text("Upstream version", &default, validate)?; let answer = ask_text(prompter, "Upstream version", &default, validate)?;
cli.upstream_version = Some(answer.clone()); cli.upstream_version = Some(answer.clone());
// The typed version names an existing tag HEAD is not on: offer to // The typed version names an existing tag HEAD is not on: offer to
@@ -279,7 +279,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
"Version {answer} matches tag {tag}, but HEAD is not that \ "Version {answer} matches tag {tag}, but HEAD is not that \
tag. Check out {tag} now?" tag. Check out {tag} now?"
); );
if prompt::confirm(&question, false)? { if prompter.confirm(&question, false)? {
let dir = packaged_dir.as_deref().expect("origin implies a directory"); let dir = packaged_dir.as_deref().expect("origin implies a directory");
crate::new::origin::checkout_tag(dir, tag)?; crate::new::origin::checkout_tag(dir, tag)?;
log::info!( log::info!(
@@ -311,7 +311,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else { } else {
"Snapshot this working tree".to_string() "Snapshot this working tree".to_string()
}; };
let answer = select_from(ORIG_LABEL, &labels, &default, |answer| { let answer = select_from(prompter, ORIG_LABEL, &labels, &default, |answer| {
labels.contains(&answer.to_string()) labels.contains(&answer.to_string())
})?; })?;
let chosen = choices let chosen = choices
@@ -322,14 +322,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
cli.orig_from = Some(chosen.to_string()); cli.orig_from = Some(chosen.to_string());
if chosen == "path" { if chosen == "path" {
let validator = |path: &str| options::validate_orig_path(path); let validator = |path: &str| options::validate_orig_path(path);
let path = prompt::text("Tarball path or URL", "", Some(&validator))?; let path = prompter.text("Tarball path or URL", "", Some(&validator))?;
cli.orig_path = Some(path); cli.orig_path = Some(path);
} }
} }
// 5. Debian revision. // 5. Debian revision.
if cli.revision.is_none() { if cli.revision.is_none() {
let answer = ask_text("Debian revision", "1", validate_revision_answer)?; let answer = ask_text(prompter, "Debian revision", "1", validate_revision_answer)?;
cli.revision = answer.parse::<u32>().ok(); cli.revision = answer.parse::<u32>().ok();
} }
@@ -340,7 +340,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.and_then(|p| p.description.clone()) .and_then(|p| p.description.clone())
.unwrap_or_default(); .unwrap_or_default();
let validate = required_answer("the description"); let validate = required_answer("the description");
let answer = ask_text("One-line description", &default, validate)?; let answer = ask_text(prompter, "One-line description", &default, validate)?;
cli.description = Some(answer); cli.description = Some(answer);
} }
@@ -357,7 +357,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
options::validate_homepage(url) options::validate_homepage(url)
} }
}; };
let answer = ask_text("Homepage (blank to skip)", &default, validate)?; let answer = ask_text(prompter, "Homepage (blank to skip)", &default, validate)?;
if !answer.is_empty() { if !answer.is_empty() {
cli.homepage = Some(answer); cli.homepage = Some(answer);
} }
@@ -373,12 +373,17 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.or_else(|| detect::sniff_license(&detect_dir)); .or_else(|| detect::sniff_license(&detect_dir));
let (default, custom_default) = license_question_default(detected.as_deref()); let (default, custom_default) = license_question_default(detected.as_deref());
let options = license_menu(); let options = license_menu();
let answer = select_from(LICENSE_LABEL, &options, &default, |answer| { let answer = select_from(prompter, LICENSE_LABEL, &options, &default, |answer| {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
if answer == LICENSE_OTHER { if answer == LICENSE_OTHER {
let validate = required_answer("the license identifier"); let validate = required_answer("the license identifier");
let license = ask_text("License (SPDX identifier)", &custom_default, validate)?; let license = ask_text(
prompter,
"License (SPDX identifier)",
&custom_default,
validate,
)?;
cli.license = Some(license); cli.license = Some(license);
} else { } else {
cli.license = Some(answer); cli.license = Some(answer);
@@ -395,7 +400,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.as_ref() .as_ref()
.and_then(|p| p.command.clone()) .and_then(|p| p.command.clone())
.unwrap_or_else(|| cli.name.clone().unwrap_or_default()); .unwrap_or_else(|| cli.name.clone().unwrap_or_default());
let command = ask_text("Command name", &default, options::validate_command)?; let command = ask_text(
prompter,
"Command name",
&default,
options::validate_command,
)?;
cli.command = Some(command); cli.command = Some(command);
} }
@@ -421,13 +431,13 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
); );
} }
let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ()); let validate = |answer: &str| options::parse_maintainer(answer).map(|_| ());
cli.maintainer = Some(ask_text("Maintainer", &default, validate)?); cli.maintainer = Some(ask_text(prompter, "Maintainer", &default, validate)?);
} }
// 11. Target distribution. The menu derives from the distro data pkh // 11. Target distribution. The menu derives from the distro data pkh
// ships (sorted); ubuntu is moved to the front when present so it // ships (sorted); ubuntu is moved to the front when present so it
// stays the menu's first entry and fallback default as it has always // stays the menu's first entry and fallback default as it has always
// been — prompt::select positions on a default value, not an index. // been — the selector positions on a default value, not an index.
if cli.dist.is_none() { if cli.dist.is_none() {
let vendor = crate::build::env::current_vendor().to_lowercase(); let vendor = crate::build::env::current_vendor().to_lowercase();
let mut options = crate::distro_info::supported_dists(); let mut options = crate::distro_info::supported_dists();
@@ -442,7 +452,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else { } else {
"ubuntu".to_string() "ubuntu".to_string()
}; };
let answer = select_from(DIST_LABEL, &options, &default, |answer| { let answer = select_from(prompter, DIST_LABEL, &options, &default, |answer| {
options.contains(&answer.to_string()) options.contains(&answer.to_string())
})?; })?;
cli.dist = Some(answer); cli.dist = Some(answer);
@@ -456,7 +466,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if cli.series.is_none() { if cli.series.is_none() {
match crate::distro_info::get_ordered_series_name(&dist).await { match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(series) if !series.is_empty() => { Ok(series) if !series.is_empty() => {
let answer = prompt::select(SERIES_LABEL, &series, &series[0])?; let answer = prompter.select(SERIES_LABEL, &series, &series[0])?;
cli.series = Some(answer); cli.series = Some(answer);
} }
_ => { _ => {
@@ -472,6 +482,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if template == TemplateId::EMPTY && cli.depends.is_empty() { if template == TemplateId::EMPTY && cli.depends.is_empty() {
let validate = |answer: &str| options::validate_depends(answer).map(|_| ()); let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
let answer = ask_text( let answer = ask_text(
prompter,
"Depends (metapackage, comma-separated, blank for an empty base)", "Depends (metapackage, comma-separated, blank for an empty base)",
"", "",
validate, validate,
@@ -494,7 +505,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// `--no-git` already declined, or there is nothing to initialize. // `--no-git` already declined, or there is nothing to initialize.
cli.git = false; cli.git = false;
} else { } else {
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?; cli.git = prompter.confirm("Initialize a git repository?", cli.git)?;
} }
// Resolve through the same pipeline as the non-interactive path: one // Resolve through the same pipeline as the non-interactive path: one
@@ -505,7 +516,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// build resolve libraries through pkg-config? The project files prefill // build resolve libraries through pkg-config? The project files prefill
// the default (dependency() / pkg_check_modules calls found). // the default (dependency() / pkg_check_modules calls found).
if matches!(template, TemplateId::MESON | TemplateId::CMAKE) if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
&& prompt::confirm( && prompter.confirm(
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?", "Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
pkg_config_hint(&detect_dir, template), pkg_config_hint(&detect_dir, template),
)? )?
@@ -515,7 +526,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// Wizard-only extras (default off). // Wizard-only extras (default off).
if template != TemplateId::EMPTY if template != TemplateId::EMPTY
&& prompt::confirm( && prompter.confirm(
"Add an autopkgtest smoke test (debian/tests/control)?", "Add an autopkgtest smoke test (debian/tests/control)?",
false, false,
)? )?
@@ -523,15 +534,15 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
opts.autopkgtest = true; opts.autopkgtest = true;
} }
if let Some(watch) = watch_template(opts.homepage.as_deref()) if let Some(watch) = watch_template(opts.homepage.as_deref())
&& prompt::confirm("Add a debian/watch release watcher?", false)? && prompter.confirm("Add a debian/watch release watcher?", false)?
{ {
opts.watch = Some(watch); opts.watch = Some(watch);
} }
// Summary screen + final confirmation: Ctrl+C or 'n' abort with // Summary screen + final confirmation: Ctrl+C or 'n' abort with
// nothing written (generation is all-or-nothing later anyway). // nothing written (generation is all-or-nothing later anyway).
println!("{}", summary_text(&opts, toolchain_pin.as_deref())); prompter.present(&summary_text(&opts, toolchain_pin.as_deref()));
if !prompt::confirm("Generate?", true)? { if !prompter.confirm("Generate?", true)? {
return Err("Aborted: nothing was written to disk.".into()); return Err("Aborted: nothing was written to disk.".into());
} }
@@ -553,9 +564,10 @@ pub async fn offer_verification(
outcome: &ScaffoldOutcome, outcome: &ScaffoldOutcome,
multi: &MultiProgress, multi: &MultiProgress,
no_verify: bool, no_verify: bool,
prompter: &dyn Prompter,
) { ) {
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default()); let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
let display = crate::ui::display_path(&tree); let display = crate::report::display_path(&tree);
let display = if display.is_empty() { let display = if display.is_empty() {
".".to_string() ".".to_string()
} else { } else {
@@ -565,7 +577,7 @@ pub async fn offer_verification(
if outcome.vendoring_failed { if outcome.vendoring_failed {
// Set apart from the surrounding success output by blank lines: a // Set apart from the surrounding success output by blank lines: a
// single warning between two success lines is easy to miss. // single warning between two success lines is easy to miss.
println!(); prompter.present("");
log::warn!( log::warn!(
"The Cargo dependencies could NOT be vendored: this package will \ "The Cargo dependencies could NOT be vendored: this package will \
not build until the vendoring is completed by hand:\n\ not build until the vendoring is completed by hand:\n\
@@ -573,10 +585,10 @@ pub async fn offer_verification(
\x20 2. add the printed source replacement to .cargo/config.toml, \ \x20 2. add the printed source replacement to .cargo/config.toml, \
plus `[net] offline = true`" plus `[net] offline = true`"
); );
println!(); prompter.present("");
} }
if no_verify || !is_interactive() { if no_verify || !prompter.interactive() {
return; return;
} }
@@ -585,7 +597,7 @@ pub async fn offer_verification(
} else { } else {
"Verify with `pkh build` now?" "Verify with `pkh build` now?"
}; };
let verify_source = match prompt::confirm(build_offer, !outcome.vendoring_failed) { let verify_source = match prompter.confirm(build_offer, !outcome.vendoring_failed) {
Ok(answer) => answer, Ok(answer) => answer,
Err(_) => return, Err(_) => return,
}; };
@@ -593,12 +605,13 @@ pub async fn offer_verification(
return; return;
} }
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi))); let ui = std::sync::Arc::new(crate::ui::deb::DebUi::new(multi));
if let Err(e) = crate::build::build_source_package( if let Err(e) = crate::build::build_source_package(crate::build::BuildSourceOptions {
Some(&tree), source: Some(tree.clone()),
crate::build::SourceBuildOptions::default(), options: crate::build::SourceBuildOptions::default(),
ui, view: &*ui,
) { prompter,
}) {
log::error!("Verification source build failed: {e}"); log::error!("Verification source build failed: {e}");
log::info!( log::info!(
"The scaffolded tree is intact. Inspect it, then retry with \ "The scaffolded tree is intact. Inspect it, then retry with \
@@ -612,7 +625,7 @@ pub async fn offer_verification(
return; return;
} }
let verify_deb = match prompt::confirm( let verify_deb = match prompter.confirm(
"Verify with `pkh deb` now? (needs network + build deps)", "Verify with `pkh deb` now? (needs network + build deps)",
false, false,
) { ) {
@@ -623,20 +636,13 @@ pub async fn offer_verification(
return; return;
} }
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi))); let view = crate::ui::deb::DebUi::new(multi);
if let Err(e) = crate::deb::build_binary_package( if let Err(e) = crate::deb::build_binary_package(crate::deb::DebBuildOptions {
None, series: Some(opts.series.clone()),
Some(&opts.series), cwd: Some(tree.clone()),
None, view: &view,
Some(&tree), ..Default::default()
false, })
None,
None,
None,
None,
ui,
None,
)
.await .await
{ {
log::error!("Verification binary build failed: {e}"); log::error!("Verification binary build failed: {e}");
@@ -803,9 +809,13 @@ fn license_question_default(probe_license: Option<&str>) -> (String, String) {
/// Ask the language question until a known template label (or CLI /// Ask the language question until a known template label (or CLI
/// identifier) is answered — the selector allows typing arbitrary text. /// identifier) is answered — the selector allows typing arbitrary text.
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> { fn select_template(
prompter: &dyn Prompter,
options: &[String],
default: &str,
) -> Result<TemplateId, Box<dyn Error>> {
loop { loop {
let answer = prompt::select(LANGUAGE_LABEL, options, default)?; let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
match TemplateId::from_label(&answer) { match TemplateId::from_label(&answer) {
Some(id) => return Ok(id), Some(id) => return Ok(id),
None => log::warn!( None => log::warn!(
@@ -819,13 +829,14 @@ fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<
/// Ask a `select` question until `accept` holds for the answer (the /// Ask a `select` question until `accept` holds for the answer (the
/// selector allows typing arbitrary text, which callers may need to reject). /// selector allows typing arbitrary text, which callers may need to reject).
fn select_from( fn select_from(
prompter: &dyn Prompter,
label: &str, label: &str,
options: &[String], options: &[String],
default: &str, default: &str,
accept: impl Fn(&str) -> bool, accept: impl Fn(&str) -> bool,
) -> Result<String, Box<dyn Error>> { ) -> Result<String, Box<dyn Error>> {
loop { loop {
let answer = prompt::select(label, options, default)?; let answer = prompter.select(label, options, default)?;
if accept(&answer) { if accept(&answer) {
return Ok(answer); return Ok(answer);
} }
@@ -838,7 +849,7 @@ fn select_from(
/// upstream version like `1.0-2` carrying a Debian revision) is withheld — /// upstream version like `1.0-2` carrying a Debian revision) is withheld —
/// the question is asked without a default instead of offering one that /// the question is asked without a default instead of offering one that
/// Enter would accept verbatim. /// Enter would accept verbatim.
fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a str { fn offered_default<'a>(default: &'a str, validate: &Validator) -> &'a str {
if default.is_empty() || validate(default).is_ok() { if default.is_empty() || validate(default).is_ok() {
default default
} else { } else {
@@ -850,11 +861,7 @@ fn offered_default<'a>(default: &'a str, validate: &prompt::Validator) -> &'a st
/// `default`, and whatever answer is finally proposed — typed or the /// `default`, and whatever answer is finally proposed — typed or the
/// default — must pass `validate`. `Err` carries the validation error so the /// default — must pass `validate`. `Err` carries the validation error so the
/// caller re-asks with it. /// caller re-asks with it.
fn accept_answer( fn accept_answer(answer: &str, default: &str, validate: &Validator) -> Result<String, String> {
answer: &str,
default: &str,
validate: &prompt::Validator,
) -> Result<String, String> {
let answer = if answer.is_empty() { default } else { answer }; let answer = if answer.is_empty() { default } else { answer };
validate(answer).map(|_| answer.to_string()) validate(answer).map(|_| answer.to_string())
} }
@@ -868,6 +875,7 @@ fn accept_answer(
/// validation error) — probe data can never bypass validation and only blow /// validation error) — probe data can never bypass validation and only blow
/// up later in [`options::resolve`]. /// up later in [`options::resolve`].
fn ask_text( fn ask_text(
prompter: &dyn Prompter,
label: &str, label: &str,
default: &str, default: &str,
validate: impl Fn(&str) -> Result<(), String> + 'static, validate: impl Fn(&str) -> Result<(), String> + 'static,
@@ -889,7 +897,7 @@ fn ask_text(
} }
}; };
loop { loop {
let answer = prompt::text(label, default, Some(&accept_empty))?; let answer = prompter.text(label, default, Some(&accept_empty))?;
// Typed non-empty answers were already validated by the prompt; only // Typed non-empty answers were already validated by the prompt; only
// an empty one resolves to the default, pre-decided above. // an empty one resolves to the default, pre-decided above.
let answer = if answer.is_empty() { let answer = if answer.is_empty() {
@@ -1551,7 +1559,7 @@ mod tests {
#[test] #[test]
fn select_labels_carry_their_own_separator() { fn select_labels_carry_their_own_separator() {
// prompt::select renders `> <label><answer>` verbatim; a label // the selector renders `> <label><answer>` verbatim; a label
// without a trailing separator glues the answer to the prompt // without a trailing separator glues the answer to the prompt
// (regression: the wizard once rendered "> LicenseMIT"). // (regression: the wizard once rendered "> LicenseMIT").
for label in SELECT_LABELS { for label in SELECT_LABELS {
+33
View File
@@ -9,6 +9,22 @@ use crate::apt::release::{self, VerifiedRelease};
use crossterm::style::Stylize; use crossterm::style::Stylize;
use log::{debug, warn}; use log::{debug, warn};
/// Split a PPA reference into its `(user, name)` parts
///
/// A PPA is written `user/ppa_name` (e.g. `user/my-ppa`); anything else —
/// more segments, empty parts — is a format error carrying the canonical
/// message.
pub fn split_ppa(ppa: &str) -> Result<(&str, &str), String> {
let parts: Vec<&str> = ppa.split('/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
Ok((parts[0], parts[1]))
} else {
Err(format!(
"Invalid PPA format: '{ppa}'. Expected: user/ppa_name"
))
}
}
/// Convert a PPA specification to a base URL /// Convert a PPA specification to a base URL
/// ///
/// # Arguments /// # Arguments
@@ -943,6 +959,23 @@ pub async fn lookup(
mod tests { mod tests {
use super::*; use super::*;
/// `user/ppa_name` splits into its two parts.
#[test]
fn split_ppa_parses_the_canonical_form() {
assert_eq!(split_ppa("user/my-ppa"), Ok(("user", "my-ppa")));
}
/// Anything but exactly two non-empty segments is rejected, with the
/// canonical message.
#[test]
fn split_ppa_rejects_malformed_references() {
for bad in ["", "user", "user/", "/ppa", "a/b/c"] {
let err = split_ppa(bad).unwrap_err();
assert!(err.contains("Invalid PPA format"), "{err}");
assert!(err.contains(bad), "{err}");
}
}
/// Serve canned byte responses on a local port, one per connection (the /// Serve canned byte responses on a local port, one per connection (the
/// last response repeats), and return the base URL /// last response repeats), and return the base URL
/// ///
+41 -82
View File
@@ -18,7 +18,6 @@ pub mod target;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use indicatif::{MultiProgress, ProgressBar};
use log::info; use log::info;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -27,10 +26,10 @@ use crate::debian::checksums::FileChecksums;
use crate::debian::control::ControlInfo; use crate::debian::control::ControlInfo;
use crate::debian::version::DebianVersion; use crate::debian::version::DebianVersion;
use crate::launchpad; use crate::launchpad;
use crate::ui; use crate::report::{BuildTarget, BuildView, Prompter};
/// Everything `put` needs to run. /// Everything `put` needs to run.
pub struct PutOptions { pub struct PutOptions<'a> {
/// PPA to upload to, `user/ppa_name` format. /// PPA to upload to, `user/ppa_name` format.
pub ppa: String, pub ppa: String,
/// Explicit `.changes` file to upload; when `None`, the one matching the /// Explicit `.changes` file to upload; when `None`, the one matching the
@@ -42,14 +41,25 @@ pub struct PutOptions {
pub force: bool, pub force: bool,
/// Source package directory (the one containing `debian/`). /// Source package directory (the one containing `debian/`).
pub cwd: PathBuf, pub cwd: PathBuf,
/// Where the upload progress (status messages, per-file byte counts) is
/// reported.
pub view: &'a dyn BuildView,
/// Who answers the host-key question on first contact with the target
/// server (fail-closed when nobody can be asked).
pub prompter: &'a dyn Prompter,
} }
/// Upload the package described by `opts` to its target through `multi`'s /// Upload the package described by `opts` to its target, reporting progress
/// progress bars. /// through the view and asking the prompter when the server is unknown.
pub async fn put( pub async fn put(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error>> {
opts: &PutOptions, // The display is released on every path (success and early `?` bails)
multi: &MultiProgress, // before the outcome is logged, so no stale status line lingers above it
) -> Result<(), Box<dyn std::error::Error>> { let result = put_impl(opts).await;
opts.view.suspend();
result
}
async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error>> {
let target = launchpad::ppa_target(&opts.ppa)?; let target = launchpad::ppa_target(&opts.ppa)?;
let ssh_config = ssh::lookup_ssh_config(&target.fqdn); let ssh_config = ssh::lookup_ssh_config(&target.fqdn);
@@ -79,21 +89,20 @@ pub async fn put(
}; };
let changes = changes::parse(&changes_path)?; let changes = changes::parse(&changes_path)?;
// The summary line stays up for the whole flow: the completion message // The summary line stays up for the whole flow: rendered as the view's
// replaces it on success, and the `PutBars` guard clears it on every // persistent status, with the per-step messages below it
// early `?` bail so the error logged by main is not preceded by stale opts.view.target(BuildTarget {
// bars package: &changes.source,
let mut bars = PutBars::default(); version: &changes.version,
let summary = multi.add(ProgressBar::new(0)); target: &target.label,
// Style and prefix go in before the steady tick: otherwise the first display: format!(
// tick can render one frame with the default bar template
summary.set_style(ui::spinner_style());
summary.set_prefix(format!(
"Uploading {} {} to {}", "Uploading {} {} to {}",
changes.source, changes.version, target.label changes.source, changes.version, target.label
)); ),
summary.enable_steady_tick(TICK); source_only: false,
bars.track(summary.clone()); // An upload runs no subprocess: nothing to tee
tee_log: false,
});
changes::validate(&changes)?; changes::validate(&changes)?;
// Pre-flight checks for everything the upload queue only rejects after // Pre-flight checks for everything the upload queue only rejects after
@@ -109,14 +118,10 @@ pub async fn put(
), ),
} }
let checking = multi.add(ProgressBar::new(0)); opts.view
checking.set_style(ui::spinner_style()); .message(&format!("Checking {} on Launchpad...", target.label));
checking.set_prefix(format!("Checking {} on Launchpad...", target.label));
checking.enable_steady_tick(TICK);
bars.track(checking.clone());
launchpad::ppa_info(&opts.ppa).await?; launchpad::ppa_info(&opts.ppa).await?;
launchpad::check_ppa_series(&changes.distribution).await?; launchpad::check_ppa_series(&changes.distribution).await?;
clear_bar(&checking);
// Last pre-flight check: a version the PPA already publishes at or // Last pre-flight check: a version the PPA already publishes at or
// above the changes' one would supersede (or reject) this upload // above the changes' one would supersede (or reject) this upload
@@ -134,14 +139,10 @@ pub async fn put(
.into()); .into());
} }
let connecting = multi.add(ProgressBar::new(0)); opts.view
connecting.set_style(ui::spinner_style()); .message(&format!("Connecting to {login}@{host}:{port}..."));
connecting.set_prefix(format!("Connecting to {login}@{host}:{port}...")); let session = ssh::connect(&host, port, &login, &ssh_config, opts.prompter)?;
connecting.enable_steady_tick(TICK);
bars.track(connecting.clone());
let session = ssh::connect(&host, port, &login, &ssh_config)?;
let sftp = ssh::sftp(&session)?; let sftp = ssh::sftp(&session)?;
clear_bar(&connecting);
// Payload first, the .changes file last (like dput), so the server-side // Payload first, the .changes file last (like dput), so the server-side
// queue processor can never pick up an incomplete upload // queue processor can never pick up an incomplete upload
@@ -179,15 +180,11 @@ pub async fn put(
return Err(error); return Err(error);
} }
}; };
// Same transfer view as pull: prefix line, bar on its own line
let bar = multi.add(ProgressBar::new(size));
bar.enable_steady_tick(std::time::Duration::from_millis(50));
bar.set_style(ui::transfer_style());
bar.set_prefix(format!("Uploading {name}..."));
let remote = format!("{incoming}/{name}"); let remote = format!("{incoming}/{name}");
let result = ssh::upload_file(&sftp, path, &remote, &host, &bar); let label = format!("Uploading {name}");
bar.finish_and_clear(); let view = opts.view;
let on_progress = |uploaded: u64| view.progress(&label, uploaded as usize, size as usize);
let result = ssh::upload_file(&sftp, path, &remote, &host, &on_progress);
if let Err(e) = result { if let Err(e) = result {
// The failed file itself joins the cleanup: its remote `create` // The failed file itself joins the cleanup: its remote `create`
// may have succeeded before the failure, leaving a partial — or, // may have succeeded before the failure, leaving a partial — or,
@@ -204,9 +201,6 @@ pub async fn put(
// replaying is safe). // replaying is safe).
record_upload(&upload_log_path()?, &record)?; record_upload(&upload_log_path()?, &record)?;
// The completion lines replace the summary bar; the guard's own clear
// at scope exit is a no-op for the already-finished bars
clear_bar(&summary);
info!( info!(
"Upload of {} {} to {} complete.", "Upload of {} {} to {} complete.",
changes.source, changes.version, target.label changes.source, changes.version, target.label
@@ -260,41 +254,6 @@ fn cleanup_partial_upload(
} }
} }
/// Steady tick interval of every bar rendered by a `put` run
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
/// Stop `bar`'s steady tick and clear it from the terminal. The tick is
/// disabled first: a tick firing right after the clear would redraw a stale
/// frame, the race [`crate::ui::deb::DebUi::suspend`] guards against too.
/// Clearing twice is harmless: finished bars stay finished.
fn clear_bar(bar: &ProgressBar) {
bar.disable_steady_tick();
bar.finish_and_clear();
}
/// The bars rendered by one `put` run, cleared when the guard drops. The
/// error paths bail out early through `?` and `main` logs the error
/// afterwards, so a bar left unfinished would linger on screen above it.
#[derive(Default)]
struct PutBars {
bars: Vec<ProgressBar>,
}
impl PutBars {
/// Track `bar` so it is cleared with the rest when the guard drops
fn track(&mut self, bar: ProgressBar) {
self.bars.push(bar);
}
}
impl Drop for PutBars {
fn drop(&mut self) {
for bar in &self.bars {
clear_bar(bar);
}
}
}
/// Validate the source package's `Section` (debian/control source stanza) /// Validate the source package's `Section` (debian/control source stanza)
/// against the distribution's valid sections: a bare section or a /// against the distribution's valid sections: a bare section or a
/// `section/subsection` is accepted. Archives reject uploads carrying an /// `section/subsection` is accepted. Archives reject uploads carrying an
@@ -521,7 +480,7 @@ fn discover_changes(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
Pass the one to upload explicitly", Pass the one to upload explicitly",
entry.source, entry.source,
many.iter() many.iter()
.map(|p| ui::display_path(p)) .map(|p| crate::report::display_path(p))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
) )
+9 -11
View File
@@ -20,14 +20,13 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use indicatif::ProgressBar;
use log::debug; use log::debug;
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session}; use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session};
use crate::data::embed_data; use crate::data::embed_data;
use crate::ui::prompt; use crate::report::Prompter;
/// Pinned SSH host key fingerprints, loaded from the bundled /// Pinned SSH host key fingerprints, loaded from the bundled
/// `host_keys.yml` data file (same pattern as `distro_info.yml`): data /// `host_keys.yml` data file (same pattern as `distro_info.yml`): data
@@ -303,6 +302,7 @@ pub fn connect(
port: u16, port: u16,
login: &str, login: &str,
config: &SshConfig, config: &SshConfig,
prompter: &dyn Prompter,
) -> Result<Session, Box<dyn std::error::Error>> { ) -> Result<Session, Box<dyn std::error::Error>> {
let tcp = tcp_connect(host, port)?; let tcp = tcp_connect(host, port)?;
@@ -326,7 +326,7 @@ pub fn connect(
let (key, key_type) = session let (key, key_type) = session
.host_key() .host_key()
.ok_or_else(|| format!("{host} offered no host key"))?; .ok_or_else(|| format!("{host} offered no host key"))?;
verify_host_key(host, port, key, key_type)?; verify_host_key(host, port, key, key_type, prompter)?;
authenticate(&session, host, login, config)?; authenticate(&session, host, login, config)?;
@@ -353,6 +353,7 @@ fn verify_host_key(
port: u16, port: u16,
key: &[u8], key: &[u8],
key_type: HostKeyType, key_type: HostKeyType,
prompter: &dyn Prompter,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let fingerprint = fingerprint(key); let fingerprint = fingerprint(key);
@@ -387,12 +388,7 @@ fn verify_host_key(
format!("[{host}]:{port}") format!("[{host}]:{port}")
}; };
// The banner is plain output: the confirmation prompt itself if !prompter.accept_host_key(&display, key_type_desc, &fingerprint) {
// must stay a single line for its redraw logic
println!("The authenticity of host '{display}' can't be established.");
println!("{key_type_desc} key fingerprint is {fingerprint}.");
let accepted = prompt::confirm("Accept and store this host key?", false)?;
if !accepted {
return Err(format!("Host key for {display} rejected, aborting upload").into()); return Err(format!("Host key for {display} rejected, aborting upload").into());
} }
if let Some(name) = key_type_name(key_type) { if let Some(name) = key_type_name(key_type) {
@@ -592,7 +588,7 @@ pub fn upload_file(
local: &Path, local: &Path,
remote: &str, remote: &str,
host: &str, host: &str,
bar: &ProgressBar, on_progress: &dyn Fn(u64),
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut local_file = let mut local_file =
fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?; fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?;
@@ -602,6 +598,7 @@ pub fn upload_file(
.map_err(|e| format!("cannot create remote file {remote} on {host}: {e}"))?; .map_err(|e| format!("cannot create remote file {remote} on {host}: {e}"))?;
let mut buf = [0u8; 32 * 1024]; let mut buf = [0u8; 32 * 1024];
let mut uploaded: u64 = 0;
loop { loop {
let n = local_file.read(&mut buf)?; let n = local_file.read(&mut buf)?;
if n == 0 { if n == 0 {
@@ -610,7 +607,8 @@ pub fn upload_file(
remote_file remote_file
.write_all(&buf[..n]) .write_all(&buf[..n])
.map_err(|e| format!("failed uploading to {remote} on {host}: {e}"))?; .map_err(|e| format!("failed uploading to {remote} on {host}: {e}"))?;
bar.inc(n as u64); uploaded += n as u64;
on_progress(uploaded);
} }
// Close explicitly: quota-exceeded and similar failures only surface in // Close explicitly: quota-exceeded and similar failures only surface in
+207
View File
@@ -0,0 +1,207 @@
//! 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).
//! Asking can fail (the user cancels, the connection drops); flows
//! propagate the error, which aborts them.
use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::context::LineSink;
use crate::logfmt::Classifier;
/// Answer validator of [`Prompter::text`]: accepts the answer, or explains
/// why it is rejected (the implementation re-asks with the explanation).
pub type Validator = dyn Fn(&str) -> Result<(), String>;
/// Identity of the build whose events follow, as announced through
/// [`BuildView::target`].
#[derive(Debug, Clone)]
pub struct BuildTarget<'a> {
/// Source package name (e.g. `hello`).
pub package: &'a str,
/// Full version being built (e.g. `2.10-3`).
pub version: &'a str,
/// What the build targets: a distribution series, optionally with an
/// architecture (`noble`, `sid`, `noble/arm64`), or the upload target
/// label.
pub target: &'a str,
/// Ready-to-render status line for display adapters, composed by the
/// flow (e.g. "Building source package hello (2.10-3) for unstable",
/// "Uploading hello (2.10-3) to ppa:user/ppa"): the wording is the
/// flow's, adapters render it verbatim.
pub display: String,
/// Whether this is a source-only build (producing a `.dsc`); names the
/// terminal adapter's tee log (`build-*.log` vs `deb-*.log`).
pub source_only: bool,
/// Whether the view should tee raw subprocess output to its log file.
/// Flows without subprocess output (uploads) pass `false` and create
/// no log file.
pub tee_log: bool,
}
/// Observer of a running build: target identification, phases, status
/// messages, progress and the final outcome.
///
/// Implement this to observe the [`crate::build`] and [`crate::deb`] 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; the events that follow belong to it
/// (including the earliest subprocess output, e.g. a chroot download).
fn target(&self, _target: BuildTarget) {}
/// 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<dyn Classifier>) {}
/// 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<Arc<dyn LineSink>> {
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) {}
/// Release the display before writing directly to the shared terminal
/// (passthrough diagnostics, cleanup commands that inherit it). The
/// release is final for this build; later events may be dropped. A
/// no-op for views without a display.
fn suspend(&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.
///
/// 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 {
/// Whether this prompter can interact with a user at all. Flows with an
/// interactive and a headless path use this to pick one: a headless run
/// takes the defaults or fails with the list of missing answers instead
/// of asking question by question.
fn interactive(&self) -> bool {
false
}
/// Ask a yes/no question. `default` is the answer to take when no user
/// can be reached; `Err` means the question was cancelled (Ctrl+C,
/// dropped connection) and the flow should abort.
fn confirm(&self, _question: &str, default: bool) -> Result<bool, Box<dyn Error>> {
Ok(default)
}
/// Ask a one-line selection among `options`, with `default`
/// preselected. Selector implementations may also accept typed
/// arbitrary text; callers validate the answer and re-ask through the
/// same method when they must reject one. `Err` cancels the flow.
fn select(
&self,
_label: &str,
_options: &[String],
default: &str,
) -> Result<String, Box<dyn Error>> {
Ok(default.to_string())
}
/// Ask a free-text answer. `validate` is applied by the implementation
/// so invalid input re-asks at the source (mid-input for a terminal,
/// round-trip for a server). `Err` cancels the flow.
fn text(
&self,
_label: &str,
default: &str,
_validate: Option<&Validator>,
) -> Result<String, Box<dyn Error>> {
Ok(default.to_string())
}
/// Ask whether to accept and store an unverified SSH host key (trust on
/// first use): `host` is the display form (`host` or `[host]:port`),
/// `key_type` the key type name ("ssh-ed25519", ...) and `fingerprint`
/// the human-readable digest. Fail-closed: implementations that cannot
/// ask anyone answer `false`, refusing the connection.
fn accept_host_key(&self, _host: &str, _key_type: &str, _fingerprint: &str) -> bool {
false
}
/// Present information to the user outside of any question: the
/// scaffold wizard's summary screen, prominent notices around a
/// warning. Terminal implementations print the text as-is (unstyled,
/// on stdout); server implementations forward it as a display event.
/// Implementations that cannot show anything drop it — flows only
/// present context that is also available structurally (returned data,
/// log records).
fn present(&self, _text: &str) {}
}
/// 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 {}
/// Render a path for terminal display: relative to the current working
/// directory when the target lives inside it or directly next to it
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
///
/// Pure formatting shared by the flows that mention paths in their events
/// and messages; a remote frontend reproduces it (or not) on its side.
pub fn display_path(path: &Path) -> String {
let Ok(cwd) = std::env::current_dir() else {
return path.display().to_string();
};
if let Ok(rel) = path.strip_prefix(&cwd) {
return rel.display().to_string();
}
if let Some(parent) = cwd.parent()
&& let Ok(rel) = path.strip_prefix(parent)
{
return format!("../{}", rel.display());
}
path.display().to_string()
}
-21
View File
@@ -3,8 +3,6 @@
/// Live build view for `pkh deb` (status bar + rolling log pane) /// Live build view for `pkh deb` (status bar + rolling log pane)
pub mod deb; 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 /// Interactive raw-mode prompts: free-text input, option selection and
/// yes/no confirmation /// yes/no confirmation
pub mod prompt; pub mod prompt;
@@ -12,27 +10,8 @@ pub mod prompt;
#[cfg(test)] #[cfg(test)]
use indicatif::ProgressDrawTarget; use indicatif::ProgressDrawTarget;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::path::Path;
use std::time::Duration; use std::time::Duration;
/// Render a path for terminal display: relative to the current working
/// directory when the target lives inside it or directly next to it
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
pub fn display_path(path: &Path) -> String {
let Ok(cwd) = std::env::current_dir() else {
return path.display().to_string();
};
if let Ok(rel) = path.strip_prefix(&cwd) {
return rel.display().to_string();
}
if let Some(parent) = cwd.parent()
&& let Ok(rel) = path.strip_prefix(parent)
{
return format!("../{}", rel.display());
}
path.display().to_string()
}
/// Style of an unsized operation: spinner and prefix on one line /// Style of an unsized operation: spinner and prefix on one line
pub(crate) fn spinner_style() -> ProgressStyle { pub(crate) fn spinner_style() -> ProgressStyle {
ProgressStyle::default_bar() ProgressStyle::default_bar()
+80 -148
View File
@@ -3,7 +3,7 @@
//! ("a terminal in the terminal"). //! ("a terminal in the terminal").
//! //!
//! Subprocess output is captured through a [`LineSink`] implementation, //! 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 //! 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 //! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log
//! file under the pkh cache directory. //! file under the pkh cache directory.
@@ -21,10 +21,8 @@ use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use crate::context::{LineSink, Stream}; use crate::context::{LineSink, Stream};
use crate::ui::logfmt::{ use crate::logfmt::{Action, Classifier, GenericClassifier};
Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, use crate::report::BuildTarget;
MakeClassifier, MmdebstrapClassifier, QuiltClassifier,
};
/// Number of lines displayed in the rolling pane /// Number of lines displayed in the rolling pane
const PANE_LINES: usize = 10; const PANE_LINES: usize = 10;
@@ -32,69 +30,6 @@ const PANE_LINES: usize = 10;
/// Minimum interval between pane redraws /// Minimum interval between pane redraws
const REDRAW_INTERVAL: Duration = Duration::from_millis(50); 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 /// Visual kind of a pane line, driving its color
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
enum Kind { enum Kind {
@@ -129,9 +64,9 @@ struct Shared {
/// Live build view for `pkh deb` / `pkh build` /// Live build view for `pkh deb` / `pkh build`
/// ///
/// Create one per build (disabled automatically when stdout is not a TTY or /// Create one per build (it disables itself automatically when stdout is not
/// when the user requests verbose output), pass it down as /// a TTY) and pass it down through the [`crate::report::BuildView`] port.
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`]. /// Subprocess output reaches it through [`crate::report::BuildView::sink`].
pub struct DebUi { pub struct DebUi {
shared: Arc<Shared>, shared: Arc<Shared>,
} }
@@ -213,17 +148,6 @@ impl DebUi {
self.open_log("deb", package, version, &format!("for {series}/{arch}")); self.open_log("deb", package, version, &format!("for {series}/{arch}"));
} }
/// Identify the source package being built; names the log file
/// (`build-<package>-<version>-<timestamp>.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 /// Rename the placeholder log file to include the build identity
/// (best-effort), then open it so subsequent captured lines are tee'd /// (best-effort), then open it so subsequent captured lines are tee'd
fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) { fn open_log(&self, kind: &str, package: &str, version: &str, detail: &str) {
@@ -261,20 +185,8 @@ impl DebUi {
} }
} }
/// Switch to a phase, installing its default classifier /// Switch to an arbitrary status label with a custom classifier
pub fn phase(&self, phase: Phase) { fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
self.phase_custom(phase.label(), 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>) {
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<dyn Classifier>) {
{ {
let mut st = self.shared.state.lock().unwrap(); let mut st = self.shared.state.lock().unwrap();
st.classifier = classifier; st.classifier = classifier;
@@ -289,53 +201,18 @@ impl DebUi {
} }
} }
/// 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 /// Whether the widget is enabled and still drawn
fn active(&self) -> bool { fn active(&self) -> bool {
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst) self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
} }
/// Whether the widget renders at all (false on non-TTY stdout); callers /// Release the widget from the terminal (e.g. before printing
/// use this to fall back to plain-line summaries /// passthrough diagnostics or letting child cleanup commands write to
pub fn is_enabled(&self) -> bool { /// the terminal); idempotent
self.shared.enabled
}
/// 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 /// Steady ticks are disabled first: otherwise a tick can redraw a frame
/// right after the clear, leaving stale copies of the widget on screen. /// right after the clear, leaving stale copies of the widget on screen.
pub fn suspend(&self) { fn suspend(&self) {
if !self.shared.enabled { if !self.shared.enabled {
return; return;
} }
@@ -348,21 +225,21 @@ impl DebUi {
self.shared.pane.finish_and_clear(); self.shared.pane.finish_and_clear();
} }
/// Clear the widget and print a success summary with the artifacts, /// Success outcome body: clear the widget and print the artifacts,
/// rendered relative to the working directory when possible /// rendered relative to the working directory when possible
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) { fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) {
self.suspend(); self.suspend();
if self.shared.enabled && !artifacts.is_empty() { if self.shared.enabled && !artifacts.is_empty() {
println!("Built in {}s:", elapsed.as_secs()); println!("Built in {}s:", elapsed.as_secs());
for artifact in artifacts { for artifact in artifacts {
println!(" {}", crate::ui::display_path(artifact)); println!(" {}", crate::report::display_path(artifact));
} }
} }
} }
/// Clear the widget and print a failure summary (recent captured errors /// Failure outcome body: clear the widget and print a summary (recent
/// and the path to the full log) /// captured errors and the path to the full log)
pub fn finish_failure(&self) { fn failure_summary(&self) {
self.suspend(); self.suspend();
let st = self.shared.state.lock().unwrap(); let st = self.shared.state.lock().unwrap();
@@ -385,15 +262,70 @@ impl DebUi {
eprintln!("Full log: {}", log_path.display()); 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 /// [`crate::report::BuildView`] port: forwards build events to the live
pub fn log_path(&self) -> PathBuf { /// widget, so core flows drive the view without knowing it is a terminal
self.shared.log_path.lock().unwrap().clone() /// widget.
impl crate::report::BuildView for DebUi {
fn target(&self, target: BuildTarget<'_>) {
if self.shared.enabled {
self.shared.top.set_prefix(target.display.clone());
}
if target.tee_log {
let kind = if target.source_only { "build" } else { "deb" };
self.open_log(
kind,
target.package,
target.version,
&format!("for {}", target.target),
);
}
}
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
self.phase_custom(name, classifier);
}
fn message(&self, text: &str) {
if self.active() {
self.shared.top.set_message(text.to_string());
}
}
fn 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());
}
fn sink(&self) -> Option<Arc<dyn LineSink>> {
Some(Arc::new(Sink {
shared: self.shared.clone(),
}))
}
fn finish_success(&self, artifacts: &[PathBuf]) {
self.success_summary(artifacts, self.shared.started.elapsed());
}
fn finish_failure(&self) {
self.failure_summary();
}
fn suspend(&self) {
DebUi::suspend(self);
}
fn is_enabled(&self) -> bool {
self.shared.enabled
} }
} }
+50 -4
View File
@@ -12,7 +12,7 @@ use crossterm::{
style::{self, Color, Print, SetForegroundColor}, style::{self, Color, Print, SetForegroundColor},
terminal, terminal,
}; };
use std::io::{self, Write}; use std::io::{self, IsTerminal, Write};
use std::time::Duration; use std::time::Duration;
/// Why a prompt could not run interactively /// Why a prompt could not run interactively
@@ -32,9 +32,8 @@ impl std::fmt::Display for PromptError {
impl std::error::Error for PromptError {} impl std::error::Error for PromptError {}
/// Answer validator of [`text`]: accepts the answer, or explains why it is /// Answer validator of [`text`] (re-exported from [`crate::report`])
/// rejected pub use crate::report::Validator;
pub type Validator = dyn Fn(&str) -> Result<(), String>;
/// What a prompt's event loop asks its drawing helper to render; keeping all /// What a prompt's event loop asks its drawing helper to render; keeping all
/// terminal access behind this callback makes the loops pure logic that unit /// terminal access behind this callback makes the loops pure logic that unit
@@ -127,6 +126,53 @@ pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::E
} }
} }
/// [`crate::report::Prompter`] answered by the interactive terminal: each
/// question runs the matching raw-mode prompt. Without an interactive
/// terminal (CI, piped input) [`Prompter::interactive`] is false — flows
/// then take their headless path instead of asking — and a cancel (Ctrl+C)
/// propagates as `Err` so flows can abort.
pub struct TerminalPrompter;
impl crate::report::Prompter for TerminalPrompter {
fn interactive(&self) -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn confirm(&self, question: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
confirm(question, default)
}
fn select(
&self,
label: &str,
options: &[String],
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
select(label, options, default)
}
fn text(
&self,
label: &str,
default: &str,
validate: Option<&Validator>,
) -> Result<String, Box<dyn std::error::Error>> {
text(label, default, validate)
}
fn accept_host_key(&self, host: &str, key_type: &str, fingerprint: &str) -> bool {
// The banner is plain output: the confirmation prompt itself must
// stay a single line for its redraw logic
println!("The authenticity of host '{host}' can't be established.");
println!("{key_type} key fingerprint is {fingerprint}.");
confirm("Accept and store this host key?", false).unwrap_or(false)
}
fn present(&self, text: &str) {
println!("{text}");
}
}
/// Run `prompt` with the terminal in raw mode, always restoring it /// Run `prompt` with the terminal in raw mode, always restoring it
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be /// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
/// enabled. /// enabled.