diff --git a/src/config.rs b/src/config.rs index 00427b9..b8c5166 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,50 +96,72 @@ impl Config { ) })?; - // Run interactive configuration wizard - let paths = Self::prompt_for_paths()?; - let max_depth = Self::prompt_for_max_depth()?; - let cache_enabled = Self::prompt_for_cache_enabled()?; - let cache_ttl_hours = if cache_enabled { - Self::prompt_for_cache_ttl()? - } else { - 24 - }; - let windows = Self::prompt_for_windows()?; - - // Render styled success message before moving windows - ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows); - - let config = Config { - paths: paths.clone(), - max_depth, - cache_enabled, - cache_ttl_hours, - update_check_interval_hours: default_update_check_interval_hours(), - default_session: SessionConfig { windows }, - }; - - let toml_string = - toml::to_string_pretty(&config).context("Failed to serialize config")?; - - fs::write(&config_path, toml_string).with_context(|| { - format!("Failed to write config file: {}", config_path.display()) - })?; - - // Offer to set up a keyboard shortcut (best-effort, non-fatal) - if let Err(e) = crate::shortcut::setup_shortcut_wizard() { - eprintln!("Warning: shortcut setup failed: {}", e); - } - - // Offer to install .desktop entry + icon (best-effort, non-fatal) - if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() { - eprintln!("Warning: desktop integration failed: {}", e); + // Ask whether to run the interactive wizard or apply sensible defaults + let raw = ui::render_setup_choice_prompt()?; + match ui::parse_setup_choice_input(&raw) { + ui::SetupChoice::Default => { + Self::write_default_config(&config_path)?; + ui::render_default_config_saved(&config_path.display().to_string()); + } + ui::SetupChoice::Wizard => { + Self::run_wizard(&config_path)?; + } } } Ok(config_path) } + /// Write the built-in default config to `config_path` without any prompts. + fn write_default_config(config_path: &std::path::Path) -> Result<()> { + let config = Self::default_config(); + let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?; + fs::write(config_path, toml_string) + .with_context(|| format!("Failed to write config file: {}", config_path.display())) + } + + /// Run the full interactive configuration wizard and offer shortcut / desktop setup at the end. + fn run_wizard(config_path: &std::path::Path) -> Result<()> { + let paths = Self::prompt_for_paths()?; + let max_depth = Self::prompt_for_max_depth()?; + let cache_enabled = Self::prompt_for_cache_enabled()?; + let cache_ttl_hours = if cache_enabled { + Self::prompt_for_cache_ttl()? + } else { + 24 + }; + let windows = Self::prompt_for_windows()?; + + // Render styled success message before moving windows + ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows); + + let config = Config { + paths, + max_depth, + cache_enabled, + cache_ttl_hours, + update_check_interval_hours: default_update_check_interval_hours(), + default_session: SessionConfig { windows }, + }; + + let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?; + + fs::write(config_path, toml_string) + .with_context(|| format!("Failed to write config file: {}", config_path.display()))?; + + // Offer to set up a keyboard shortcut (best-effort, non-fatal) + if let Err(e) = crate::shortcut::setup_shortcut_wizard() { + eprintln!("Warning: shortcut setup failed: {}", e); + } + + // Offer to install .desktop entry + icon (best-effort, non-fatal) + if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() { + eprintln!("Warning: desktop integration failed: {}", e); + } + + Ok(()) + } + fn prompt_for_paths() -> Result> { // Render styled welcome banner ui::render_welcome_banner(); @@ -377,6 +399,35 @@ mod tests { assert_eq!(ui::parse_layout_input("invalid"), None); } + #[test] + fn should_write_default_config_to_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("tmuxido.toml"); + + Config::write_default_config(&config_path).unwrap(); + + assert!(config_path.exists()); + let content = std::fs::read_to_string(&config_path).unwrap(); + let loaded: Config = toml::from_str(&content).unwrap(); + assert!(!loaded.paths.is_empty()); + assert_eq!(loaded.max_depth, 5); + assert!(loaded.cache_enabled); + assert_eq!(loaded.cache_ttl_hours, 24); + } + + #[test] + fn should_write_valid_toml_in_default_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("tmuxido.toml"); + + Config::write_default_config(&config_path).unwrap(); + + let content = std::fs::read_to_string(&config_path).unwrap(); + // Must parse cleanly + let result: Result = toml::from_str(&content); + assert!(result.is_ok(), "Default config must be valid TOML"); + } + #[test] fn should_parse_config_with_windows_and_panes() { let toml_str = r#" diff --git a/src/ui.rs b/src/ui.rs index b8cb37c..5c1e5c1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -613,6 +613,91 @@ pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInsta // ============================================================================ +/// Choices offered when no configuration file is found on first run +#[derive(Debug, PartialEq)] +pub enum SetupChoice { + Wizard, + Default, +} + +/// Parse the first-run setup choice input. +/// +/// Accepts: +/// - `""`, `" "`, `"1"`, `"w"`, `"wizard"` → `Wizard` (default) +/// - `"2"`, `"d"`, `"default"` → `Default` +/// - anything else falls back to `Wizard` +pub fn parse_setup_choice_input(input: &str) -> SetupChoice { + match input.trim().to_lowercase().as_str() { + "2" | "d" | "default" => SetupChoice::Default, + _ => SetupChoice::Wizard, + } +} + +/// Renders the first-run prompt asking whether to run the wizard or use defaults. +/// Returns the raw user input. +pub fn render_setup_choice_prompt() -> Result { + let prompt_style = Style::new().bold(true).foreground(color_green()); + let hint_style = Style::new().italic(true).foreground(color_dark_gray()); + let title_style = Style::new().bold(true).foreground(color_blue()); + let option_style = Style::new().foreground(color_purple()); + + println!(); + println!("{}", title_style.render(" 🚀 Welcome to tmuxido!")); + println!(); + println!( + "{}", + hint_style.render(" No configuration found. How would you like to get started?") + ); + println!(); + println!( + " {}", + option_style + .render("1. Run setup wizard — configure paths, cache, and windows interactively") + ); + println!( + " {}", + option_style.render("2. Use default config — start immediately with sensible defaults") + ); + println!(); + print!(" {} ", prompt_style.render("❯ Choose (1/2):")); + io::stdout().flush().context("Failed to flush stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("Failed to read input")?; + + Ok(input.trim().to_string()) +} + +/// Renders a confirmation message after the default config is written. +pub fn render_default_config_saved(config_path: &str) { + let success_style = Style::new().bold(true).foreground(color_green()); + let info_style = Style::new().foreground(color_dark_gray()); + let path_style = Style::new().bold(true).foreground(color_blue()); + + println!(); + println!( + "{}", + success_style.render(" ✅ Default configuration saved!") + ); + println!( + " {} {}", + info_style.render("Config:"), + path_style.render(config_path) + ); + println!(); + println!( + "{}", + info_style.render( + " ⚙️ Edit it anytime to customise your setup, or run 'tmuxido --setup-shortcut'." + ) + ); + println!(); +} + +// ============================================================================ + /// Parse comma-separated list into Vec, filtering empty items pub fn parse_comma_separated_list(input: &str) -> Vec { input @@ -882,4 +967,50 @@ mod tests { }; render_desktop_integration_success(&result); } + + // ---- SetupChoice / parse_setup_choice_input ---- + + #[test] + fn should_return_wizard_for_empty_input() { + assert_eq!(parse_setup_choice_input(""), SetupChoice::Wizard); + assert_eq!(parse_setup_choice_input(" "), SetupChoice::Wizard); + } + + #[test] + fn should_return_wizard_for_option_1() { + assert_eq!(parse_setup_choice_input("1"), SetupChoice::Wizard); + } + + #[test] + fn should_return_wizard_for_w_aliases() { + assert_eq!(parse_setup_choice_input("w"), SetupChoice::Wizard); + assert_eq!(parse_setup_choice_input("wizard"), SetupChoice::Wizard); + assert_eq!(parse_setup_choice_input("W"), SetupChoice::Wizard); + assert_eq!(parse_setup_choice_input("WIZARD"), SetupChoice::Wizard); + } + + #[test] + fn should_return_default_for_option_2() { + assert_eq!(parse_setup_choice_input("2"), SetupChoice::Default); + } + + #[test] + fn should_return_default_for_d_aliases() { + assert_eq!(parse_setup_choice_input("d"), SetupChoice::Default); + assert_eq!(parse_setup_choice_input("default"), SetupChoice::Default); + assert_eq!(parse_setup_choice_input("D"), SetupChoice::Default); + assert_eq!(parse_setup_choice_input("DEFAULT"), SetupChoice::Default); + } + + #[test] + fn should_return_wizard_for_unknown_input() { + assert_eq!(parse_setup_choice_input("banana"), SetupChoice::Wizard); + assert_eq!(parse_setup_choice_input("3"), SetupChoice::Wizard); + assert_eq!(parse_setup_choice_input("yes"), SetupChoice::Wizard); + } + + #[test] + fn render_default_config_saved_should_not_panic() { + render_default_config_saved("/home/user/.config/tmuxido/tmuxido.toml"); + } }