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.
This commit is contained in:
2026-09-18 20:02:56 +02:00
parent a7d2cfdc6e
commit 27b1083b15
11 changed files with 335 additions and 179 deletions
+71 -27
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,7 +21,7 @@ use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use crate::context::{LineSink, Stream};
use crate::ui::logfmt::{
use crate::logfmt::{
Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier,
MakeClassifier, MmdebstrapClassifier, QuiltClassifier,
};
@@ -130,8 +130,10 @@ struct Shared {
/// Live build view for `pkh deb` / `pkh build`
///
/// Create one per build (disabled automatically when stdout is not a TTY or
/// when the user requests verbose output), pass it down as
/// `Option<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`].
/// when the user requests verbose output), then either pass it down through
/// the [`crate::report::BuildView`] port (source builds) or as
/// `Option<Arc<DebUi>>` (the `pkh deb` flow, until it migrates), and feed
/// subprocess output through [`DebUi::sink`].
pub struct DebUi {
shared: Arc<Shared>,
}
@@ -213,17 +215,6 @@ impl DebUi {
self.open_log("deb", package, version, &format!("for {series}/{arch}"));
}
/// Identify the source package being built; names the log file
/// (`build-<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) {
@@ -272,9 +263,10 @@ impl DebUi {
self.phase_custom(phase.label(), classifier);
}
/// Switch to an arbitrary status label with a custom classifier; used by
/// flows whose phases are not part of [`Phase`] (e.g. source builds)
pub fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
/// Switch to an arbitrary status label with a custom classifier; the
/// shared implementation behind [`DebUi::phase`], [`DebUi::phase_with`]
/// and the [`BuildView`] port
fn phase_custom(&self, label: &str, classifier: Box<dyn Classifier>) {
{
let mut st = self.shared.state.lock().unwrap();
st.classifier = classifier;
@@ -316,12 +308,6 @@ impl DebUi {
self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst)
}
/// Whether the widget renders at all (false on non-TTY stdout); callers
/// use this to fall back to plain-line summaries
pub fn is_enabled(&self) -> bool {
self.shared.enabled
}
/// Obtain a sink feeding this view; pass it to `ContextCommand::capture`
pub fn sink(self: &Arc<Self>) -> Arc<dyn LineSink> {
Arc::new(Sink {
@@ -351,6 +337,18 @@ impl DebUi {
/// Clear the widget and print a success summary with the artifacts,
/// rendered relative to the working directory when possible
pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) {
self.success_summary(artifacts, elapsed);
}
/// Clear the widget and print a failure summary (recent captured errors
/// and the path to the full log)
pub fn finish_failure(&self) {
self.failure_summary();
}
/// Shared body of the success outcome ([`DebUi::finish_success`] and the
/// [`BuildView`] port)
fn success_summary(&self, artifacts: &[PathBuf], elapsed: Duration) {
self.suspend();
if self.shared.enabled && !artifacts.is_empty() {
println!("Built in {}s:", elapsed.as_secs());
@@ -360,9 +358,9 @@ impl DebUi {
}
}
/// Clear the widget and print a failure summary (recent captured errors
/// and the path to the full log)
pub fn finish_failure(&self) {
/// Shared body of the failure outcome ([`DebUi::finish_failure`] and the
/// [`BuildView`] port)
fn failure_summary(&self) {
self.suspend();
let st = self.shared.state.lock().unwrap();
@@ -397,6 +395,52 @@ impl DebUi {
}
}
/// [`crate::report::BuildView`] port: forwards build events to the live
/// widget, so core flows can drive the view without knowing it is a
/// terminal widget. Where the inherent methods keep their richer signatures
/// (e.g. [`DebUi::finish_success`] takes the elapsed duration explicitly for
/// the `pkh deb` flow), the port bridges them.
impl crate::report::BuildView for DebUi {
fn target(&self, package: &str, version: &str, target: &str) {
if self.shared.enabled {
self.shared.top.set_prefix(format!(
"Building source package {package} ({version}) for {target}"
));
}
self.open_log("build", package, version, &format!("for {target}"));
}
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
self.phase_custom(name, classifier);
}
fn message(&self, text: &str) {
self.progress_message(text);
}
fn progress(&self, label: &str, pos: usize, total: usize) {
self.count_progress(label, pos, total);
}
fn sink(&self) -> Option<Arc<dyn LineSink>> {
Some(Arc::new(Sink {
shared: self.shared.clone(),
}))
}
fn finish_success(&self, artifacts: &[PathBuf]) {
self.success_summary(artifacts, self.elapsed());
}
fn finish_failure(&self) {
self.failure_summary();
}
fn is_enabled(&self) -> bool {
self.shared.enabled
}
}
impl Drop for DebUi {
fn drop(&mut self) {
// Safety net: clear the widget on early returns/unwinds
-640
View File
@@ -1,640 +0,0 @@
//! Line classifiers rewriting raw subprocess output for the live UI
//!
//! Each classifier is a small stateful machine fed every captured line of a
//! build phase; it decides what to display (rewritten lines, warnings,
//! errors) and whether the line carries countable progress. Classifiers are
//! pure with respect to the UI: they only return [`Action`]s.
use std::sync::OnceLock;
use crate::context::Stream;
use regex::Regex;
/// Maximum length of a rewritten line displayed in the rolling pane
pub(crate) const MAX_LINE_WIDTH: usize = 120;
/// What a classifier decided to do with a captured line
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// Drop the line (noise)
Hidden,
/// Display a rewritten line in the rolling pane
Shown(String),
/// Display a warning line (yellow)
Warning(String),
/// Display an error line (red, sticky)
Error(String),
/// Update the determinate progress bar
Progress {
/// Current position
pos: u64,
/// Total number of items (0 = unknown)
total: u64,
},
}
/// Stateful classifier turning raw subprocess lines into UI actions
pub trait Classifier: Send {
/// Feed one captured line, returning the actions it produces
fn feed(&mut self, stream: Stream, line: &str) -> Vec<Action>;
}
/// Truncate a line to [`MAX_LINE_WIDTH`], appending an ellipsis if cut
pub(crate) fn truncate(line: &str) -> String {
if line.chars().count() <= MAX_LINE_WIDTH {
line.to_string()
} else {
let cut: String = line.chars().take(MAX_LINE_WIDTH - 1).collect();
format!("{}…", cut.trim_end())
}
}
/// Classify apt-style severity prefixes (`E:` / `W:`)
fn apt_severity(line: &str) -> Option<Action> {
if line.starts_with("E:") {
Some(Action::Error(truncate(line)))
} else if line.starts_with("W:") {
Some(Action::Warning(truncate(line)))
} else {
None
}
}
/// Classifier for `apt-get update` output
///
/// Collapses `Get:/Hit:/Ign:` lines into a running source counter and always
/// surfaces errors and warnings.
#[derive(Default)]
pub struct AptUpdateClassifier {
sources: u64,
}
impl AptUpdateClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for AptUpdateClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
if line.starts_with("Get:") || line.starts_with("Hit:") || line.starts_with("Ign:") {
self.sources += 1;
vec![Action::Shown(format!(
"Updating package lists… ({} sources)",
self.sources
))]
} else if let Some(severity) = apt_severity(line) {
vec![severity]
} else {
vec![Action::Hidden]
}
}
}
/// Classifier for `apt-get install` / `apt-get build-dep` output
///
/// Parses the upfront summary ("N upgraded, M newly installed, …") to derive
/// a total, then counts `Unpacking`/`Setting up` lines to drive a determinate
/// progress bar.
#[derive(Default)]
pub struct AptInstallClassifier {
label: String,
total: u64,
done: u64,
}
impl AptInstallClassifier {
/// Create a classifier for an install phase labeled `label`
pub fn new(label: &str) -> Self {
Self {
label: label.to_string(),
..Default::default()
}
}
fn progress(&self) -> Action {
if self.total > 0 {
Action::Progress {
pos: self.done.min(self.total),
total: self.total,
}
} else {
Action::Hidden
}
}
}
impl Classifier for AptInstallClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
static SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
let summary_re = SUMMARY_RE.get_or_init(|| {
Regex::new(r"(\d+) (?:upgraded|newly installed|re-installed)").unwrap()
});
static UNPACK_RE: OnceLock<Regex> = OnceLock::new();
let unpack_re =
UNPACK_RE.get_or_init(|| Regex::new(r"^Unpacking ([^ ]+) \(([^)]+)\)").unwrap());
static SETUP_RE: OnceLock<Regex> = OnceLock::new();
let setup_re =
SETUP_RE.get_or_init(|| Regex::new(r"^Setting up ([^ ]+) \(([^)]+)\)").unwrap());
if summary_re.is_match(line) && !self.label.is_empty() {
// Only accept the summary once: later lines may repeat counts
if self.total == 0 {
let total: u64 = summary_re
.captures_iter(line)
.filter_map(|c| c[1].parse::<u64>().ok())
.sum();
self.total = total;
vec![Action::Shown(format!("{}: {} packages", self.label, total))]
} else {
vec![Action::Hidden]
}
} else if let Some(caps) = unpack_re.captures(line) {
self.done += 1;
vec![
Action::Shown(format!(
"{}: unpacking {} ({})",
self.label, &caps[1], &caps[2]
)),
self.progress(),
]
} else if let Some(caps) = setup_re.captures(line) {
self.done += 1;
vec![
Action::Shown(format!(
"{}: setting up {} ({})",
self.label, &caps[1], &caps[2]
)),
self.progress(),
]
} else if let Some(severity) = apt_severity(line) {
vec![severity]
} else {
vec![Action::Hidden]
}
}
}
/// Classifier for `quilt push -a` output
///
/// Driven by the number of patches listed in `debian/patches/series`, known
/// before the command runs.
pub struct QuiltClassifier {
total: u64,
applied: u64,
}
impl QuiltClassifier {
/// Create a classifier expecting `total` patches
pub fn new(total: usize) -> Self {
Self {
total: total as u64,
applied: 0,
}
}
}
impl Classifier for QuiltClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
static APPLYING_RE: OnceLock<Regex> = OnceLock::new();
let applying_re =
APPLYING_RE.get_or_init(|| Regex::new(r"^Applying patch ([^ ]+)").unwrap());
if line.contains("failed") || line.contains("Failed") {
// Check failures first: "Applying patch x failed" must not be
// counted as a successful application
vec![Action::Error(truncate(line))]
} else if let Some(caps) = applying_re.captures(line) {
self.applied += 1;
let mut actions = vec![Action::Shown(format!("Applying patch {}", &caps[1]))];
if self.total > 0 {
actions.push(Action::Progress {
pos: self.applied.min(self.total),
total: self.total,
});
}
actions
} else if line.starts_with("Now at patch") {
vec![Action::Shown(truncate(line))]
} else {
vec![Action::Hidden]
}
}
}
/// Classifier for make/cmake-based builds (`debian/rules build`, dh helpers)
///
/// Detects `[ 42%]`-style progress markers, hides directory enter/leave
/// noise, and shows compile/link/dh lines.
#[derive(Default)]
pub struct MakeClassifier {}
impl MakeClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for MakeClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
static PERCENT_RE: OnceLock<Regex> = OnceLock::new();
let percent_re = PERCENT_RE.get_or_init(|| Regex::new(r"\[\s*(\d+)%\]").unwrap());
if let Some(caps) = percent_re.captures(line) {
let pct: u64 = caps[1].parse().unwrap_or(0);
return vec![
Action::Shown(truncate(line)),
Action::Progress {
pos: pct,
total: 100,
},
];
}
if line.contains("make[")
&& (line.contains("Entering directory") || line.contains("Leaving directory"))
{
return vec![Action::Hidden];
}
if line.contains("error:")
|| line.contains("Error ")
|| line.contains("*** [")
|| line.contains("failed")
{
return vec![Action::Error(truncate(line))];
}
if line.starts_with("dh_")
|| line.contains("gcc ")
|| line.contains("g++ ")
|| line.contains("cc ")
|| line.contains("clang")
|| line.contains("ld ")
|| line.contains("ar ")
{
return vec![Action::Shown(truncate(line))];
}
vec![Action::Hidden]
}
}
/// Classifier for `dpkg-source` output (source-build phases)
///
/// The build pipeline pins `LC_ALL=C`, so dpkg-source emits stable English
/// messages prefixed with `info:` / `warning:` / `error:`; the prefix is
/// stripped and the severity drives the pane color. Raw `tar:` diagnostics
/// emitted while repacking tarballs are surfaced too.
#[derive(Default)]
pub struct DpkgSourceClassifier {}
impl DpkgSourceClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for DpkgSourceClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
const PREFIX: &str = "dpkg-source: ";
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
if let Some(rest) = rest.strip_prefix("info: ") {
vec![Action::Shown(truncate(rest))]
} else if let Some(rest) = rest.strip_prefix("warning: ") {
vec![Action::Warning(truncate(rest))]
} else if let Some(rest) = rest.strip_prefix("error: ") {
vec![Action::Error(truncate(rest))]
} else if let Some(tar) = rest.strip_prefix("tar: ") {
// Diagnostics from the tarball repacking subprocess; warnings
// about unknown header keywords are benign, real failures are not.
let lower = tar.to_lowercase();
if ["error", "cannot", "failed", "exited"]
.iter()
.any(|m| lower.contains(m))
{
vec![Action::Error(truncate(tar))]
} else {
vec![Action::Warning(truncate(tar))]
}
} else if line == PREFIX.trim_end() || rest.is_empty() {
vec![Action::Hidden]
} else {
// Unprefixed output from a foreign subprocess: keep it visible
vec![Action::Shown(truncate(line))]
}
}
}
/// Classifier for `mmdebstrap` output (chroot tarball creation)
///
/// mmdebstrap prefixes its own messages with `I:` / `W:` / `E:`; everything
/// else is chroot-internal apt/dpkg noise.
#[derive(Default)]
pub struct MmdebstrapClassifier {}
impl MmdebstrapClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for MmdebstrapClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
if let Some(rest) = line.strip_prefix("I: ") {
vec![Action::Shown(truncate(rest))]
} else if let Some(rest) = line.strip_prefix("W: ") {
vec![Action::Warning(truncate(rest))]
} else if let Some(rest) = line.strip_prefix("E: ") {
vec![Action::Error(truncate(rest))]
} else if line.starts_with("Setting up ") {
vec![Action::Shown(truncate(line))]
} else {
vec![Action::Hidden]
}
}
}
/// Generic fallback classifier: shows the last meaningful line and surfaces
/// obvious error/warning patterns.
#[derive(Default)]
pub struct GenericClassifier {}
impl GenericClassifier {
/// Create a new classifier
pub fn new() -> Self {
Self::default()
}
}
impl Classifier for GenericClassifier {
fn feed(&mut self, _stream: Stream, line: &str) -> Vec<Action> {
if line.starts_with("E:")
|| line.contains("error:")
|| line.contains("Error ")
|| line.contains("failed")
{
vec![Action::Error(truncate(line))]
} else if line.starts_with("W:") {
vec![Action::Warning(truncate(line))]
} else {
vec![Action::Shown(truncate(line))]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_one(c: &mut dyn Classifier, line: &str) -> Vec<Action> {
c.feed(Stream::Stdout, line)
}
#[test]
fn test_apt_update_collapses_sources_and_surfaces_errors() {
let mut c = AptUpdateClassifier::new();
assert_eq!(
feed_one(&mut c, "Hit:1 http://archive.ubuntu.com noble InRelease"),
vec![Action::Shown(
"Updating package lists… (1 sources)".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"Get:2 http://security.ubuntu.com noble-security InRelease"
),
vec![Action::Shown(
"Updating package lists… (2 sources)".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "E: Repository 'x' changed its 'suite' value"),
vec![Action::Error(
"E: Repository 'x' changed its 'suite' value".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "Reading package lists..."),
vec![Action::Hidden]
);
}
#[test]
fn test_apt_install_counts_packages() {
let mut c = AptInstallClassifier::new("Installing build dependencies");
// Summary line sets the total
assert_eq!(
feed_one(
&mut c,
"2 upgraded, 3 newly installed, 0 to remove and 0 not upgraded."
),
vec![Action::Shown(
"Installing build dependencies: 5 packages".to_string()
)]
);
// Unpacking and setting up drive progress
assert_eq!(
feed_one(&mut c, "Unpacking libfoo (1.2-3)"),
vec![
Action::Shown(
"Installing build dependencies: unpacking libfoo (1.2-3)".to_string()
),
Action::Progress { pos: 1, total: 5 }
]
);
assert_eq!(
feed_one(&mut c, "Setting up libfoo (1.2-3)"),
vec![
Action::Shown(
"Installing build dependencies: setting up libfoo (1.2-3)".to_string()
),
Action::Progress { pos: 2, total: 5 }
]
);
}
#[test]
fn test_quilt_counts_patches() {
let mut c = QuiltClassifier::new(2);
assert_eq!(
feed_one(&mut c, "Applying patch debian/patches/foo.patch"),
vec![
Action::Shown("Applying patch debian/patches/foo.patch".to_string()),
Action::Progress { pos: 1, total: 2 }
]
);
assert_eq!(
feed_one(&mut c, "Applying patch debian/patches/bar.patch"),
vec![
Action::Shown("Applying patch debian/patches/bar.patch".to_string()),
Action::Progress { pos: 2, total: 2 }
]
);
assert_eq!(
feed_one(&mut c, "Applying patch x failed"),
vec![Action::Error("Applying patch x failed".to_string())]
);
}
#[test]
fn test_make_detects_percent_and_hides_noise() {
let mut c = MakeClassifier::new();
assert_eq!(
feed_one(
&mut c,
"[ 42%] Building CXX object CMakeFiles/hello.dir/hello.o"
),
vec![
Action::Shown(
"[ 42%] Building CXX object CMakeFiles/hello.dir/hello.o".to_string()
),
Action::Progress {
pos: 42,
total: 100
}
]
);
assert_eq!(
feed_one(&mut c, "make[2]: Entering directory '/tmp/build'"),
vec![Action::Hidden]
);
assert_eq!(
feed_one(&mut c, "make[1]: *** [Makefile:531: hello.o] Error 1"),
vec![Action::Error(
"make[1]: *** [Makefile:531: hello.o] Error 1".to_string()
)]
);
assert_eq!(
feed_one(&mut c, "dh_auto_build"),
vec![Action::Shown("dh_auto_build".to_string())]
);
}
#[test]
fn test_mmdebstrap_prefixes() {
let mut c = MmdebstrapClassifier::new();
assert_eq!(
feed_one(&mut c, "I: chroot architecture is amd64"),
vec![Action::Shown("chroot architecture is amd64".to_string())]
);
assert_eq!(
feed_one(&mut c, "W: some warning"),
vec![Action::Warning("some warning".to_string())]
);
assert_eq!(
feed_one(&mut c, "Get:1 http://x InRelease"),
vec![Action::Hidden]
);
}
#[test]
fn test_generic_shows_lines_and_errors() {
let mut c = GenericClassifier::new();
assert_eq!(
feed_one(&mut c, "some random output"),
vec![Action::Shown("some random output".to_string())]
);
assert_eq!(
feed_one(&mut c, "something failed badly"),
vec![Action::Error("something failed badly".to_string())]
);
}
#[test]
fn test_dpkg_source_severity_prefixes() {
let mut c = DpkgSourceClassifier::new();
assert_eq!(
feed_one(
&mut c,
"dpkg-source: info: using patch list from debian/patches/series"
),
vec![Action::Shown(
"using patch list from debian/patches/series".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: info: applying patch debian/patches/reproducible.patch"
),
vec![Action::Shown(
"applying patch debian/patches/reproducible.patch".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: info: building hello in ../hello_2.10-5.dsc"
),
vec![Action::Shown(
"building hello in ../hello_2.10-5.dsc".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: warning: upstream signing key but no upstream signature"
),
vec![Action::Warning(
"upstream signing key but no upstream signature".to_string()
)]
);
assert_eq!(
feed_one(
&mut c,
"dpkg-source: error: unrepresentable changes to source"
),
vec![Action::Error(
"unrepresentable changes to source".to_string()
)]
);
}
#[test]
fn test_dpkg_source_tar_and_unknown_lines() {
let mut c = DpkgSourceClassifier::new();
// Benign tar header-keyword warnings stay yellow
assert_eq!(
feed_one(
&mut c,
"tar: Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'"
),
vec![Action::Warning(
"Ignoring unknown extended header keyword 'SCHILY.xattr.user.foo'".to_string()
)]
);
// Real tar failures are errors
assert_eq!(
feed_one(
&mut c,
"tar: ../hello_2.10.orig.tar.xz: Cannot open: No such file or directory"
),
vec![Action::Error(
"../hello_2.10.orig.tar.xz: Cannot open: No such file or directory".to_string()
)]
);
// Unprefixed foreign output stays visible
assert_eq!(
feed_one(&mut c, "gpgv: Signature made Tue 01 Jan 2026"),
vec![Action::Shown(
"gpgv: Signature made Tue 01 Jan 2026".to_string()
)]
);
}
#[test]
fn test_truncate_long_lines() {
let long = "x".repeat(300);
let truncated = truncate(&long);
assert_eq!(truncated.chars().count(), MAX_LINE_WIDTH);
assert!(truncated.ends_with('…'));
assert_eq!(truncate("short"), "short");
}
}
+11
View File
@@ -127,6 +127,17 @@ 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, falling back to the default
/// answer when no interactive terminal is attached (CI, piped input).
pub struct TerminalPrompter;
impl crate::report::Prompter for TerminalPrompter {
fn confirm(&self, question: &str, default: bool) -> bool {
confirm(question, default).unwrap_or(default)
}
}
/// Run `prompt` with the terminal in raw mode, always restoring it
/// afterwards. Fails with [`PromptError::NotATty`] when raw mode cannot be
/// enabled.