diff --git a/src/cli/args.rs b/src/cli/args.rs index db303a4..6b9bf9f 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,4 +1,5 @@ use crate::constants::{exit_codes, verbosity}; +use crate::prompt::PromptTheme; use clap::{error::ErrorKind, CommandFactory, Parser, ValueEnum}; use log::LevelFilter; use std::fmt::Display; @@ -34,6 +35,36 @@ impl Display for SkipConfirm { } } +/// Available terminal prompt themes. +#[derive(Debug, Clone, ValueEnum, Copy, PartialEq, Eq, Default)] +#[value(rename_all = "lowercase")] +pub enum PromptThemeArg { + /// dialoguer-like default colorful theme. + Classic, + /// Baker-branded prompt theme. + #[default] + Fancy, +} + +impl Display for PromptThemeArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + PromptThemeArg::Classic => "classic", + PromptThemeArg::Fancy => "fancy", + }; + write!(f, "{s}") + } +} + +impl From for PromptTheme { + fn from(value: PromptThemeArg) -> Self { + match value { + PromptThemeArg::Classic => PromptTheme::Classic, + PromptThemeArg::Fancy => PromptTheme::Fancy, + } + } +} + /// CLI arguments for Baker. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -74,6 +105,10 @@ pub struct Args { /// Preview actions without touching the filesystem. #[arg(long = "dry-run")] pub dry_run: bool, + + /// Prompt theme (`classic` or `fancy`). + #[arg(long = "theme", value_enum, default_value_t = PromptThemeArg::Fancy)] + pub theme: PromptThemeArg, } /// Parse command line arguments with custom handling for missing required inputs. @@ -133,6 +168,12 @@ mod tests { assert_eq!(SkipConfirm::Hooks.to_string(), "hooks"); } + #[test] + fn display_prompt_theme_variants() { + assert_eq!(PromptThemeArg::Classic.to_string(), "classic"); + assert_eq!(PromptThemeArg::Fancy.to_string(), "fancy"); + } + #[test] fn parses_full_feature_flags() { use clap::Parser; @@ -148,6 +189,8 @@ mod tests { "all,overwrite", "--non-interactive", "--dry-run", + "--theme", + "classic", ]); assert_eq!(args.template, "template_dir"); assert_eq!(args.output_dir, PathBuf::from("output_dir")); @@ -158,6 +201,7 @@ mod tests { assert!(args.skip_confirms.contains(&SkipConfirm::Overwrite)); assert!(args.non_interactive); assert!(args.dry_run); + assert_eq!(args.theme, PromptThemeArg::Classic); } #[test] @@ -174,4 +218,11 @@ mod tests { assert_eq!(args.output_dir, PathBuf::from("output_dir")); assert_eq!(args.answers_file, Some(PathBuf::from("/path/to/answers.json"))); } + + #[test] + fn parses_minimal_args_with_default_theme() { + use clap::Parser; + let args = Args::parse_from(["baker", "template_dir", "output_dir"]); + assert_eq!(args.theme, PromptThemeArg::Fancy); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8358985..379c4fa 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -5,5 +5,5 @@ pub mod hooks; pub mod processor; pub mod runner; -pub use args::{get_args, get_log_level_from_verbose, Args, SkipConfirm}; +pub use args::{get_args, get_log_level_from_verbose, Args, PromptThemeArg, SkipConfirm}; pub use runner::run; diff --git a/src/cli/runner.rs b/src/cli/runner.rs index 76aecb5..184af2c 100644 --- a/src/cli/runner.rs +++ b/src/cli/runner.rs @@ -7,7 +7,7 @@ use crate::{ error::{Error, Result}, ignore::parse_bakerignore_file, loader::get_template, - prompt::confirm, + prompt::{confirm, set_prompt_theme}, renderer::TemplateRenderer, template::{get_template_engine, processor::TemplateProcessor}, }; @@ -480,6 +480,7 @@ fn log_dry_run_action>(action: &str, target: A) { #[cfg(test)] mod tests { use super::*; + use crate::cli::PromptThemeArg; use serde_json::json; use tempfile::TempDir; @@ -494,6 +495,7 @@ mod tests { skip_confirms: Vec::new(), non_interactive: false, dry_run: false, + theme: PromptThemeArg::Fancy, } } @@ -607,6 +609,7 @@ fn completion_message(dry_run: bool, output_root: &Path) -> String { /// Main entry point for CLI execution pub fn run(args: Args) -> Result<()> { + set_prompt_theme(args.theme.into()); let runner = Runner::new(args); runner.run() } diff --git a/src/prompt/dialoguer.rs b/src/prompt/dialoguer.rs index 23598e1..917af54 100644 --- a/src/prompt/dialoguer.rs +++ b/src/prompt/dialoguer.rs @@ -7,16 +7,51 @@ use super::interface::{ ConfirmationConfig, MultipleChoiceConfig, SecretConfig, SingleChoiceConfig, StructuredDataConfig, TextPromptConfig, }; +use super::theme::PromptTheme; use crate::{error::Result, prompt::parser::DataParser}; -use dialoguer::{Confirm, Editor, Input, MultiSelect, Password, Select}; +use dialoguer::console::{style, Style}; +use dialoguer::{ + theme::ColorfulTheme, Confirm, Editor, Input, MultiSelect, Password, Select, +}; use serde_json::Value; /// Default terminal-backed prompt provider implemented with `dialoguer`. -pub struct DialoguerPrompter; +pub struct DialoguerPrompter { + theme: ColorfulTheme, +} impl DialoguerPrompter { pub fn new() -> Self { - Self + Self::with_theme(PromptTheme::default()) + } + + pub fn with_theme(theme: PromptTheme) -> Self { + let theme = match theme { + PromptTheme::Classic => ColorfulTheme::default(), + PromptTheme::Fancy => Self::baker_theme(), + }; + Self { theme } + } + + fn baker_theme() -> ColorfulTheme { + ColorfulTheme { + defaults_style: Style::new().for_stderr().cyan(), + prompt_style: Style::new().for_stderr().bold().cyan(), + prompt_prefix: style("baker".to_string()).for_stderr().cyan(), + prompt_suffix: style(">".to_string()).for_stderr().black().bright(), + success_prefix: style("ok".to_string()).for_stderr().green(), + success_suffix: style(":".to_string()).for_stderr().black().bright(), + error_prefix: style("err".to_string()).for_stderr().red(), + values_style: Style::new().for_stderr().green(), + active_item_style: Style::new().for_stderr().bold().green(), + active_item_prefix: style(">".to_string()).for_stderr().green(), + inactive_item_prefix: style(" ".to_string()).for_stderr(), + checked_item_prefix: style("[x]".to_string()).for_stderr().green(), + unchecked_item_prefix: style("[ ]".to_string()).for_stderr().yellow(), + picked_item_prefix: style(">".to_string()).for_stderr().green(), + unpicked_item_prefix: style(" ".to_string()).for_stderr(), + ..ColorfulTheme::default() + } } } @@ -41,7 +76,9 @@ impl super::interface::TextPrompter for DialoguerPrompter { impl super::interface::SingleChoicePrompter for DialoguerPrompter { fn prompt_single_choice(&self, config: &SingleChoiceConfig) -> Result { - let mut select = Select::new().with_prompt(&config.prompt).items(&config.choices); + let mut select = Select::with_theme(&self.theme) + .with_prompt(&config.prompt) + .items(&config.choices); if let Some(default_index) = config.default_index { select = select.default(default_index); @@ -56,7 +93,7 @@ impl super::interface::MultipleChoicePrompter for DialoguerPrompter { &self, config: &MultipleChoiceConfig, ) -> Result> { - let indices = MultiSelect::new() + let indices = MultiSelect::with_theme(&self.theme) .with_prompt(&config.prompt) .items(&config.choices) .defaults(&config.defaults) @@ -68,7 +105,7 @@ impl super::interface::MultipleChoicePrompter for DialoguerPrompter { impl super::interface::ConfirmationPrompter for DialoguerPrompter { fn prompt_confirmation(&self, config: &ConfirmationConfig) -> Result { - let result = Confirm::new() + let result = Confirm::with_theme(&self.theme) .with_prompt(&config.prompt) .default(config.default) .interact()?; @@ -84,7 +121,7 @@ impl super::interface::StructuredDataPrompter for DialoguerPrompter { let options = vec!["Enter in terminal", "Open editor"]; - let selection = Select::new() + let selection = Select::with_theme(&self.theme) .with_prompt(&config.prompt) .items(&options) .default(0) @@ -109,7 +146,7 @@ impl DialoguerPrompter { prompt: &str, secret_config: &SecretConfig, ) -> Result { - let mut password = Password::new().with_prompt(prompt); + let mut password = Password::with_theme(&self.theme).with_prompt(prompt); if secret_config.confirm { let error_message = if secret_config.mismatch_error.is_empty() { @@ -127,7 +164,7 @@ impl DialoguerPrompter { /// Handle regular text input fn prompt_regular_text(&self, prompt: &str, default: &str) -> Result { - Ok(Input::new() + Ok(Input::with_theme(&self.theme) .with_prompt(prompt) .default(default.to_string()) .interact_text()?) @@ -139,7 +176,7 @@ impl DialoguerPrompter { default_content: &str, is_yaml: bool, ) -> Result { - let content: String = Input::new() + let content: String = Input::with_theme(&self.theme) .with_prompt("Enter content") .default(default_content.to_string()) .interact_text()?; diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 4061d86..754dd57 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -6,6 +6,7 @@ //! - [`handler`]: orchestration that chooses which prompt to display. //! - [`context`]: immutable data passed to prompt providers. //! - [`provider`]: convenience helpers exposed to the rest of the crate. +//! - [`theme`]: prompt appearance presets. pub mod context; pub mod dialoguer; @@ -13,7 +14,11 @@ pub mod handler; pub mod interface; pub mod parser; pub mod provider; +pub mod theme; pub use context::PromptContext; pub use interface::*; -pub use provider::{ask_question, confirm, get_prompt_provider, Prompter}; +pub use provider::{ + ask_question, confirm, get_prompt_provider, set_prompt_theme, Prompter, +}; +pub use theme::PromptTheme; diff --git a/src/prompt/provider.rs b/src/prompt/provider.rs index badef19..adab1cb 100644 --- a/src/prompt/provider.rs +++ b/src/prompt/provider.rs @@ -3,12 +3,18 @@ use crate::{ error::Result, }; use serde_json::Value; +use std::sync::atomic::{AtomicU8, Ordering}; use super::{ context::PromptContext, dialoguer::DialoguerPrompter, handler::PromptHandler, - interface::PromptProvider, + interface::PromptProvider, theme::PromptTheme, }; +const PROMPT_THEME_CLASSIC: u8 = 0; +const PROMPT_THEME_FANCY: u8 = 1; + +static ACTIVE_PROMPT_THEME: AtomicU8 = AtomicU8::new(PROMPT_THEME_FANCY); + /// Trait implemented by prompt backends that can render a question via a [`PromptContext`]. pub trait Prompter<'a> { fn prompt(&self, prompt_context: &PromptContext<'a>) -> Result; @@ -16,7 +22,30 @@ pub trait Prompter<'a> { /// Convenience function to construct the default terminal prompt provider. pub fn get_prompt_provider() -> impl PromptProvider { - DialoguerPrompter::new() + DialoguerPrompter::with_theme(get_prompt_theme()) +} + +/// Sets the active prompt theme for all interactive dialogs in the current process. +pub fn set_prompt_theme(theme: PromptTheme) { + ACTIVE_PROMPT_THEME.store(theme_to_u8(theme), Ordering::Relaxed); +} + +fn get_prompt_theme() -> PromptTheme { + theme_from_u8(ACTIVE_PROMPT_THEME.load(Ordering::Relaxed)) +} + +const fn theme_to_u8(theme: PromptTheme) -> u8 { + match theme { + PromptTheme::Classic => PROMPT_THEME_CLASSIC, + PromptTheme::Fancy => PROMPT_THEME_FANCY, + } +} + +const fn theme_from_u8(raw: u8) -> PromptTheme { + match raw { + PROMPT_THEME_CLASSIC => PromptTheme::Classic, + _ => PromptTheme::Fancy, + } } /// High-level helper that collects an answer for a single configuration question. diff --git a/src/prompt/theme.rs b/src/prompt/theme.rs new file mode 100644 index 0000000..8cf7e32 --- /dev/null +++ b/src/prompt/theme.rs @@ -0,0 +1,9 @@ +/// Visual themes for interactive terminal prompts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PromptTheme { + /// Uses dialoguer's default colorful look. + Classic, + /// Uses Baker-branded colors and prefixes. + #[default] + Fancy, +} diff --git a/tests/gitea_integration_tests.rs b/tests/gitea_integration_tests.rs index 73e2ec3..7367683 100644 --- a/tests/gitea_integration_tests.rs +++ b/tests/gitea_integration_tests.rs @@ -8,7 +8,7 @@ //! Run these tests with: `cargo test --test gitea_integration_tests -- --ignored` use baker::cli::SkipConfirm::All; -use baker::cli::{run, Args}; +use baker::cli::{run, Args, PromptThemeArg}; use git2::{Cred, PushOptions, RemoteCallbacks, Repository, Signature}; use reqwest::blocking::Client; use serde_json::json; @@ -676,6 +676,7 @@ fn test_jsonschema_file_template_from_gitea() { skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).expect("Baker run failed"); @@ -876,6 +877,7 @@ fn test_template_with_submodule_schema_file() { skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).expect("Baker run failed - submodule schema_file should be accessible"); @@ -1083,6 +1085,7 @@ Entities count: {{ entities | length }} skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; // Run baker - this should succeed because submodules are now initialized diff --git a/tests/readme_examples_integration_tests.rs b/tests/readme_examples_integration_tests.rs index 5e166ee..30bbe88 100644 --- a/tests/readme_examples_integration_tests.rs +++ b/tests/readme_examples_integration_tests.rs @@ -1,4 +1,4 @@ -use baker::cli::{run, Args, SkipConfirm::All}; +use baker::cli::{run, Args, PromptThemeArg, SkipConfirm::All}; use test_log::test; mod utils; use utils::run_and_assert; @@ -160,6 +160,7 @@ fn test_non_interactive_mode_with_defaults() { skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).unwrap(); @@ -193,6 +194,7 @@ fn test_nested_answer_context() { skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).unwrap(); @@ -296,6 +298,7 @@ fn test_answers_file() { skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).unwrap(); @@ -338,6 +341,7 @@ fn test_answers_file_with_cli_override() { skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).unwrap(); diff --git a/tests/utils.rs b/tests/utils.rs index 7039294..4536a28 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -1,5 +1,5 @@ use baker::cli::SkipConfirm::All; -use baker::cli::{run, Args}; +use baker::cli::{run, Args, PromptThemeArg}; use log::debug; use std::fs; use std::path::Path; @@ -106,6 +106,7 @@ pub fn run_and_assert(template: &str, expected_dir: &str, answers: Option<&str>) skip_confirms: vec![All], non_interactive: true, dry_run: false, + theme: PromptThemeArg::Fancy, }; run(args).unwrap(); let result = dir_diff::is_different(tmp_dir.path(), expected_dir);