diff --git a/Cargo.lock b/Cargo.lock index 460a342..ca9e6df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,22 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "assert_cmd" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-compression" version = "0.4.27" @@ -172,6 +188,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "regex-automata 0.4.9", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -374,6 +401,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -416,6 +449,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "either" version = "1.15.0" @@ -1113,6 +1152,7 @@ name = "polyscribe-cli" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", "clap", "clap_complete", "clap_mangen", @@ -1191,6 +1231,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.36" @@ -1699,6 +1766,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "textwrap" version = "0.16.2" @@ -2055,6 +2128,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/crates/polyscribe-cli/Cargo.toml b/crates/polyscribe-cli/Cargo.toml index b783297..a6b67a2 100644 --- a/crates/polyscribe-cli/Cargo.toml +++ b/crates/polyscribe-cli/Cargo.toml @@ -3,6 +3,10 @@ name = "polyscribe-cli" version = "0.1.0" edition = "2024" +[[bin]] +name = "polyscribe" +path = "src/main.rs" + [dependencies] anyhow = "1.0.99" clap = { version = "4.5.44", features = ["derive"] } @@ -23,3 +27,6 @@ polyscribe-protocol = { path = "../polyscribe-protocol" } [features] # Optional GPU-specific flags can be forwarded down to core/host if needed default = [] + +[dev-dependencies] +assert_cmd = "2.0.16" diff --git a/crates/polyscribe-cli/src/cli.rs b/crates/polyscribe-cli/src/cli.rs index d886d0a..3c22b10 100644 --- a/crates/polyscribe-cli/src/cli.rs +++ b/crates/polyscribe-cli/src/cli.rs @@ -25,6 +25,10 @@ pub struct Cli { #[arg(long, default_value_t = false)] pub no_interaction: bool, + /// Disable progress bars/spinners + #[arg(long, default_value_t = false)] + pub no_progress: bool, + #[command(subcommand)] pub command: Commands, } diff --git a/crates/polyscribe-cli/src/main.rs b/crates/polyscribe-cli/src/main.rs index f71a29a..5883b2e 100644 --- a/crates/polyscribe-cli/src/main.rs +++ b/crates/polyscribe-cli/src/main.rs @@ -35,10 +35,11 @@ async fn main() -> Result<()> { init_tracing(args.quiet, args.verbose); - // Optionally propagate quiet/no-interaction/verbosity to core if your lib exposes setters. - // polyscribe_core::set_quiet(args.quiet); - // polyscribe_core::set_no_interaction(args.no_interaction); - // polyscribe_core::set_verbose(args.verbose); + // Propagate UI flags to core so ui facade can apply policy + polyscribe_core::set_quiet(args.quiet); + polyscribe_core::set_no_interaction(args.no_interaction); + polyscribe_core::set_verbose(args.verbose); + polyscribe_core::set_no_progress(args.no_progress); let _cfg = ConfigService::load_or_default().context("loading configuration")?; diff --git a/crates/polyscribe-cli/tests/integration_aux.rs b/crates/polyscribe-cli/tests/integration_aux.rs index 2f4e76c..158035f 100644 --- a/crates/polyscribe-cli/tests/integration_aux.rs +++ b/crates/polyscribe-cli/tests/integration_aux.rs @@ -2,11 +2,9 @@ // Copyright (c) 2025 . All rights reserved. use std::process::Command; +use assert_cmd::cargo::cargo_bin; -fn bin() -> String { - std::env::var("CARGO_BIN_EXE_polyscribe") - .unwrap_or_else(|_| "polyscribe".to_string()) -} +fn bin() -> std::path::PathBuf { cargo_bin("polyscribe") } #[test] fn aux_completions_bash_outputs_script() { diff --git a/crates/polyscribe-core/src/models.rs b/crates/polyscribe-core/src/models.rs index 5573176..3c52b59 100644 --- a/crates/polyscribe-core/src/models.rs +++ b/crates/polyscribe-core/src/models.rs @@ -17,7 +17,6 @@ use std::collections::BTreeSet; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::thread; use std::time::{Duration, Instant}; fn format_size_mb(size: Option) -> String { @@ -712,16 +711,7 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { crate::ui::info(format!("Download: {}", part_path.display())); let pb_total = total_len.unwrap_or(0); - let mut bar = None; - let mut sp: Option = None; - if pb_total > 0 { - let mut b = cliclack::progress_bar(pb_total); - b.start("Downloading"); - if resume_from > 0 { b.inc(resume_from); } - bar = Some(b); - } else { - sp = Some(crate::ui::Spinner::start("Downloading")); - } + let mut bar = crate::ui::BytesProgress::start(pb_total, "Downloading", resume_from); let start = Instant::now(); let mut resp = req.send()?.error_for_status()?; @@ -744,12 +734,8 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { // Plain GET without conditional headers let mut req2 = client.get(url); resp = req2.send()?.error_for_status()?; - if let Some(b) = bar.as_mut() { - b.stop("restarting"); - } - let mut b2 = cliclack::progress_bar(pb_total); - b2.start("Downloading"); - bar = Some(b2); + bar.stop("restarting"); + bar = crate::ui::BytesProgress::start(pb_total, "Downloading", 0); // Reopen the part file since we dropped it part_file = OpenOptions::new() @@ -770,23 +756,13 @@ fn download_with_progress(dest_path: &Path, entry: &ModelEntry) -> Result<()> { break; } part_file.write_all(&buf[..read])?; - if pb_total > 0 { - if let Some(b) = bar.as_mut() { - b.inc(read as u64); - } - } else { - // spinner: nothing to update per chunk beyond the animation - } + bar.inc(read as u64); } part_file.flush()?; part_file.sync_all()?; } - if pb_total > 0 { - if let Some(b) = bar.as_mut() { b.stop("done"); } - } else { - if let Some(s) = sp.take() { s.success("done"); } - } + bar.stop("done"); if let Some(expected_hex) = entry.sha256.as_deref() { crate::ui::info("Verify: SHA-256"); diff --git a/crates/polyscribe-core/src/ui.rs b/crates/polyscribe-core/src/ui.rs index bb3da7a..4258dd8 100644 --- a/crates/polyscribe-core/src/ui.rs +++ b/crates/polyscribe-core/src/ui.rs @@ -8,6 +8,7 @@ pub mod progress; use std::io; +use std::io::IsTerminal; /// Log an informational message. pub fn info(msg: impl AsRef) { @@ -57,6 +58,9 @@ pub fn println_above_bars(line: impl AsRef) { /// Returns default if provided and user enters empty string. /// In non-interactive workflows, callers should skip prompt based on their flags. pub fn prompt_input(prompt: &str, default: Option<&str>) -> io::Result { + if crate::is_no_interaction() || !crate::stdin_is_tty() { + return Ok(default.unwrap_or("").to_string()); + } let mut q = cliclack::input(prompt); if let Some(def) = default { q = q.default_input(def); } q.interact().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) @@ -64,6 +68,9 @@ pub fn prompt_input(prompt: &str, default: Option<&str>) -> io::Result { /// Present a single-choice selector and return the selected index. pub fn prompt_select<'a>(prompt: &str, items: &[&'a str]) -> io::Result { + if crate::is_no_interaction() || !crate::stdin_is_tty() { + return Err(io::Error::new(io::ErrorKind::Other, "interactive prompt disabled")); + } let mut sel = cliclack::select::(prompt); for (idx, label) in items.iter().enumerate() { sel = sel.item(idx, *label, ""); @@ -74,6 +81,9 @@ pub fn prompt_select<'a>(prompt: &str, items: &[&'a str]) -> io::Result { /// Present a multi-choice selector and return indices of selected items. pub fn prompt_multi_select<'a>(prompt: &str, items: &[&'a str], defaults: Option<&[bool]>) -> io::Result> { + if crate::is_no_interaction() || !crate::stdin_is_tty() { + return Err(io::Error::new(io::ErrorKind::Other, "interactive prompt disabled")); + } let mut ms = cliclack::multiselect::(prompt); for (idx, label) in items.iter().enumerate() { ms = ms.item(idx, *label, ""); @@ -92,6 +102,41 @@ pub fn prompt_multi_select<'a>(prompt: &str, items: &[&'a str], defaults: Option .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) } +/// Confirm prompt with default, respecting non-interactive mode. +pub fn prompt_confirm(prompt: &str, default: bool) -> io::Result { + if crate::is_no_interaction() || !crate::stdin_is_tty() { + return Ok(default); + } + let mut q = cliclack::confirm(prompt); + // If `cliclack::confirm` lacks default, we simply ask; caller can handle ESC/cancel if needed. + q.interact().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +/// Read a secret/password without echoing, respecting non-interactive mode. +pub fn prompt_password(prompt: &str) -> io::Result { + if crate::is_no_interaction() || !crate::stdin_is_tty() { + return Err(io::Error::new(io::ErrorKind::Other, "password prompt disabled in non-interactive mode")); + } + let mut q = cliclack::password(prompt); + q.interact().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +/// Input with validation closure; on non-interactive returns default or error when no default. +pub fn prompt_input_validated(prompt: &str, default: Option<&str>, validate: F) -> io::Result +where + F: Fn(&str) -> Result<(), String> + 'static, +{ + if crate::is_no_interaction() || !crate::stdin_is_tty() { + if let Some(def) = default { return Ok(def.to_string()); } + return Err(io::Error::new(io::ErrorKind::Other, "interactive prompt disabled")); + } + let mut q = cliclack::input(prompt); + if let Some(def) = default { q = q.default_input(def); } + q.validate(move |s: &String| validate(s)) + .interact() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + /// A simple spinner wrapper built on top of `cliclack::spinner()`. /// /// This wrapper provides a minimal API with start/stop/success/error methods @@ -101,24 +146,75 @@ pub struct Spinner(cliclack::ProgressBar); impl Spinner { /// Creates and starts a new spinner with the provided status text. pub fn start(text: impl AsRef) -> Self { - let s = cliclack::spinner(); - s.start(text.as_ref()); - Self(s) + if crate::is_no_progress() || crate::is_no_interaction() || !std::io::stderr().is_terminal() { + // Fallback: no spinner, but log start + let _ = cliclack::log::info(text.as_ref()); + let s = cliclack::spinner(); + Self(s) + } else { + let s = cliclack::spinner(); + s.start(text.as_ref()); + Self(s) + } } /// Stops the spinner with a submitted/completed style and message. pub fn stop(self, text: impl AsRef) { let s = self.0; - s.stop(text.as_ref()); + if crate::is_no_progress() { + let _ = cliclack::log::info(text.as_ref()); + } else { + s.stop(text.as_ref()); + } } /// Marks the spinner as successfully finished (alias for `stop`). pub fn success(self, text: impl AsRef) { let s = self.0; // cliclack progress bar uses `stop` for successful completion styling - s.stop(text.as_ref()); + if crate::is_no_progress() { + let _ = cliclack::log::success(text.as_ref()); + } else { + s.stop(text.as_ref()); + } } /// Marks the spinner as failed with an error style and message. pub fn error(self, text: impl AsRef) { let s = self.0; - s.error(text.as_ref()); + if crate::is_no_progress() { + let _ = cliclack::log::error(text.as_ref()); + } else { + s.error(text.as_ref()); + } + } +} + +/// Byte-count progress bar that respects `--no-progress` and TTY state. +pub struct BytesProgress(Option); + +impl BytesProgress { + /// Start a new progress bar with a total and initial position. + pub fn start(total: u64, text: &str, initial: u64) -> Self { + if crate::is_no_progress() || crate::is_no_interaction() || !std::io::stderr().is_terminal() || total == 0 { + let _ = cliclack::log::info(text); + return Self(None); + } + let mut b = cliclack::progress_bar(total); + b.start(text); + if initial > 0 { b.inc(initial); } + Self(Some(b)) + } + + /// Increment by delta bytes. + pub fn inc(&mut self, delta: u64) { + if let Some(b) = self.0.as_mut() { b.inc(delta); } + } + + /// Stop with a message. + pub fn stop(mut self, text: &str) { + if let Some(b) = self.0.take() { b.stop(text); } else { let _ = cliclack::log::info(text); } + } + + /// Mark as error with a message. + pub fn error(mut self, text: &str) { + if let Some(b) = self.0.take() { b.error(text); } else { let _ = cliclack::log::error(text); } } }