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.
This commit is contained in:
2026-09-18 20:34:33 +02:00
parent 052c02cdc3
commit d64e472845
11 changed files with 115 additions and 47 deletions
+7 -4
View File
@@ -170,10 +170,13 @@ fn retry_after_revendor(
original: Box<dyn Error>, original: Box<dyn Error>,
) -> Result<SourceBuildOutput, Box<dyn Error>> { ) -> Result<SourceBuildOutput, Box<dyn Error>> {
log::error!("{original}"); log::error!("{original}");
if !prompter.confirm( if !prompter
"Re-vendor the Cargo dependencies and retry the build?", .confirm(
false, "Re-vendor the Cargo dependencies and retry the build?",
) { false,
)
.unwrap_or(false)
{
view.finish_failure(); view.finish_failure();
return Err(original); return Err(original);
} }
+1 -1
View File
@@ -518,7 +518,7 @@ fn main() {
// print them as plain lines. // print them as plain lines.
if !view.is_enabled() { if !view.is_enabled() {
for artifact in output.artifacts() { for artifact in output.artifacts() {
println!(" {}", pkh::ui::display_path(&artifact)); println!(" {}", pkh::report::display_path(&artifact));
} }
} }
if output.signed { if output.signed {
+1 -1
View File
@@ -436,7 +436,7 @@ pub fn create_orig_tarball_excluding(
log::info!( log::info!(
"Created orig tarball {}", "Created orig tarball {}",
crate::ui::display_path(&tarball_path) crate::report::display_path(&tarball_path)
); );
Ok(tarball_path) Ok(tarball_path)
} }
+1 -1
View File
@@ -251,7 +251,7 @@ fn print_success(opts: &NewOptions, outcome: &ScaffoldOutcome) {
// `display_path` yields an empty string when the target is the cwd // `display_path` yields an empty string when the target is the cwd
// itself (Here mode): `Created .` would be cryptic, so spell the // itself (Here mode): `Created .` would be cryptic, so spell the
// location out; the skeleton/path modes keep the `<dir>` display. // location out; the skeleton/path modes keep the `<dir>` display.
let display = crate::ui::display_path(&target); let display = crate::report::display_path(&target);
let location = if display.is_empty() { let location = if display.is_empty() {
"package in the current directory".to_string() "package in the current directory".to_string()
} else { } else {
+4 -4
View File
@@ -183,7 +183,7 @@ pub fn create_vendor_component(
log::info!( log::info!(
"Created vendored-dependencies component {}", "Created vendored-dependencies component {}",
crate::ui::display_path(&component_path) crate::report::display_path(&component_path)
); );
Ok(component_path) Ok(component_path)
} }
@@ -283,7 +283,7 @@ fn git_archive_tarball(
log::info!( log::info!(
"Created orig tarball from git archive of {tag}: {}", "Created orig tarball from git archive of {tag}: {}",
crate::ui::display_path(&dest) crate::report::display_path(&dest)
); );
Ok(dest) Ok(dest)
} }
@@ -311,7 +311,7 @@ fn download_release(
Ok(path) => { Ok(path) => {
log::info!( log::info!(
"Created orig tarball from the release download of {tag}: {}", "Created orig tarball from the release download of {tag}: {}",
crate::ui::display_path(&path) crate::report::display_path(&path)
); );
Ok(path) Ok(path)
} }
@@ -352,7 +352,7 @@ fn fetch_and_repack(
log::info!( log::info!(
"Created orig tarball from {}: {}", "Created orig tarball from {}: {}",
source, source,
crate::ui::display_path(&dest) crate::report::display_path(&dest)
); );
Ok(dest) Ok(dest)
} }
+1 -1
View File
@@ -555,7 +555,7 @@ pub async fn offer_verification(
no_verify: bool, no_verify: bool,
) { ) {
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default()); let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
let display = crate::ui::display_path(&tree); let display = crate::report::display_path(&tree);
let display = if display.is_empty() { let display = if display.is_empty() {
".".to_string() ".".to_string()
} else { } else {
+1 -1
View File
@@ -521,7 +521,7 @@ fn discover_changes(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
Pass the one to upload explicitly", Pass the one to upload explicitly",
entry.source, entry.source,
many.iter() many.iter()
.map(|p| ui::display_path(p)) .map(|p| crate::report::display_path(p))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
) )
+67 -6
View File
@@ -14,13 +14,20 @@
//! implementations used by headless runs and tests. [`Prompter`] is //! implementations used by headless runs and tests. [`Prompter`] is
//! deliberately blocking: an implementation may round-trip each question to //! deliberately blocking: an implementation may round-trip each question to
//! a remote user, as long as it eventually answers (or takes the default). //! 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::path::PathBuf; use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use crate::context::LineSink; use crate::context::LineSink;
use crate::logfmt::Classifier; 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 /// Identity of the build whose events follow, as announced through
/// [`BuildView::target`]. /// [`BuildView::target`].
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -94,18 +101,51 @@ pub trait BuildView: Send + Sync {
} }
} }
/// Answerer of the questions a core flow may ask mid-run (confirmations /// Answerer of the questions a core flow may ask mid-run.
/// now, selections and free-text input as more flows migrate).
/// ///
/// Questions are blocking on purpose: a terminal implementation waits for /// Questions are blocking on purpose: a terminal implementation waits for
/// key presses, and a builder-server implementation may forward the question /// key presses, and a builder-server implementation may forward the question
/// to a web client and await the answer on a channel. Implementations that /// to a web client and await the answer on a channel. Implementations that
/// cannot ask anyone answer with the question's default. /// cannot ask anyone answer with the question's default.
pub trait Prompter: Send + Sync { 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 /// Ask a yes/no question. `default` is the answer to take when no user
/// can be reached or the question is cancelled. /// can be reached; `Err` means the question was cancelled (Ctrl+C,
fn confirm(&self, _question: &str, default: bool) -> bool { /// dropped connection) and the flow should abort.
default 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())
} }
} }
@@ -116,3 +156,24 @@ pub struct Quiet;
impl BuildView for Quiet {} impl BuildView for Quiet {}
impl Prompter 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()
}
-19
View File
@@ -10,27 +10,8 @@ pub mod prompt;
#[cfg(test)] #[cfg(test)]
use indicatif::ProgressDrawTarget; use indicatif::ProgressDrawTarget;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::path::Path;
use std::time::Duration; use std::time::Duration;
/// Render a path for terminal display: relative to the current working
/// directory when the target lives inside it or directly next to it
/// (`../name`, the usual layout of build artifacts), absolute otherwise.
pub fn display_path(path: &Path) -> String {
let Ok(cwd) = std::env::current_dir() else {
return path.display().to_string();
};
if let Ok(rel) = path.strip_prefix(&cwd) {
return rel.display().to_string();
}
if let Some(parent) = cwd.parent()
&& let Ok(rel) = path.strip_prefix(parent)
{
return format!("../{}", rel.display());
}
path.display().to_string()
}
/// Style of an unsized operation: spinner and prefix on one line /// Style of an unsized operation: spinner and prefix on one line
pub(crate) fn spinner_style() -> ProgressStyle { pub(crate) fn spinner_style() -> ProgressStyle {
ProgressStyle::default_bar() ProgressStyle::default_bar()
+1 -1
View File
@@ -232,7 +232,7 @@ impl DebUi {
if self.shared.enabled && !artifacts.is_empty() { if self.shared.enabled && !artifacts.is_empty() {
println!("Built in {}s:", elapsed.as_secs()); println!("Built in {}s:", elapsed.as_secs());
for artifact in artifacts { for artifact in artifacts {
println!(" {}", crate::ui::display_path(artifact)); println!(" {}", crate::report::display_path(artifact));
} }
} }
} }
+31 -8
View File
@@ -12,7 +12,7 @@ use crossterm::{
style::{self, Color, Print, SetForegroundColor}, style::{self, Color, Print, SetForegroundColor},
terminal, terminal,
}; };
use std::io::{self, Write}; use std::io::{self, IsTerminal, Write};
use std::time::Duration; use std::time::Duration;
/// Why a prompt could not run interactively /// Why a prompt could not run interactively
@@ -32,9 +32,8 @@ impl std::fmt::Display for PromptError {
impl std::error::Error for PromptError {} impl std::error::Error for PromptError {}
/// Answer validator of [`text`]: accepts the answer, or explains why it is /// Answer validator of [`text`] (re-exported from [`crate::report`])
/// rejected pub use crate::report::Validator;
pub type Validator = dyn Fn(&str) -> Result<(), String>;
/// What a prompt's event loop asks its drawing helper to render; keeping all /// What a prompt's event loop asks its drawing helper to render; keeping all
/// terminal access behind this callback makes the loops pure logic that unit /// terminal access behind this callback makes the loops pure logic that unit
@@ -128,13 +127,37 @@ pub fn confirm(label: &str, default: bool) -> Result<bool, Box<dyn std::error::E
} }
/// [`crate::report::Prompter`] answered by the interactive terminal: each /// [`crate::report::Prompter`] answered by the interactive terminal: each
/// question runs the matching raw-mode prompt, falling back to the default /// question runs the matching raw-mode prompt. Without an interactive
/// answer when no interactive terminal is attached (CI, piped input). /// 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; pub struct TerminalPrompter;
impl crate::report::Prompter for TerminalPrompter { impl crate::report::Prompter for TerminalPrompter {
fn confirm(&self, question: &str, default: bool) -> bool { fn interactive(&self) -> bool {
confirm(question, default).unwrap_or(default) 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)
} }
} }