[refactor] streamline crate structure, update dependencies, and integrate CLI functionalities
This commit is contained in:
@@ -1,149 +1,108 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Simple ConfigService with XDG/system/workspace merge and atomic writes
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
use crate::prelude::*;
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
/// Generic configuration represented as TOML table
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config(pub toml::value::Table);
|
||||
const ENV_PREFIX: &str = "POLYSCRIBE";
|
||||
|
||||
impl Config {
|
||||
/// Get a mutable reference to a top-level table under the given key, creating
|
||||
/// an empty table if it does not exist yet.
|
||||
pub fn get_table_mut(&mut self, key: &str) -> &mut toml::value::Table {
|
||||
let needs_init = !matches!(self.0.get(key), Some(toml::Value::Table(_)));
|
||||
if needs_init {
|
||||
self.0.insert(key.to_string(), toml::Value::Table(Default::default()));
|
||||
}
|
||||
match self.0.get_mut(key) {
|
||||
Some(toml::Value::Table(t)) => t,
|
||||
_ => unreachable!(),
|
||||
/// Configuration for the Polyscribe application
|
||||
///
|
||||
/// Contains paths to models and plugins directories that can be customized
|
||||
/// through configuration files or environment variables.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Directory path where ML models are stored
|
||||
pub models_dir: Option<PathBuf>,
|
||||
/// Directory path where plugins are stored
|
||||
pub plugins_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
models_dir: None,
|
||||
plugins_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_tables(base: &mut toml::value::Table, overlay: &toml::value::Table) {
|
||||
for (k, v) in overlay.iter() {
|
||||
match (base.get_mut(k), v) {
|
||||
(Some(toml::Value::Table(bsub)), toml::Value::Table(osub)) => {
|
||||
merge_tables(bsub, osub);
|
||||
}
|
||||
_ => {
|
||||
base.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_toml(path: &Path) -> Result<toml::value::Table> {
|
||||
let s = fs::read_to_string(path).with_context(|| format!("Failed to read config: {}", path.display()))?;
|
||||
let v: toml::Value = toml::from_str(&s).with_context(|| format!("Invalid TOML in {}", path.display()))?;
|
||||
Ok(v.as_table().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn write_toml_atomic(path: &Path, tbl: &toml::value::Table) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("Failed to create config dir: {}", parent.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
let mut f = fs::File::create(&tmp).with_context(|| format!("Failed to create temp file: {}", tmp.display()))?;
|
||||
let s = toml::to_string_pretty(&toml::Value::Table(tbl.clone()))?;
|
||||
f.write_all(s.as_bytes())?;
|
||||
if !s.ends_with('\n') { f.write_all(b"\n")?; }
|
||||
drop(f);
|
||||
fs::rename(&tmp, path).with_context(|| format!("Failed to atomically replace config: {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn system_config_path() -> PathBuf {
|
||||
if cfg!(unix) { PathBuf::from("/etc").join("polyscribe").join("config.toml") } else { default_user_config_path() }
|
||||
}
|
||||
|
||||
fn default_user_config_path() -> PathBuf {
|
||||
if let Some(base) = BaseDirs::new() {
|
||||
return PathBuf::from(base.config_dir()).join("polyscribe").join("config.toml");
|
||||
}
|
||||
PathBuf::from(".polyscribe").join("config.toml")
|
||||
}
|
||||
|
||||
fn workspace_config_path() -> PathBuf {
|
||||
PathBuf::from(".polyscribe").join("config.toml")
|
||||
}
|
||||
|
||||
/// Service responsible for loading and saving PolyScribe configuration
|
||||
#[derive(Debug, Default, Clone)]
|
||||
/// Service for managing Polyscribe configuration
|
||||
///
|
||||
/// Provides functionality to load, save, and access configuration settings
|
||||
/// from disk or environment variables.
|
||||
pub struct ConfigService;
|
||||
|
||||
impl ConfigService {
|
||||
/// Load configuration, merging system < user < workspace < env overrides.
|
||||
pub fn load(&self) -> Result<Config> {
|
||||
let mut accum = toml::value::Table::default();
|
||||
let sys = system_config_path();
|
||||
if sys.exists() {
|
||||
merge_tables(&mut accum, &read_toml(&sys)?);
|
||||
}
|
||||
let user = default_user_config_path();
|
||||
if user.exists() {
|
||||
merge_tables(&mut accum, &read_toml(&user)?);
|
||||
}
|
||||
let ws = workspace_config_path();
|
||||
if ws.exists() {
|
||||
merge_tables(&mut accum, &read_toml(&ws)?);
|
||||
}
|
||||
// Env overrides: POLYSCRIBE__SECTION__KEY=value
|
||||
let mut env_over = toml::value::Table::default();
|
||||
for (k, v) in env::vars() {
|
||||
if let Some(rest) = k.strip_prefix("POLYSCRIBE__") {
|
||||
let parts: Vec<&str> = rest.split("__").collect();
|
||||
if parts.is_empty() { continue; }
|
||||
let val: toml::Value = toml::Value::String(v);
|
||||
// Build nested tables
|
||||
let mut current = &mut env_over;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
current.insert(part.to_lowercase(), val.clone());
|
||||
} else {
|
||||
current = current.entry(part.to_lowercase()).or_insert_with(|| toml::Value::Table(Default::default()))
|
||||
.as_table_mut().expect("table");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
merge_tables(&mut accum, &env_over);
|
||||
Ok(Config(accum))
|
||||
/// Loads configuration from disk or returns default values if not found
|
||||
///
|
||||
/// This function attempts to read the configuration file from disk. If the file
|
||||
/// doesn't exist or can't be parsed, it falls back to default values.
|
||||
/// Environment variable overrides are then applied to the configuration.
|
||||
pub fn load_or_default() -> Result<Config> {
|
||||
let mut cfg = Self::read_disk().unwrap_or_default();
|
||||
Self::apply_env_overrides(&mut cfg)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Ensure user config exists with sensible defaults, return loaded config
|
||||
pub fn ensure_user_config(&self) -> Result<Config> {
|
||||
let path = default_user_config_path();
|
||||
if !path.exists() {
|
||||
let mut defaults = toml::value::Table::default();
|
||||
defaults.insert("ui".into(), toml::Value::Table({
|
||||
let mut t = toml::value::Table::default();
|
||||
t.insert("theme".into(), toml::Value::String("auto".into()));
|
||||
t
|
||||
}));
|
||||
write_toml_atomic(&path, &defaults)?;
|
||||
/// Saves the configuration to disk
|
||||
///
|
||||
/// This function serializes the configuration to TOML format and writes it
|
||||
/// to the standard configuration directory for the application.
|
||||
/// Returns an error if writing fails or if project directories cannot be determined.
|
||||
pub fn save(cfg: &Config) -> Result<()> {
|
||||
let Some(dirs) = Self::dirs() else {
|
||||
return Err(Error::Other("unable to get project dirs".into()));
|
||||
};
|
||||
let cfg_dir = dirs.config_dir();
|
||||
fs::create_dir_all(cfg_dir)?;
|
||||
let path = cfg_dir.join("config.toml");
|
||||
let s = toml::to_string_pretty(cfg)?;
|
||||
fs::write(path, s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_disk() -> Option<Config> {
|
||||
let dirs = Self::dirs()?;
|
||||
let path = dirs.config_dir().join("config.toml");
|
||||
let s = fs::read_to_string(path).ok()?;
|
||||
toml::from_str(&s).ok()
|
||||
}
|
||||
|
||||
fn apply_env_overrides(cfg: &mut Config) -> Result<()> {
|
||||
// POLYSCRIBE__SECTION__KEY format reserved for future nested config.
|
||||
if let Ok(v) = std::env::var(format!("{ENV_PREFIX}_MODELS_DIR")) {
|
||||
cfg.models_dir = Some(PathBuf::from(v));
|
||||
}
|
||||
self.load()
|
||||
if let Ok(v) = std::env::var(format!("{ENV_PREFIX}_PLUGINS_DIR")) {
|
||||
cfg.plugins_dir = Some(PathBuf::from(v));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save to user config atomically, merging over existing user file.
|
||||
pub fn save_user(&self, new_values: &toml::value::Table) -> Result<()> {
|
||||
let path = default_user_config_path();
|
||||
let mut base = if path.exists() { read_toml(&path)? } else { Default::default() };
|
||||
merge_tables(&mut base, new_values);
|
||||
write_toml_atomic(&path, &base)
|
||||
/// Returns the standard project directories for the application
|
||||
///
|
||||
/// This function creates a ProjectDirs instance with the appropriate
|
||||
/// organization and application names for Polyscribe.
|
||||
/// Returns None if the project directories cannot be determined.
|
||||
pub fn dirs() -> Option<ProjectDirs> {
|
||||
ProjectDirs::from("dev", "polyscribe", "polyscribe")
|
||||
}
|
||||
|
||||
/// Paths used for debugging/information
|
||||
pub fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
|
||||
(system_config_path(), default_user_config_path(), workspace_config_path())
|
||||
/// Returns the default directory path for storing ML models
|
||||
///
|
||||
/// This function determines the standard data directory for the application
|
||||
/// and appends a 'models' subdirectory to it.
|
||||
/// Returns None if the project directories cannot be determined.
|
||||
pub fn default_models_dir() -> Option<PathBuf> {
|
||||
Self::dirs().map(|d| d.data_dir().join("models"))
|
||||
}
|
||||
|
||||
/// Returns the default directory path for storing plugins
|
||||
///
|
||||
/// This function determines the standard data directory for the application
|
||||
/// and appends a 'plugins' subdirectory to it.
|
||||
/// Returns None if the project directories cannot be determined.
|
||||
pub fn default_plugins_dir() -> Option<PathBuf> {
|
||||
Self::dirs().map(|d| d.data_dir().join("plugins"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// The common error type for the polyscribe core crate.
|
||||
/// Add more domain-specific variants as needed.
|
||||
#[derive(Debug, Error)]
|
||||
/// Error types for the polyscribe-core crate.
|
||||
///
|
||||
/// This enum represents various error conditions that can occur during
|
||||
/// operations in this crate, including I/O errors, serialization/deserialization
|
||||
/// errors, and environment variable access errors.
|
||||
pub enum Error {
|
||||
/// Wrapper for any boxed dynamic error. Useful as a temporary catch-all.
|
||||
#[error("anyhow error: {0}")]
|
||||
Anyhow(#[from] anyhow::Error),
|
||||
|
||||
/// IO-related error.
|
||||
#[error("io error: {0}")]
|
||||
#[error("I/O error: {0}")]
|
||||
/// Represents an I/O error that occurred during file or stream operations
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// UTF-8 conversion error.
|
||||
#[error("utf8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
#[error("serde error: {0}")]
|
||||
/// Represents a JSON serialization or deserialization error
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
#[error("toml error: {0}")]
|
||||
/// Represents a TOML deserialization error
|
||||
Toml(#[from] toml::de::Error),
|
||||
|
||||
#[error("toml ser error: {0}")]
|
||||
/// Represents a TOML serialization error
|
||||
TomlSer(#[from] toml::ser::Error),
|
||||
|
||||
/// Environment variable error.
|
||||
#[error("env var error: {0}")]
|
||||
Var(#[from] std::env::VarError),
|
||||
/// Represents an error that occurred during environment variable access
|
||||
EnvVar(#[from] std::env::VarError),
|
||||
|
||||
/// TOML de serialization error.
|
||||
#[error("toml de error: {0}")]
|
||||
TomlDe(#[from] toml::de::Error),
|
||||
|
||||
/// Configuration parsing error.
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Placeholder for not-yet-implemented backends or features.
|
||||
#[error("unimplemented: {0}")]
|
||||
Unimplemented(&'static str),
|
||||
#[error("other: {0}")]
|
||||
/// Represents a general error condition with a custom message
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Convenient result alias for the polyscribe core crate.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
Error::Other(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,20 +192,17 @@ macro_rules! qlog {
|
||||
($($arg:tt)*) => {{ $crate::ilog!($($arg)*); }}
|
||||
}
|
||||
|
||||
/// Re-export backend module (GPU/CPU selection and transcription).
|
||||
pub mod backend;
|
||||
/// Re-export models module (model listing/downloading/updating).
|
||||
pub mod models;
|
||||
/// Configuration service (XDG + atomic writes)
|
||||
/// Configuration handling for PolyScribe
|
||||
pub mod config;
|
||||
/// UI helpers
|
||||
// Use the file-backed ui.rs module, which also declares its own `progress` submodule.
|
||||
pub mod ui;
|
||||
/// Error types for the crate.
|
||||
/// Error definitions for the PolyScribe library
|
||||
pub mod error;
|
||||
pub use error::Error;
|
||||
pub mod prelude;
|
||||
|
||||
pub use error::{Error, Result as OtherResult};
|
||||
|
||||
/// Transcript entry for a single segment.
|
||||
#[derive(Debug, serde::Serialize, Clone)]
|
||||
pub struct OutputEntry {
|
||||
|
||||
@@ -88,9 +88,10 @@ pub fn run_interactive_model_downloader() -> Result<()> {
|
||||
}
|
||||
|
||||
let answer = ui::prompt_input("Your selection", Some("1"))?;
|
||||
let selection_raw = match answer {
|
||||
Some(s) => s.trim().to_string(),
|
||||
None => "1".to_string(),
|
||||
let selection_raw = if answer.trim().is_empty() {
|
||||
"1".to_string()
|
||||
} else {
|
||||
answer.trim().to_string()
|
||||
};
|
||||
let selection = if selection_raw.is_empty() { "1" } else { &selection_raw };
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
pub use crate::backend::*;
|
||||
pub use crate::config::*;
|
||||
pub use crate::error::{Error, Result};
|
||||
pub use crate::error::Error;
|
||||
pub use crate::models::*;
|
||||
|
||||
// If you frequently use UI helpers across binaries/tests, export them too.
|
||||
// Keep this lean to avoid pulling UI everywhere unintentionally.
|
||||
#[allow(unused_imports)]
|
||||
pub use crate::ui::*;
|
||||
|
||||
/// A convenient alias for `std::result::Result` with the error type defaulting to [`Error`].
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
@@ -1,87 +1,64 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2025 <COPYRIGHT HOLDER>. All rights reserved.
|
||||
|
||||
//! Centralized UI helpers (TTY-aware, quiet/verbose-aware)
|
||||
//! Minimal UI helpers used across the core crate.
|
||||
//! This keeps interactive bits centralized and easy to stub in tests.
|
||||
|
||||
use std::io;
|
||||
/// Progress indicators and reporting tools for displaying task completion.
|
||||
pub mod progress;
|
||||
|
||||
/// Startup intro/banner (suppressed when quiet).
|
||||
pub fn intro(msg: impl AsRef<str>) {
|
||||
let _ = cliclack::intro(msg.as_ref());
|
||||
}
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Final outro/summary printed below any progress indicators (suppressed when quiet).
|
||||
pub fn outro(msg: impl AsRef<str>) {
|
||||
let _ = cliclack::outro(msg.as_ref());
|
||||
}
|
||||
|
||||
/// Info message (TTY-aware; suppressed by --quiet is handled by outer callers if needed)
|
||||
/// Print an informational line to stderr (suppressed when quiet mode is enabled by callers).
|
||||
pub fn info(msg: impl AsRef<str>) {
|
||||
let _ = cliclack::log::info(msg.as_ref());
|
||||
eprintln!("{}", msg.as_ref());
|
||||
}
|
||||
|
||||
/// Print a warning (always printed).
|
||||
/// Print a warning line to stderr.
|
||||
pub fn warn(msg: impl AsRef<str>) {
|
||||
// cliclack provides a warning-level log utility
|
||||
let _ = cliclack::log::warning(msg.as_ref());
|
||||
eprintln!("WARNING: {}", msg.as_ref());
|
||||
}
|
||||
|
||||
/// Print an error (always printed).
|
||||
/// Print an error line to stderr.
|
||||
pub fn error(msg: impl AsRef<str>) {
|
||||
let _ = cliclack::log::error(msg.as_ref());
|
||||
eprintln!("ERROR: {}", msg.as_ref());
|
||||
}
|
||||
|
||||
/// Print a line above any progress bars (maps to cliclack log; synchronized).
|
||||
pub fn println_above_bars(msg: impl AsRef<str>) {
|
||||
if crate::is_quiet() { return; }
|
||||
// cliclack logs are synchronized with its spinners/bars
|
||||
let _ = cliclack::log::info(msg.as_ref());
|
||||
/// Print a short intro header (non-fancy).
|
||||
pub fn intro(title: impl AsRef<str>) {
|
||||
eprintln!("== {} ==", title.as_ref());
|
||||
}
|
||||
|
||||
/// Input prompt with a question: returns Ok(None) if non-interactive or canceled
|
||||
pub fn prompt_input(question: impl AsRef<str>, default: Option<&str>) -> anyhow::Result<Option<String>> {
|
||||
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut p = cliclack::input(question.as_ref());
|
||||
if let Some(d) = default {
|
||||
// Use default_input when available in 0.3.x
|
||||
p = p.default_input(d);
|
||||
}
|
||||
match p.interact() {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
/// Print a short outro footer (non-fancy).
|
||||
pub fn outro(msg: impl AsRef<str>) {
|
||||
eprintln!("{}", msg.as_ref());
|
||||
}
|
||||
|
||||
/// Confirmation prompt; returns Ok(None) if non-interactive or canceled
|
||||
pub fn prompt_confirm(question: impl AsRef<str>, default_yes: bool) -> anyhow::Result<Option<bool>> {
|
||||
if crate::is_no_interaction() || !crate::stdin_is_tty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let res = cliclack::confirm(question.as_ref())
|
||||
.initial_value(default_yes)
|
||||
.interact();
|
||||
match res {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
/// Print a line that should appear above any progress indicators (plain for now).
|
||||
pub fn println_above_bars(line: impl AsRef<str>) {
|
||||
eprintln!("{}", line.as_ref());
|
||||
}
|
||||
|
||||
/// Prompt the user (TTY-aware via cliclack) and read a line from stdin. Returns the raw line with trailing newline removed.
|
||||
pub fn prompt_line(prompt: &str) -> io::Result<String> {
|
||||
// Route prompt through cliclack to keep consistent styling and avoid direct eprint!/println!
|
||||
let _ = cliclack::log::info(prompt);
|
||||
let mut s = String::new();
|
||||
io::stdin().read_line(&mut s)?;
|
||||
Ok(s)
|
||||
}
|
||||
/// Prompt for input on stdin. 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<String> {
|
||||
let mut stdout = io::stdout();
|
||||
match default {
|
||||
Some(def) => {
|
||||
write!(stdout, "{} [{}]: ", prompt, def)?;
|
||||
}
|
||||
None => {
|
||||
write!(stdout, "{}: ", prompt)?;
|
||||
}
|
||||
}
|
||||
stdout.flush()?;
|
||||
|
||||
/// TTY-aware progress UI built on `indicatif` for per-file and aggregate progress bars.
|
||||
///
|
||||
/// This small helper encapsulates a `MultiProgress` with one aggregate (total) bar and
|
||||
/// one per-file bar. It is intentionally minimal to keep integration lightweight.
|
||||
pub mod progress {
|
||||
// The submodule is defined in a separate file for clarity.
|
||||
include!("ui/progress.rs");
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default.unwrap_or_default().to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,3 +79,47 @@ impl ProgressManager {
|
||||
if let Some(total) = &self.total { total.set_position(self.completed as u64); }
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple reporter for displaying progress messages in the terminal.
|
||||
/// Provides different output formatting based on whether the environment is interactive or not.
|
||||
#[derive(Debug)]
|
||||
pub struct ProgressReporter {
|
||||
non_interactive: bool,
|
||||
}
|
||||
|
||||
impl ProgressReporter {
|
||||
/// Creates a new progress reporter.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `non_interactive` - Whether the output should be formatted for non-interactive environments.
|
||||
pub fn new(non_interactive: bool) -> Self {
|
||||
Self { non_interactive }
|
||||
}
|
||||
|
||||
/// Displays a progress step message.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The message to display for this progress step.
|
||||
pub fn step(&mut self, message: &str) {
|
||||
if self.non_interactive {
|
||||
eprintln!("[..] {message}");
|
||||
} else {
|
||||
eprintln!("• {message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays a completion message.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - The message to display when a task is completed.
|
||||
pub fn finish_with_message(&mut self, message: &str) {
|
||||
if self.non_interactive {
|
||||
eprintln!("[ok] {message}");
|
||||
} else {
|
||||
eprintln!("✓ {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user