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::error::Error;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
@@ -24,8 +23,8 @@ use crate::context::{LineSink, Stream};
use crate::debian::{
ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs,
};
use crate::ui::deb::DebUi;
use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier};
use crate::logfmt::{DpkgSourceClassifier, GenericClassifier};
use crate::report::{BuildTarget, BuildView, Prompter};
/// Whether the upload distributes the upstream orig tarballs (`--orig`),
/// mirroring the `dpkg-genchanges` source styles.
@@ -74,105 +73,118 @@ pub struct SourceBuildOutput {
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.
///
/// When `ui` is set, subprocess output is captured into a live view (status
/// bar + rolling pane) and tee'd to a log file; on failure the view prints a
/// summary of the last captured errors. Without a UI, commands inherit the
/// terminal as before.
/// Subprocess output is captured into the view (status line + rolling
/// pane for the terminal adapter) and tee'd to a log file; on failure the
/// view prints a summary of the last captured errors. Headless callers use
/// [`crate::report::Quiet`], in which case commands still run with captured
/// output in test builds.
///
/// A `dpkg-source -b` failure is classified (see
/// [`classify_dpkg_source_failure`]); when the vendored rust dependencies
/// diverged from the orig-vendor component and a terminal is attached, the
/// flow offers to re-vendor, recreate the component and retry the build
/// diverged from the orig-vendor component, the flow asks the prompter
/// whether to re-vendor, recreates the component and retries the build
/// exactly once.
///
/// On success the produced artifacts are reported through the view and
/// returned.
pub fn build_source_package(
cwd: Option<&Path>,
opts: SourceBuildOptions,
ui: Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
opts: BuildSourceOptions<'_>,
) -> Result<SourceBuildOutput, Box<dyn Error>> {
// Default to the process's current working directory, resolved to an
// absolute path: the output directory is derived from `cwd.parent()`
// downstream, which only yields a real directory for an absolute `cwd`
// (the parent of "." is the empty path).
let cwd = match cwd {
Some(p) => p.to_path_buf(),
let cwd = match opts.source {
Some(ref p) => p.clone(),
None => std::env::current_dir()
.map_err(|e| format!("cannot determine the current working directory: {e}"))?,
};
let output = match run_source_build(&cwd, &opts, ui.clone()) {
let output = match run_source_build(&cwd, &opts.options, opts.view) {
Ok(output) => output,
Err(e) if e.downcast_ref::<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) => {
if let Some(u) = &ui {
u.finish_failure();
}
opts.view.finish_failure();
return Err(e);
}
};
// Artifact listing in dpkg order: dsc → tarballs → buildinfo → changes.
let mut artifacts = Vec::with_capacity(3 + output.tarballs.len());
artifacts.push(output.dsc.clone());
artifacts.extend(output.tarballs.iter().cloned());
artifacts.push(output.buildinfo.clone());
artifacts.push(output.changes.clone());
// The live view lists the artifacts itself when it renders; otherwise
// (verbose mode or non-TTY stdout) print them as plain lines.
let listed = match &ui {
Some(u) if u.is_enabled() => {
u.finish_success(&artifacts, u.elapsed());
true
}
_ => false,
};
if !listed {
for artifact in &artifacts {
println!(" {}", crate::ui::display_path(artifact));
}
}
if output.signed {
println!("Package built and signed successfully!");
} else {
println!("Package built successfully (unsigned).");
}
Ok(())
opts.view.finish_success(&output.artifacts());
Ok(output)
}
/// The re-vendor retry hook for a [`VendorDriftError`]: on an interactive
/// terminal, offer to re-run the vendoring step (the same helper the rust
/// template uses at scaffold time), recreate the `orig-vendor` component
/// from the fresh `vendor/` tree and retry the source build exactly once.
/// Without a terminal (or on a declined offer) the original error is
/// returned untouched.
/// The re-vendor retry hook for a [`VendorDriftError`]: offer to re-run the
/// vendoring step through the prompter (the same helper the rust template
/// uses at scaffold time), recreate the `orig-vendor` component from the
/// fresh `vendor/` tree and retry the source build exactly once. Without an
/// accepting answer (headless prompters answer with the default, `false`)
/// the original error is returned untouched.
fn retry_after_revendor(
cwd: &Path,
ui: Option<Arc<DebUi>>,
opts: SourceBuildOptions,
view: &dyn BuildView,
prompter: &dyn Prompter,
opts: &SourceBuildOptions,
original: Box<dyn Error>,
) -> Result<(), Box<dyn Error>> {
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
if !interactive {
if let Some(u) = &ui {
u.finish_failure();
}
) -> Result<SourceBuildOutput, Box<dyn Error>> {
// The offer is only made when someone can answer it: headless prompters
// answer with the default (false) without the error being logged here —
// the caller logs the returned error itself, exactly once.
if !prompter.interactive() {
view.finish_failure();
return Err(original);
}
log::error!("{original}");
let retry = crate::ui::prompt::confirm(
if !prompter
.confirm(
"Re-vendor the Cargo dependencies and retry the build?",
false,
)
.unwrap_or(false);
if !retry {
if let Some(u) = &ui {
u.finish_failure();
}
.unwrap_or(false)
{
view.finish_failure();
return Err(original);
}
@@ -189,9 +201,7 @@ fn retry_after_revendor(
std::fs::remove_file(&config)?;
}
if !crate::new::templates::rust::vendor_dependencies(cwd)? {
if let Some(u) = &ui {
u.finish_failure();
}
view.finish_failure();
return Err(
"Re-vendoring did not complete: the tree is unchanged, fix the \
vendoring by hand and build again."
@@ -218,7 +228,7 @@ fn retry_after_revendor(
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
// 3. One retry, with the options of the original attempt.
run_source_build(cwd, &opts, ui).map(|_| ())
run_source_build(cwd, opts, view)
}
/// Run the full native source-build pipeline in `cwd`.
@@ -237,14 +247,12 @@ fn retry_after_revendor(
pub fn run_source_build(
cwd: &Path,
opts: &SourceBuildOptions,
ui: Option<Arc<DebUi>>,
view: &dyn BuildView,
) -> Result<SourceBuildOutput, Box<dyn Error>> {
// Without a live UI, test runs still capture command output into the
// per-test log file instead of letting it inherit the terminal
let sink: Option<Arc<dyn LineSink>> = ui
.as_ref()
.map(|u| u.sink())
.or_else(crate::test_support::subprocess_sink);
// The view consumes the captured lines itself (live view + tee log);
// without one, test runs still capture command output into the per-test
// log file instead of letting it inherit the terminal
let sink: Option<Arc<dyn LineSink>> = view.sink().or_else(crate::test_support::subprocess_sink);
// ------------------------------------------------------------------
// 1. Sanity checks
// ------------------------------------------------------------------
@@ -296,9 +304,19 @@ pub fn run_source_build(
let ctrl = ControlInfo::parse(&control_path)?;
if let Some(u) = &ui {
u.set_build_target(&entry.source, &entry.version.full(), &entry.distribution);
}
view.target(BuildTarget {
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();
@@ -357,9 +375,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------
// 5. dpkg-source lifecycle: before-build + source build
// ------------------------------------------------------------------
if let Some(u) = &ui {
u.phase_custom("Applying patches", Box::new(DpkgSourceClassifier::new()));
}
view.phase("Applying patches", Box::new(DpkgSourceClassifier::new()));
run_command(
cwd,
"dpkg-source",
@@ -372,9 +388,7 @@ pub fn run_source_build(
// dpkg-buildpackage skips it entirely for source-only builds unless
// forced with -D; unsatisfied dependencies abort with exit status 3.
if opts.force_dep_check {
if let Some(u) = &ui {
u.progress_message("Checking build dependencies");
}
view.message("Checking build dependencies");
let check_opts = crate::debian::deps::CheckOpts {
host_arch: arch_vars
.get("DEB_HOST_ARCH")
@@ -389,19 +403,19 @@ pub fn run_source_build(
};
let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?;
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(
report,
)));
}
}
if let Some(u) = &ui {
u.phase_custom(
view.phase(
"Building source package",
Box::new(DpkgSourceClassifier::new()),
);
}
if let Err(failure) = run_command_capturing(
cwd,
"dpkg-source",
@@ -427,9 +441,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
// ------------------------------------------------------------------
if let Some(u) = &ui {
u.progress_message("Generating .buildinfo");
}
view.message("Generating .buildinfo");
// What the .buildinfo itself records: like dpkg-genbuildinfo, only the
// referenced .dsc — not the tarballs, and never the buildinfo itself.
let mut buildinfo_checksums = FileChecksums::new();
@@ -479,9 +491,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------
// 7. .changes generation (native dpkg-genchanges equivalent)
// ------------------------------------------------------------------
if let Some(u) = &ui {
u.progress_message("Generating .changes");
}
view.message("Generating .changes");
// What the .changes distributes: the .dsc (with its recorded digests),
// the tarballs listed in it (below), and the .buildinfo (last, as
// dpkg-genchanges does when it consumes debian/files).
@@ -538,13 +548,11 @@ pub fn run_source_build(
if opts.orig_source == OrigSourceMode::Never && !has_debian_part {
log::warn!("ignoring --orig never for a native Debian package");
}
if let Some(u) = &ui {
u.progress_message(if strip_origs {
view.message(if strip_origs {
"Not including original source code in upload"
} else {
"Including full source code in upload"
});
}
// Stripped orig tarballs (and their detached .asc signatures) are not
// distributed at all: not hashed, not required on disk, like
// dpkg-genchanges.
@@ -634,9 +642,7 @@ pub fn run_source_build(
// ------------------------------------------------------------------
// 8. dpkg-source after-build (unapplies quilt patches it applied)
// ------------------------------------------------------------------
if let Some(u) = &ui {
u.phase_custom("Restoring patches", Box::new(DpkgSourceClassifier::new()));
}
view.phase("Restoring patches", Box::new(DpkgSourceClassifier::new()));
run_command(
cwd,
"dpkg-source",
@@ -652,9 +658,7 @@ pub fn run_source_build(
if let Some(keyid) = signing_key.filter(|_| do_sign) {
crate::utils::gpg::validate_key_id(&keyid)?;
if let Some(u) = &ui {
u.phase_custom("Signing artifacts", Box::new(GenericClassifier::new()));
}
view.phase("Signing artifacts", Box::new(GenericClassifier::new()));
log::info!("Signing {}", dsc_name);
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
@@ -1039,7 +1043,7 @@ mod tests {
.expect("write control");
std::fs::write(tree.join("debian/rules"), "#!/usr/bin/make -f\n").expect("write rules");
let err = run_source_build(&tree, &SourceBuildOptions::default(), None)
let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet)
.expect_err("binary-only entries must not build a source package");
let err = err.to_string();
assert!(err.contains("binary-only"), "{err}");
@@ -1575,7 +1579,8 @@ mod differential_tests {
OrigSourceMode::Never => &["-sd"],
};
run_dpkg(&golden_tree, source_style);
run_source_build(&ours_tree, opts, None).expect("native source pipeline failed");
run_source_build(&ours_tree, opts, &crate::report::Quiet)
.expect("native source pipeline failed");
let entry =
crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap();
@@ -2160,7 +2165,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
);
// Ours: the native pipeline refuses likewise.
let err = run_source_build(&tree, &SourceBuildOptions::default(), None)
let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet)
.expect_err("native source build must refuse a binary-only entry");
assert!(err.to_string().contains("binary-only"), "{err}");
}
+174 -7
View File
@@ -5,13 +5,30 @@ use std::fs::File;
use std::io::{Read, Write};
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
pub fn generate_entry(
changelog_file: &str,
cwd: Option<&Path>,
user_version: 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 {
path.join(changelog_file)
} else {
@@ -19,8 +36,7 @@ pub fn generate_entry(
};
// Parse existing changelog to get current (old) version
let (package, old_version, series) = parse_changelog_header(&changelog_path)?;
println!("Found package: {}, version: {}", package, old_version);
let (package, old_version, current_series) = parse_changelog_header(&changelog_path)?;
// Open git repo, and find commits since last version tag
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 series = target_series.unwrap_or(&series).to_string();
let series = target_series.unwrap_or(&current_series).to_string();
let new_entry = format_entry(
&package,
&new_version,
@@ -56,9 +72,13 @@ pub fn generate_entry(
prepend_to_file(&changelog_path, &new_entry)?;
println!("Added new changelog entry to {}", changelog_path.display());
Ok(())
Ok(GeneratedEntry {
package,
previous_version: old_version,
new_version,
series,
path: changelog_path,
})
}
/// 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))
}
/// 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
/// Returns (name, email) tuple from the last modification entry
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 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) {
Command::new("git")
.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
pub struct 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}"))
}
}
#[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}"))?;
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());
Ok(())
@@ -289,4 +303,25 @@ mod tests {
);
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::ui::deb::{DebUi, Phase};
use crate::deb::{Phase, enter_phase};
use crate::report::BuildView;
use directories::ProjectDirs;
use std::any::Any;
use std::error::Error;
@@ -312,7 +313,7 @@ impl EphemeralContextGuard {
series: &str,
arch: Option<&str>,
base_ctx: Arc<Context>,
ui: Option<Arc<DebUi>>,
view: &dyn BuildView,
) -> Result<Self, Box<dyn Error>> {
// Save the globally-installed context so Drop can restore exactly
// this handle: concurrent builds install their own ephemeral
@@ -355,7 +356,7 @@ impl EphemeralContextGuard {
// Download and extract the chroot tarball
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
{
// The guard (and its Drop) never materializes on this path, so
@@ -408,7 +409,7 @@ impl EphemeralContextGuard {
arch: Option<&str>,
chroot_path: &PathBuf,
ctx: Arc<context::Context>,
ui: &Option<Arc<DebUi>>,
view: &dyn BuildView,
) -> Result<(), Box<dyn Error>> {
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
let ctx_for_devices = ctx.clone();
@@ -464,10 +465,8 @@ impl EphemeralContextGuard {
series,
arch
);
if let Some(u) = ui {
u.phase(Phase::PreparingChroot);
}
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, ui).await?;
enter_phase(view, Phase::PreparingChroot);
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, view).await?;
} else {
log::debug!(
"Using cached chroot tarball for {} (arch: {:?})",
@@ -478,16 +477,12 @@ impl EphemeralContextGuard {
// Extract tarball to chroot directory
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
if let Some(u) = ui {
u.phase(Phase::ExtractingChroot);
}
Self::extract_tarball(&tarball_path, chroot_path, ui.as_deref())?;
enter_phase(view, Phase::ExtractingChroot);
Self::extract_tarball(&tarball_path, chroot_path, view)?;
// Create device nodes in the chroot
log::debug!("Creating device nodes in chroot...");
if let Some(u) = ui {
u.phase(Phase::FinalizingChroot);
}
enter_phase(view, Phase::FinalizingChroot);
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
// Bind mount /proc from host into chroot (before entering unshare namespace)
@@ -503,7 +498,7 @@ impl EphemeralContextGuard {
arch: Option<&str>,
tarball_path: &Path,
ctx: Arc<context::Context>,
ui: &Option<Arc<DebUi>>,
view: &dyn BuildView,
) -> Result<(), Box<dyn Error>> {
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
let lockfile_path = tarball_path.with_extension("lock");
@@ -537,8 +532,8 @@ impl EphemeralContextGuard {
cmd.arg(series)
.arg(tarball_path.to_string_lossy().to_string());
if let Some(u) = ui {
cmd.capture(u.sink());
if let Some(s) = view.sink() {
cmd.capture(s);
}
let status = cmd.status()?;
@@ -575,7 +570,7 @@ impl EphemeralContextGuard {
fn extract_tarball(
tarball_path: &PathBuf,
chroot_path: &PathBuf,
ui: Option<&DebUi>,
view: &dyn BuildView,
) -> Result<(), Box<dyn Error>> {
// Create the chroot directory
fs::create_dir_all(chroot_path)?;
@@ -593,15 +588,11 @@ impl EphemeralContextGuard {
let mut entry = entry?;
entry.unpack_in(chroot_path)?;
count += 1;
if count.is_multiple_of(100)
&& let Some(u) = ui
{
u.progress_message(&format!("Extracting chroot… ({count} files)"));
if count.is_multiple_of(100) {
view.message(&format!("Extracting chroot… ({count} files)"));
}
}
if let Some(u) = ui {
u.progress_message(&format!("Extracting chroot… ({count} files)"));
}
view.message(&format!("Extracting chroot… ({count} files)"));
Ok(())
}
+36 -56
View File
@@ -1,9 +1,9 @@
/// Local binary package building
/// Directly calling 'debian/rules' in current context
use crate::context::{Context, ContextCommand, LineSink};
use crate::deb::find_dsc_file;
use crate::ui::deb::{DebUi, Phase};
use crate::ui::logfmt::QuiltClassifier;
use crate::deb::{Phase, enter_phase, find_dsc_file};
use crate::logfmt::QuiltClassifier;
use crate::report::BuildView;
use log::warn;
use std::collections::{BTreeMap, HashMap};
use std::error::Error;
@@ -33,13 +33,13 @@ pub async fn build(
pocket: Option<&str>,
build_root: &str,
cross: bool,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ppa: &[String],
inject_packages: &[String],
ctx: Arc<Context>,
ui: Option<Arc<DebUi>>,
view: &dyn BuildView,
jobs: Option<usize>,
) -> 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
let mut env = HashMap::<String, String>::new();
@@ -83,12 +83,9 @@ pub async fn build(
let mut added_ppas: Vec<(&str, &str)> = Vec::new();
// Add PPA repositories if specified
if let Some(ppas) = ppa {
for ppa_str in ppas {
// PPA format: 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]);
for ppa_str in ppa {
let (ppa_user, ppa_name) = crate::package_info::split_ppa(ppa_str)?;
let base_url = crate::package_info::ppa_to_base_url(ppa_user, ppa_name);
// Add new PPA source if not found
if !sources.iter().any(|s| s.uri.contains(&base_url)) {
@@ -119,7 +116,7 @@ pub async fn build(
};
sources.push(new_source);
modified = true;
added_ppas.push((parts[0], parts[1]));
added_ppas.push((ppa_user, ppa_name));
log::info!(
"Added PPA: {} for series {} with architectures {:?}",
ppa_str,
@@ -127,12 +124,6 @@ pub async fn build(
architectures
);
}
} else {
return Err(
format!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str).into(),
);
}
}
}
// UBUNTU: Ensure the 'universe' component is enabled on official
@@ -193,9 +184,7 @@ pub async fn build(
// Update package lists
log::debug!("Updating package lists for local build...");
if let Some(u) = &ui {
u.phase(Phase::UpdatingPackageLists);
}
enter_phase(view, Phase::UpdatingPackageLists);
let status = cap(
ctx.command("apt-get").envs(env.clone()).arg("update"),
&sink,
@@ -234,9 +223,7 @@ pub async fn build(
cmd.arg(format!("libc6:{arch}"));
cmd.arg(format!("libc6-dev:{arch}"));
}
if let Some(u) = &ui {
u.phase(Phase::InstallingEssentials);
}
enter_phase(view, Phase::InstallingEssentials);
let status = cap(&mut cmd, &sink).status()?;
if !status.success() {
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(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?;
// Install injected packages if specified
if let Some(packages) = inject_packages {
install_injected_packages(packages, &env, ctx.clone(), &ui, &sink)?;
if !inject_packages.is_empty() {
install_injected_packages(inject_packages, &env, ctx.clone(), view, &sink)?;
}
// Install arch-specific build dependencies
log::debug!("Installing arch-specific build dependencies...");
if let Some(u) = &ui {
u.phase(Phase::InstallingBuildDeps);
}
enter_phase(view, Phase::InstallingBuildDeps);
let mut cmd = ctx.command("apt-get");
cmd.current_dir(package_dir_str)
.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 !status.success() {
if let Some(u) = &ui {
u.suspend();
}
view.suspend();
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
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 !status.success() {
if let Some(u) = &ui {
u.suspend();
}
view.suspend();
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
return Err("Could not install build-dependencies for the build".into());
}
@@ -330,9 +311,7 @@ pub async fn build(
// Run the build step
log::debug!("Building (debian/rules build) package...");
if let Some(u) = &ui {
u.phase(Phase::Building);
}
enter_phase(view, Phase::Building);
let status = cap(
ctx.command("debian/rules")
.current_dir(package_dir_str)
@@ -346,9 +325,7 @@ pub async fn build(
}
// Run the 'binary' step to produce deb
if let Some(u) = &ui {
u.phase(Phase::ProducingBinaries);
}
enter_phase(view, Phase::ProducingBinaries);
let status = cap(
ctx.command("fakeroot")
.current_dir(package_dir_str)
@@ -495,7 +472,7 @@ fn apply_quilt_patches(
package_dir: &str,
env: &HashMap<String, String>,
ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
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
if let Some(u) = ui {
u.phase_with(
Phase::ApplyingPatches,
view.phase(
Phase::ApplyingPatches.label(),
Box::new(QuiltClassifier::new(total_patches)),
);
}
let mut patch_env = env.clone();
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
let status = cap(
@@ -631,17 +606,15 @@ fn pin_pocket(pocket_suite: &str, ctx: &Arc<Context>) -> Result<(), Box<dyn Erro
}
fn install_injected_packages(
packages: &[&str],
packages: &[String],
env: &HashMap<String, String>,
ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
log::info!("Installing injected packages: {:?}", packages);
if let Some(u) = ui {
u.phase(Phase::InjectingPackages);
}
enter_phase(view, Phase::InjectingPackages);
// Separate .deb files from package names
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());
} else {
package_names.push(pkg);
package_names.push(pkg.as_str());
}
}
@@ -855,6 +828,13 @@ mod tests {
)
.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;
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::path::{Path, PathBuf};
use std::sync::Arc;
@@ -17,67 +21,163 @@ pub enum BuildMode {
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
///
/// Returns the list of produced artifacts (.deb files plus the upload
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
/// `debian/files` and the native metadata generation rather than by
/// globbing the build root (which would surface stale files). When `ui` is
/// set, a live view (status bar + rolling log pane) is displayed and all
/// subprocess output is captured through it; on failure the widget is
/// cleared and a summary of captured errors is printed.
#[allow(clippy::too_many_arguments)]
/// globbing the build root (which would surface stale files). Subprocess
/// output is captured through the view's sink (live view + tee log for the
/// terminal adapter); on failure the view is cleared and prints a summary
/// of captured errors.
pub async fn build_binary_package(
arch: Option<&str>,
series: Option<&str>,
pocket: Option<&str>,
cwd: Option<&Path>,
cross: bool,
mode: Option<BuildMode>,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
ui: Option<Arc<DebUi>>,
jobs: Option<usize>,
opts: DebBuildOptions<'_>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let result = build_binary_package_impl(
arch,
series,
pocket,
cwd,
cross,
mode,
ppa,
inject_packages,
ctx,
&ui,
jobs,
)
.await;
let view = opts.view;
let result = build_binary_package_impl(opts).await;
if let (Some(u), Err(_)) = (&ui, &result) {
u.finish_failure();
if result.is_err() {
view.finish_failure();
}
result
}
/// Implementation of [`build_binary_package`], without failure handling
#[allow(clippy::too_many_arguments)]
async fn build_binary_package_impl(
arch: Option<&str>,
series: Option<&str>,
pocket: Option<&str>,
cwd: Option<&Path>,
cross: bool,
mode: Option<BuildMode>,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
ui: &Option<Arc<DebUi>>,
jobs: Option<usize>,
opts: DebBuildOptions<'_>,
) -> 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
let changelog_path = cwd.join("debian/changelog");
@@ -101,44 +201,45 @@ async fn build_binary_package_impl(
&package_series
};
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
// or by using default for user-supplied parameters
let mode = if let Some(m) = mode {
m
} else {
// By default, we use local build
BuildMode::Local
};
let default_mode = BuildMode::Local;
let mode = mode.as_ref().unwrap_or(&default_mode);
// Create an ephemeral unshare context for all Local builds
// 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)
} else {
None
};
// 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
if let Some(u) = ui {
u.set_target(&package, &version, series, arch);
}
view.target(BuildTarget {
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
// this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared.
let mut guard = if mode == BuildMode::Local {
let mut guard = if *mode == BuildMode::Local {
Some(
ephemeral::EphemeralContextGuard::new_with_context(
series,
chroot_arch,
base_ctx.clone(),
ui.clone(),
view,
)
.await?,
)
@@ -179,14 +280,14 @@ async fn build_binary_package_impl(
&version,
arch,
series,
pocket,
pocket.as_deref(),
&build_root,
cross,
ppa,
inject_packages,
inject,
build_ctx.clone(),
ui.clone(),
jobs,
view,
*jobs,
)
.await?
}
@@ -194,9 +295,7 @@ async fn build_binary_package_impl(
// Retrieve the produced artifacts (binary packages plus the upload
// metadata) to the parent directory.
if let Some(u) = ui {
u.phase(Phase::RetrievingArtifacts);
}
enter_phase(view, Phase::RetrievingArtifacts);
let total_debs = remote_files.len();
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)?;
artifacts.push(local_dest);
if let Some(u) = ui {
u.count_progress("Retrieving artifacts", idx + 1, total_debs);
}
view.progress("Retrieving artifacts", idx + 1, total_debs);
}
if let Some(u) = ui {
u.finish_success(&artifacts, u.elapsed());
}
view.finish_success(&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
// the end of this function and its cleanup commands (umount, rm -rf of
// the chroot) inherit the terminal, so they must not fight the widget.
if let Some(u) = ui {
u.suspend();
}
view.suspend();
// Mark build as successful to trigger chroot cleanup
if result.is_ok()
@@ -446,19 +539,14 @@ mod tests {
log::debug!("Package directory: {}", cwd.display());
log::info!("Starting binary package build...");
crate::deb::build_binary_package(
arch,
Some(series),
None,
Some(&cwd),
crate::deb::build_binary_package(DebBuildOptions {
arch: arch.map(str::to_string),
series: Some(series.to_string()),
cross,
None,
None,
None,
Some(ctx),
None,
None,
)
cwd: Some(cwd.to_path_buf()),
ctx: Some(ctx),
..Default::default()
})
.await
.expect("Cannot build binary package (deb)");
log::info!("Successfully built binary package");
@@ -620,19 +708,14 @@ mod tests {
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
crate::deb::build_binary_package(
Some("arm64"),
Some("noble"),
None,
Some(&pkg_dir),
true,
None,
None,
None,
Some(ctx),
None,
None,
)
crate::deb::build_binary_package(DebBuildOptions {
arch: Some("arm64".to_string()),
series: Some("noble".to_string()),
cwd: Some(pkg_dir),
cross: true,
ctx: Some(ctx),
..Default::default()
})
.await
.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
pub mod quirks;
/// Line classifiers rewriting raw subprocess output into display actions
/// and countable progress (pure logic, shared by build views)
pub mod logfmt;
/// Reporting ports: environment-agnostic build observation ([`BuildView`])
/// and question answering ([`Prompter`]), implemented by terminal views,
/// server bridges or the inert [`Quiet`]
pub mod report;
/// Terminal UI helpers (progress bars, live build views, prompts)
pub mod ui;
View File
+114 -119
View File
@@ -321,10 +321,14 @@ fn main() {
// the structural self-checks inside `scaffold` always run), with
// the scaffold outcome (e.g. a failed vendoring) shaping the
// offer.
let prompter = pkh::ui::prompt::TerminalPrompter;
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)?;
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>>(())
}) {
error!("{}", e);
@@ -349,15 +353,14 @@ fn main() {
let (pb, progress_callback) = pkh::ui::create_progress_bar(&multi);
// Convert PPA to base URL if provided
let base_url = ppa.map(|ppa_str| {
// PPA format: user/ppa_name
let parts: Vec<&str> = ppa_str.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
error!("Invalid PPA format: '{}'. Expected: user/ppa_name", ppa_str);
let base_url = match ppa.map(pkh::package_info::split_ppa) {
Some(Ok((user, name))) => Some(pkh::package_info::ppa_to_base_url(user, name)),
Some(Err(e)) => {
error!("{e}");
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
if let Err(e) = rt.block_on(async {
@@ -391,74 +394,47 @@ fn main() {
let target_series = if let Some(s) = cli_series {
Some(s.to_string())
} else {
// Parse current changelog to determine the default series
let changelog_path = cwd.join("debian/changelog");
match pkh::changelog::parse_changelog_header(&changelog_path) {
Ok((_pkg, _ver, current_series)) => {
// UNRELEASED is not a real series: offer it as a
// pinned first entry (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.
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) {
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
Some(pkh::changelog::SeriesCandidates::Choose {
options,
default,
fallback,
}) => match pkh::ui::select_series(&options, &default) {
Ok(selected) => Some(selected),
Err(e) => {
error!(
"Series selection failed: {}. Using current series '{}' instead.",
e, current_series
e, fallback
);
Some(current_series)
Some(fallback)
}
}
}
Err(_) => {
// Could not fetch series list, use current series as default
Some(current_series)
}
}
}
Err(_) => None,
},
// Could not fetch the series list: use the current series
Some(pkh::changelog::SeriesCandidates::Keep(current)) => Some(current),
// No parsable changelog: leave the series decision to
// generate_entry
None => None,
}
};
if let Err(e) = generate_entry(
let entry = match generate_entry(
"debian/changelog",
Some(&cwd),
version,
target_series.as_deref(),
) {
Ok(entry) => entry,
Err(e) => {
error!("{}", e);
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") {
Ok(e) => e,
@@ -490,13 +466,19 @@ fn main() {
.copied()
.unwrap_or(false);
// Live build view: disabled by --verbose or when stdout is not a
// terminal (DebUi handles the non-TTY case itself)
let ui = if verbose {
// Live build view, unless --verbose (DebUi additionally disables
// itself when stdout is not a terminal)
let quiet = pkh::report::Quiet;
let live = if verbose {
None
} 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) {
Some("always") => pkh::build::OrigSourceMode::Always,
@@ -504,14 +486,38 @@ fn main() {
_ => pkh::build::OrigSourceMode::Auto,
};
if let Err(e) = pkh::build::build_source_package(
Some(&cwd),
pkh::build::SourceBuildOptions {
match pkh::build::build_source_package(pkh::build::BuildSourceOptions {
source: Some(cwd),
options: pkh::build::SourceBuildOptions {
orig_source,
..Default::default()
},
ui,
) {
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);
// Unmet build dependencies/conflicts exit with status 3,
// like dpkg-buildpackage does.
@@ -523,6 +529,7 @@ fn main() {
std::process::exit(1);
}
}
}
Some(("put", sub_matches)) => {
let cwd = current_dir_or_exit();
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
@@ -543,41 +550,38 @@ fn main() {
std::process::exit(1);
};
let view = pkh::ui::deb::DebUi::new(&multi);
let prompter = pkh::ui::prompt::TerminalPrompter;
let options = pkh::put::PutOptions {
ppa: ppa.to_string(),
changes,
force,
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);
std::process::exit(1);
}
}
Some(("deb", sub_matches)) => {
let cwd = current_dir_or_exit();
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
let pocket = sub_matches.get_one::<String>("pocket").map(|s| s.as_str());
let arch = sub_matches.get_one::<String>("arch").map(|s| s.as_str());
let cross = sub_matches.get_one::<bool>("cross").unwrap_or(&false);
let ppa: Vec<&str> = sub_matches
let series = sub_matches.get_one::<String>("series").cloned();
let pocket = sub_matches.get_one::<String>("pocket").cloned();
let arch = sub_matches.get_one::<String>("arch").cloned();
let cross = sub_matches
.get_one::<bool>("cross")
.copied()
.unwrap_or(false);
let ppa: Vec<String> = sub_matches
.get_many::<String>("ppa")
.map(|v| v.map(|s| s.as_str()).collect())
.map(|v| v.cloned().collect())
.unwrap_or_default();
let ppa = if ppa.is_empty() {
None
} else {
Some(ppa.as_slice())
};
let inject_packages: Vec<&str> = sub_matches
let inject: Vec<String> = sub_matches
.get_many::<String>("inject")
.map(|v| v.map(|s| s.as_str()).collect())
.map(|v| v.cloned().collect())
.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<pkh::deb::BuildMode> = match mode {
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
// terminal (DebUi handles the non-TTY case itself)
let ui = if verbose {
// Live build view, unless --verbose (DebUi additionally disables
// itself when stdout is not a terminal)
let quiet = pkh::report::Quiet;
let live = if verbose {
None
} 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 {
pkh::deb::build_binary_package(
pkh::deb::build_binary_package(pkh::deb::DebBuildOptions {
arch,
series,
pocket,
Some(cwd.as_path()),
*cross,
cwd: Some(cwd.clone()),
cross,
mode,
ppa,
inject_packages,
None,
ui.clone(),
inject,
jobs,
)
view,
..Default::default()
})
.await
});
match result {
Ok(artifacts) => {
let _ = artifacts;
info!("Done.");
}
Ok(_) => info!("Done."),
Err(e) => {
error!("{}", e);
std::process::exit(1);
@@ -655,24 +661,13 @@ fn main() {
std::process::exit(1);
});
// Parse host, user, port from endpoint
// Formats: [ssh://][user@]host[:port]
let endpoint_re = regex::Regex::new(r"^(?:ssh://)?(?:(?P<user>[^@]+)@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?$").unwrap();
let endpoint_cap = endpoint_re.captures(endpoint).unwrap_or_else(|| {
error!("Invalid endpoint format: '{}'. Expected [ssh://][user@]host[:port]", endpoint);
match pkh::context::ContextConfig::from_endpoint(endpoint) {
Ok(config) => config,
Err(e) => {
error!("{e}");
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);
+1 -1
View File
@@ -436,7 +436,7 @@ pub fn create_orig_tarball_excluding(
log::info!(
"Created orig tarball {}",
crate::ui::display_path(&tarball_path)
crate::report::display_path(&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
// itself (Here mode): `Created .` would be cryptic, so spell the
// 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() {
"package in the current directory".to_string()
} else {
@@ -819,7 +819,7 @@ mod tests {
let output = crate::build::run_source_build(
&source,
&crate::build::SourceBuildOptions::default(),
None,
&crate::report::Quiet,
)
.unwrap();
let dsc = std::fs::read_to_string(&output.dsc).unwrap();
@@ -885,7 +885,7 @@ mod tests {
let output = crate::build::run_source_build(
&dir.path().join("mytool"),
&crate::build::SourceBuildOptions::default(),
None,
&crate::report::Quiet,
)
.unwrap();
@@ -916,7 +916,7 @@ mod tests {
let output = crate::build::run_source_build(
&dir.path().join("mytool"),
&crate::build::SourceBuildOptions::default(),
None,
&crate::report::Quiet,
)
.unwrap();
assert!(output.dsc.exists(), "{:?} missing", output.dsc);
+4 -4
View File
@@ -183,7 +183,7 @@ pub fn create_vendor_component(
log::info!(
"Created vendored-dependencies component {}",
crate::ui::display_path(&component_path)
crate::report::display_path(&component_path)
);
Ok(component_path)
}
@@ -283,7 +283,7 @@ fn git_archive_tarball(
log::info!(
"Created orig tarball from git archive of {tag}: {}",
crate::ui::display_path(&dest)
crate::report::display_path(&dest)
);
Ok(dest)
}
@@ -311,7 +311,7 @@ fn download_release(
Ok(path) => {
log::info!(
"Created orig tarball from the release download of {tag}: {}",
crate::ui::display_path(&path)
crate::report::display_path(&path)
);
Ok(path)
}
@@ -352,7 +352,7 @@ fn fetch_and_repack(
log::info!(
"Created orig tarball from {}: {}",
source,
crate::ui::display_path(&dest)
crate::report::display_path(&dest)
);
Ok(dest)
}
+91 -83
View File
@@ -1,11 +1,11 @@
//! The `pkh new` interactive wizard.
//!
//! [`run`] is the single entry point: on an interactive terminal it asks the
//! questions of the spec's "Proposed UX" transcript, fills a
//! [`run`] is the single entry point: when the prompter can interact it asks
//! the questions of the spec's "Proposed UX" transcript, fills a
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
//! reuses [`options::resolve`] as the single source of truth for defaults,
//! 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
//! answer.
//!
@@ -13,11 +13,10 @@
//! verification builds of the spec ([`offer_verification`]); a failed
//! 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.
use std::error::Error;
use std::io::IsTerminal;
use std::path::PathBuf;
use indicatif::MultiProgress;
@@ -28,7 +27,7 @@ use crate::new::licenses;
use crate::new::options::{self, NewCli, NewOptions, SourceDir, SourceFormat, TemplateId};
use crate::new::origin::GitOrigin;
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.
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.
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
/// Labels of the interactive `select` questions. `prompt::select` renders
/// `> <label><answer>` verbatim — unlike [`prompt::text`], it appends no
/// Labels of the interactive `select` questions. The prompter renders
/// `> <label><answer>` verbatim — unlike [`Prompter::text`], it appends no
/// formatting of its own — so each label carries its own separator:
/// field-style prompts end with `": "`, question-style ones with `"? "`.
const LANGUAGE_LABEL: &str = "Which language/build system is your program using? ";
@@ -62,20 +61,13 @@ const SELECT_LABELS: [&str; 6] = [
ORIG_LABEL,
];
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
/// [`options::resolve`] otherwise (and with `--defaults`).
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if cli.defaults || !is_interactive() {
/// Run the `pkh new` flow: the wizard when the prompter can interact,
/// plain [`options::resolve`] otherwise (and with `--defaults`).
pub async fn run(cli: NewCli, prompter: &dyn Prompter) -> Result<NewOptions, Box<dyn Error>> {
if cli.defaults || !prompter.interactive() {
return Ok(options::resolve(cli).await?);
}
run_wizard(cli).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()
run_wizard(cli, prompter).await
}
/// 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
/// final `Generate?` confirmation. Every question with an explicit flag
/// 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 mut detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
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.
if cli.name.is_none() {
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);
}
@@ -142,7 +142,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.unwrap_or(TemplateId::EMPTY)
.display_name()
.to_string();
let id = select_template(&menu, &default)?;
let id = select_template(prompter, &menu, &default)?;
cli.lang = Some(id.as_str().to_string());
}
LanguageChoice::Ambiguous(candidates) => {
@@ -157,7 +157,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.join(", ")
);
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());
}
}
@@ -193,14 +193,14 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else {
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())
})?;
if answer == SOURCE_HERE {
cli.source = Some(cwd.clone());
} else if answer == SOURCE_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));
}
// 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());
let revision = cli.revision.unwrap_or(1);
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());
// 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 \
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");
crate::new::origin::checkout_tag(dir, tag)?;
log::info!(
@@ -311,7 +311,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
} else {
"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())
})?;
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());
if chosen == "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);
}
}
// 5. Debian revision.
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();
}
@@ -340,7 +340,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.and_then(|p| p.description.clone())
.unwrap_or_default();
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);
}
@@ -357,7 +357,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
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() {
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));
let (default, custom_default) = license_question_default(detected.as_deref());
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())
})?;
if answer == LICENSE_OTHER {
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);
} else {
cli.license = Some(answer);
@@ -395,7 +400,12 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
.as_ref()
.and_then(|p| p.command.clone())
.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);
}
@@ -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(|_| ());
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
// 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
// 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() {
let vendor = crate::build::env::current_vendor().to_lowercase();
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 {
"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())
})?;
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() {
match crate::distro_info::get_ordered_series_name(&dist).await {
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);
}
_ => {
@@ -472,6 +482,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
if template == TemplateId::EMPTY && cli.depends.is_empty() {
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
let answer = ask_text(
prompter,
"Depends (metapackage, comma-separated, blank for an empty base)",
"",
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.
cli.git = false;
} 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
@@ -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
// the default (dependency() / pkg_check_modules calls found).
if matches!(template, TemplateId::MESON | TemplateId::CMAKE)
&& prompt::confirm(
&& prompter.confirm(
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
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).
if template != TemplateId::EMPTY
&& prompt::confirm(
&& prompter.confirm(
"Add an autopkgtest smoke test (debian/tests/control)?",
false,
)?
@@ -523,15 +534,15 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
opts.autopkgtest = true;
}
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);
}
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
// nothing written (generation is all-or-nothing later anyway).
println!("{}", summary_text(&opts, toolchain_pin.as_deref()));
if !prompt::confirm("Generate?", true)? {
prompter.present(&summary_text(&opts, toolchain_pin.as_deref()));
if !prompter.confirm("Generate?", true)? {
return Err("Aborted: nothing was written to disk.".into());
}
@@ -553,9 +564,10 @@ pub async fn offer_verification(
outcome: &ScaffoldOutcome,
multi: &MultiProgress,
no_verify: bool,
prompter: &dyn Prompter,
) {
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() {
".".to_string()
} else {
@@ -565,7 +577,7 @@ pub async fn offer_verification(
if outcome.vendoring_failed {
// Set apart from the surrounding success output by blank lines: a
// single warning between two success lines is easy to miss.
println!();
prompter.present("");
log::warn!(
"The Cargo dependencies could NOT be vendored: this package will \
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, \
plus `[net] offline = true`"
);
println!();
prompter.present("");
}
if no_verify || !is_interactive() {
if no_verify || !prompter.interactive() {
return;
}
@@ -585,7 +597,7 @@ pub async fn offer_verification(
} else {
"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,
Err(_) => return,
};
@@ -593,12 +605,13 @@ pub async fn offer_verification(
return;
}
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
if let Err(e) = crate::build::build_source_package(
Some(&tree),
crate::build::SourceBuildOptions::default(),
ui,
) {
let ui = std::sync::Arc::new(crate::ui::deb::DebUi::new(multi));
if let Err(e) = crate::build::build_source_package(crate::build::BuildSourceOptions {
source: Some(tree.clone()),
options: crate::build::SourceBuildOptions::default(),
view: &*ui,
prompter,
}) {
log::error!("Verification source build failed: {e}");
log::info!(
"The scaffolded tree is intact. Inspect it, then retry with \
@@ -612,7 +625,7 @@ pub async fn offer_verification(
return;
}
let verify_deb = match prompt::confirm(
let verify_deb = match prompter.confirm(
"Verify with `pkh deb` now? (needs network + build deps)",
false,
) {
@@ -623,20 +636,13 @@ pub async fn offer_verification(
return;
}
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
if let Err(e) = crate::deb::build_binary_package(
None,
Some(&opts.series),
None,
Some(&tree),
false,
None,
None,
None,
None,
ui,
None,
)
let view = crate::ui::deb::DebUi::new(multi);
if let Err(e) = crate::deb::build_binary_package(crate::deb::DebBuildOptions {
series: Some(opts.series.clone()),
cwd: Some(tree.clone()),
view: &view,
..Default::default()
})
.await
{
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
/// 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 {
let answer = prompt::select(LANGUAGE_LABEL, options, default)?;
let answer = prompter.select(LANGUAGE_LABEL, options, default)?;
match TemplateId::from_label(&answer) {
Some(id) => return Ok(id),
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
/// selector allows typing arbitrary text, which callers may need to reject).
fn select_from(
prompter: &dyn Prompter,
label: &str,
options: &[String],
default: &str,
accept: impl Fn(&str) -> bool,
) -> Result<String, Box<dyn Error>> {
loop {
let answer = prompt::select(label, options, default)?;
let answer = prompter.select(label, options, default)?;
if accept(&answer) {
return Ok(answer);
}
@@ -838,7 +849,7 @@ fn select_from(
/// upstream version like `1.0-2` carrying a Debian revision) is withheld —
/// the question is asked without a default instead of offering one that
/// 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() {
default
} 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 — must pass `validate`. `Err` carries the validation error so the
/// caller re-asks with it.
fn accept_answer(
answer: &str,
default: &str,
validate: &prompt::Validator,
) -> Result<String, String> {
fn accept_answer(answer: &str, default: &str, validate: &Validator) -> Result<String, String> {
let answer = if answer.is_empty() { default } else { answer };
validate(answer).map(|_| answer.to_string())
}
@@ -868,6 +875,7 @@ fn accept_answer(
/// validation error) — probe data can never bypass validation and only blow
/// up later in [`options::resolve`].
fn ask_text(
prompter: &dyn Prompter,
label: &str,
default: &str,
validate: impl Fn(&str) -> Result<(), String> + 'static,
@@ -889,7 +897,7 @@ fn ask_text(
}
};
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
// an empty one resolves to the default, pre-decided above.
let answer = if answer.is_empty() {
@@ -1551,7 +1559,7 @@ mod tests {
#[test]
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
// (regression: the wizard once rendered "> LicenseMIT").
for label in SELECT_LABELS {
+33
View File
@@ -9,6 +9,22 @@ use crate::apt::release::{self, VerifiedRelease};
use crossterm::style::Stylize;
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
///
/// # Arguments
@@ -943,6 +959,23 @@ pub async fn lookup(
mod tests {
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
/// 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::path::{Path, PathBuf};
use indicatif::{MultiProgress, ProgressBar};
use log::info;
use serde::{Deserialize, Serialize};
@@ -27,10 +26,10 @@ use crate::debian::checksums::FileChecksums;
use crate::debian::control::ControlInfo;
use crate::debian::version::DebianVersion;
use crate::launchpad;
use crate::ui;
use crate::report::{BuildTarget, BuildView, Prompter};
/// Everything `put` needs to run.
pub struct PutOptions {
pub struct PutOptions<'a> {
/// PPA to upload to, `user/ppa_name` format.
pub ppa: String,
/// Explicit `.changes` file to upload; when `None`, the one matching the
@@ -42,14 +41,25 @@ pub struct PutOptions {
pub force: bool,
/// Source package directory (the one containing `debian/`).
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
/// progress bars.
pub async fn put(
opts: &PutOptions,
multi: &MultiProgress,
) -> Result<(), Box<dyn std::error::Error>> {
/// Upload the package described by `opts` to its target, reporting progress
/// through the view and asking the prompter when the server is unknown.
pub async fn put(opts: &PutOptions<'_>) -> Result<(), Box<dyn std::error::Error>> {
// The display is released on every path (success and early `?` bails)
// before the outcome is logged, so no stale status line lingers above it
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 ssh_config = ssh::lookup_ssh_config(&target.fqdn);
@@ -79,21 +89,20 @@ pub async fn put(
};
let changes = changes::parse(&changes_path)?;
// The summary line stays up for the whole flow: the completion message
// replaces it on success, and the `PutBars` guard clears it on every
// early `?` bail so the error logged by main is not preceded by stale
// bars
let mut bars = PutBars::default();
let summary = multi.add(ProgressBar::new(0));
// Style and prefix go in before the steady tick: otherwise the first
// tick can render one frame with the default bar template
summary.set_style(ui::spinner_style());
summary.set_prefix(format!(
// The summary line stays up for the whole flow: rendered as the view's
// persistent status, with the per-step messages below it
opts.view.target(BuildTarget {
package: &changes.source,
version: &changes.version,
target: &target.label,
display: format!(
"Uploading {} {} to {}",
changes.source, changes.version, target.label
));
summary.enable_steady_tick(TICK);
bars.track(summary.clone());
),
source_only: false,
// An upload runs no subprocess: nothing to tee
tee_log: false,
});
changes::validate(&changes)?;
// 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));
checking.set_style(ui::spinner_style());
checking.set_prefix(format!("Checking {} on Launchpad...", target.label));
checking.enable_steady_tick(TICK);
bars.track(checking.clone());
opts.view
.message(&format!("Checking {} on Launchpad...", target.label));
launchpad::ppa_info(&opts.ppa).await?;
launchpad::check_ppa_series(&changes.distribution).await?;
clear_bar(&checking);
// Last pre-flight check: a version the PPA already publishes at or
// above the changes' one would supersede (or reject) this upload
@@ -134,14 +139,10 @@ pub async fn put(
.into());
}
let connecting = multi.add(ProgressBar::new(0));
connecting.set_style(ui::spinner_style());
connecting.set_prefix(format!("Connecting to {login}@{host}:{port}..."));
connecting.enable_steady_tick(TICK);
bars.track(connecting.clone());
let session = ssh::connect(&host, port, &login, &ssh_config)?;
opts.view
.message(&format!("Connecting to {login}@{host}:{port}..."));
let session = ssh::connect(&host, port, &login, &ssh_config, opts.prompter)?;
let sftp = ssh::sftp(&session)?;
clear_bar(&connecting);
// Payload first, the .changes file last (like dput), so the server-side
// queue processor can never pick up an incomplete upload
@@ -179,15 +180,11 @@ pub async fn put(
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 result = ssh::upload_file(&sftp, path, &remote, &host, &bar);
bar.finish_and_clear();
let label = format!("Uploading {name}");
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 {
// The failed file itself joins the cleanup: its remote `create`
// may have succeeded before the failure, leaving a partial — or,
@@ -204,9 +201,6 @@ pub async fn put(
// replaying is safe).
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!(
"Upload of {} {} to {} complete.",
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)
/// against the distribution's valid sections: a bare section or a
/// `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",
entry.source,
many.iter()
.map(|p| ui::display_path(p))
.map(|p| crate::report::display_path(p))
.collect::<Vec<_>>()
.join(", ")
)
+9 -11
View File
@@ -20,14 +20,13 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::time::Duration;
use indicatif::ProgressBar;
use log::debug;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session};
use crate::data::embed_data;
use crate::ui::prompt;
use crate::report::Prompter;
/// Pinned SSH host key fingerprints, loaded from the bundled
/// `host_keys.yml` data file (same pattern as `distro_info.yml`): data
@@ -303,6 +302,7 @@ pub fn connect(
port: u16,
login: &str,
config: &SshConfig,
prompter: &dyn Prompter,
) -> Result<Session, Box<dyn std::error::Error>> {
let tcp = tcp_connect(host, port)?;
@@ -326,7 +326,7 @@ pub fn connect(
let (key, key_type) = session
.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)?;
@@ -353,6 +353,7 @@ fn verify_host_key(
port: u16,
key: &[u8],
key_type: HostKeyType,
prompter: &dyn Prompter,
) -> Result<(), Box<dyn std::error::Error>> {
let fingerprint = fingerprint(key);
@@ -387,12 +388,7 @@ fn verify_host_key(
format!("[{host}]:{port}")
};
// The banner is plain output: the confirmation prompt itself
// 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 {
if !prompter.accept_host_key(&display, key_type_desc, &fingerprint) {
return Err(format!("Host key for {display} rejected, aborting upload").into());
}
if let Some(name) = key_type_name(key_type) {
@@ -592,7 +588,7 @@ pub fn upload_file(
local: &Path,
remote: &str,
host: &str,
bar: &ProgressBar,
on_progress: &dyn Fn(u64),
) -> Result<(), Box<dyn std::error::Error>> {
let mut local_file =
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}"))?;
let mut buf = [0u8; 32 * 1024];
let mut uploaded: u64 = 0;
loop {
let n = local_file.read(&mut buf)?;
if n == 0 {
@@ -610,7 +607,8 @@ pub fn upload_file(
remote_file
.write_all(&buf[..n])
.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
+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)
pub mod deb;
/// Line classifiers rewriting raw subprocess output for the live views
pub mod logfmt;
/// Interactive raw-mode prompts: free-text input, option selection and
/// yes/no confirmation
pub mod prompt;
@@ -12,27 +10,8 @@ pub mod prompt;
#[cfg(test)]
use indicatif::ProgressDrawTarget;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::path::Path;
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
pub(crate) fn spinner_style() -> ProgressStyle {
ProgressStyle::default_bar()
+79 -147
View File
@@ -3,7 +3,7 @@
//! ("a terminal in the terminal").
//!
//! Subprocess output is captured through a [`LineSink`] implementation,
//! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place
//! rewritten by classifiers ([`crate::logfmt`]) and rendered in place
//! with indicatif, so pkh's own log lines keep printing above the widget via
//! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log
//! file under the pkh cache directory.
@@ -21,10 +21,8 @@ use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use crate::context::{LineSink, Stream};
use crate::ui::logfmt::{
Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier,
MakeClassifier, MmdebstrapClassifier, QuiltClassifier,
};
use crate::logfmt::{Action, Classifier, GenericClassifier};
use crate::report::BuildTarget;
/// Number of lines displayed in the rolling pane
const PANE_LINES: usize = 10;
@@ -32,69 +30,6 @@ const PANE_LINES: usize = 10;
/// Minimum interval between pane redraws
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
/// Build phases of `pkh deb`, shown in the status bar
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// Downloading the chroot tarball (mmdebstrap)
PreparingChroot,
/// Extracting the chroot tarball
ExtractingChroot,
/// Device nodes, /proc bind mount, etc.
FinalizingChroot,
/// apt-get update
UpdatingPackageLists,
/// Installing build-essential & co
InstallingEssentials,
/// quilt push -a
ApplyingPatches,
/// --inject packages
InjectingPackages,
/// apt-get build-dep
InstallingBuildDeps,
/// debian/rules build
Building,
/// fakeroot debian/rules binary
ProducingBinaries,
/// Retrieving produced .deb files
RetrievingArtifacts,
}
impl Phase {
/// Human-readable label displayed in the status bar
pub fn label(&self) -> &'static str {
match self {
Phase::PreparingChroot => "Preparing chroot",
Phase::ExtractingChroot => "Extracting chroot",
Phase::FinalizingChroot => "Finalizing chroot",
Phase::UpdatingPackageLists => "Updating package lists",
Phase::InstallingEssentials => "Installing essential packages",
Phase::ApplyingPatches => "Applying patches",
Phase::InjectingPackages => "Injecting packages",
Phase::InstallingBuildDeps => "Installing build dependencies",
Phase::Building => "Building package",
Phase::ProducingBinaries => "Producing binary packages",
Phase::RetrievingArtifacts => "Retrieving artifacts",
}
}
}
/// Default classifier used for a given phase
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
match phase {
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
Phase::InstallingBuildDeps => {
Box::new(AptInstallClassifier::new("Installing build dependencies"))
}
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
}
}
/// Visual kind of a pane line, driving its color
#[derive(Debug, Clone, Copy, PartialEq)]
enum Kind {
@@ -129,9 +64,9 @@ struct Shared {
/// Live build view for `pkh deb` / `pkh build`
///
/// Create one per build (disabled automatically when stdout is not a TTY or
/// when the user requests verbose output), pass it down as
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`].
/// Create one per build (it disables itself automatically when stdout is not
/// a TTY) and pass it down through the [`crate::report::BuildView`] port.
/// Subprocess output reaches it through [`crate::report::BuildView::sink`].
pub struct DebUi {
shared: Arc<Shared>,
}
@@ -213,17 +148,6 @@ impl DebUi {
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
/// (best-effort), then open it so subsequent captured lines are tee'd
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
pub fn phase(&self, phase: Phase) {
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>) {
/// Switch to an arbitrary status label with a custom classifier
fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
{
let mut st = self.shared.state.lock().unwrap();
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
fn active(&self) -> bool {
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
}
/// Whether the widget renders at all (false on non-TTY stdout); callers
/// use this to fall back to plain-line summaries
pub fn is_enabled(&self) -> bool {
self.shared.enabled
}
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
pub fn sink(self: &Arc<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
/// Release the widget from the terminal (e.g. before printing
/// passthrough diagnostics or letting child cleanup commands write to
/// the terminal); idempotent
///
/// Steady ticks are disabled first: otherwise a tick can redraw a frame
/// right after the clear, leaving stale copies of the widget on screen.
pub fn suspend(&self) {
fn suspend(&self) {
if !self.shared.enabled {
return;
}
@@ -348,21 +225,21 @@ impl DebUi {
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
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) {
self.suspend();
if self.shared.enabled && !artifacts.is_empty() {
println!("Built in {}s:", elapsed.as_secs());
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
/// and the path to the full log)
pub fn finish_failure(&self) {
/// Failure outcome body: clear the widget and print a summary (recent
/// captured errors and the path to the full log)
fn failure_summary(&self) {
self.suspend();
let st = self.shared.state.lock().unwrap();
@@ -385,15 +262,70 @@ impl DebUi {
eprintln!("Full log: {}", log_path.display());
}
}
}
/// Time elapsed since the view was created
pub fn elapsed(&self) -> Duration {
self.shared.started.elapsed()
/// [`crate::report::BuildView`] port: forwards build events to the live
/// widget, so core flows drive the view without knowing it is a terminal
/// 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),
);
}
}
/// Path of the full build log file
pub fn log_path(&self) -> PathBuf {
self.shared.log_path.lock().unwrap().clone()
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},
terminal,
};
use std::io::{self, Write};
use std::io::{self, IsTerminal, Write};
use std::time::Duration;
/// Why a prompt could not run interactively
@@ -32,9 +32,8 @@ impl std::fmt::Display for PromptError {
impl std::error::Error for PromptError {}
/// Answer validator of [`text`]: accepts the answer, or explains why it is
/// rejected
pub type Validator = dyn Fn(&str) -> Result<(), String>;
/// Answer validator of [`text`] (re-exported from [`crate::report`])
pub use crate::report::Validator;
/// 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
@@ -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
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
/// enabled.