diff --git a/src/build/mod.rs b/src/build/mod.rs index f7e65ce..0415194 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -170,10 +170,13 @@ fn retry_after_revendor( original: Box, ) -> Result> { log::error!("{original}"); - if !prompter.confirm( - "Re-vendor the Cargo dependencies and retry the build?", - false, - ) { + if !prompter + .confirm( + "Re-vendor the Cargo dependencies and retry the build?", + false, + ) + .unwrap_or(false) + { view.finish_failure(); return Err(original); } diff --git a/src/main.rs b/src/main.rs index 6ae419e..c37f330 100644 --- a/src/main.rs +++ b/src/main.rs @@ -518,7 +518,7 @@ fn main() { // print them as plain lines. if !view.is_enabled() { for artifact in output.artifacts() { - println!(" {}", pkh::ui::display_path(&artifact)); + println!(" {}", pkh::report::display_path(&artifact)); } } if output.signed { diff --git a/src/new/debian.rs b/src/new/debian.rs index 26cfe05..1c61648 100644 --- a/src/new/debian.rs +++ b/src/new/debian.rs @@ -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) } diff --git a/src/new/mod.rs b/src/new/mod.rs index 18e9cc9..1cd29a0 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -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 `` 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 { diff --git a/src/new/orig.rs b/src/new/orig.rs index 4636f12..b3e3843 100644 --- a/src/new/orig.rs +++ b/src/new/orig.rs @@ -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) } diff --git a/src/new/questions.rs b/src/new/questions.rs index 3436ecd..832d338 100644 --- a/src/new/questions.rs +++ b/src/new/questions.rs @@ -555,7 +555,7 @@ pub async fn offer_verification( no_verify: bool, ) { 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 { diff --git a/src/put/mod.rs b/src/put/mod.rs index 1d58f8c..d696109 100644 --- a/src/put/mod.rs +++ b/src/put/mod.rs @@ -521,7 +521,7 @@ fn discover_changes(cwd: &Path) -> Result> { Pass the one to upload explicitly", entry.source, many.iter() - .map(|p| ui::display_path(p)) + .map(|p| crate::report::display_path(p)) .collect::>() .join(", ") ) diff --git a/src/report.rs b/src/report.rs index 84529ca..9cc43ed 100644 --- a/src/report.rs +++ b/src/report.rs @@ -14,13 +14,20 @@ //! 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::path::PathBuf; +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, Copy)] @@ -94,18 +101,51 @@ pub trait BuildView: Send + Sync { } } -/// Answerer of the questions a core flow may ask mid-run (confirmations -/// now, selections and free-text input as more flows migrate). +/// 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 or the question is cancelled. - fn confirm(&self, _question: &str, default: bool) -> bool { - default + /// 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> { + 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> { + 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> { + Ok(default.to_string()) } } @@ -116,3 +156,24 @@ 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() +} diff --git a/src/ui.rs b/src/ui.rs index 188502b..a024359 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,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() diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 925090b..67992de 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -232,7 +232,7 @@ impl DebUi { 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)); } } } diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 8bdefd0..0e68d05 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -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 @@ -128,13 +127,37 @@ pub fn confirm(label: &str, default: bool) -> Result bool { - confirm(question, default).unwrap_or(default) + fn interactive(&self) -> bool { + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() + } + + fn confirm(&self, question: &str, default: bool) -> Result> { + confirm(question, default) + } + + fn select( + &self, + label: &str, + options: &[String], + default: &str, + ) -> Result> { + select(label, options, default) + } + + fn text( + &self, + label: &str, + default: &str, + validate: Option<&Validator>, + ) -> Result> { + text(label, default, validate) } }