use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use crate::session::SessionConfig; use crate::ui; #[derive(Debug, Deserialize, Serialize)] pub struct Config { pub paths: Vec, #[serde(default = "default_max_depth")] pub max_depth: usize, #[serde(default = "default_cache_enabled")] pub cache_enabled: bool, #[serde(default = "default_cache_ttl_hours")] pub cache_ttl_hours: u64, #[serde(default = "default_update_check_interval_hours")] pub update_check_interval_hours: u64, #[serde(default = "default_session_config")] pub default_session: SessionConfig, } fn default_max_depth() -> usize { 5 } fn default_cache_enabled() -> bool { true } fn default_cache_ttl_hours() -> u64 { 24 } fn default_update_check_interval_hours() -> u64 { 24 } fn default_session_config() -> SessionConfig { use crate::session::Window; SessionConfig { windows: vec![ Window { name: "editor".to_string(), panes: vec![], layout: None, }, Window { name: "terminal".to_string(), panes: vec![], layout: None, }, ], } } impl Config { pub fn load() -> Result { let config_path = Self::config_path()?; if !config_path.exists() { return Ok(Self::default_config()); } let content = fs::read_to_string(&config_path) .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; let config: Config = toml::from_str(&content) .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?; Ok(config) } pub fn config_path() -> Result { let config_dir = dirs::config_dir() .context("Could not determine config directory")? .join("tmuxido"); Ok(config_dir.join("tmuxido.toml")) } pub fn ensure_config_exists() -> Result { let config_path = Self::config_path()?; if !config_path.exists() { let config_dir = config_path .parent() .context("Could not get parent directory")?; fs::create_dir_all(config_dir).with_context(|| { format!( "Failed to create config directory: {}", config_dir.display() ) })?; // 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); } } Ok(config_path) } fn prompt_for_paths() -> Result> { // Render styled welcome banner ui::render_welcome_banner(); // Get input with styled prompt let input = ui::render_paths_prompt()?; let paths = Self::parse_paths_input(&input); if paths.is_empty() { ui::render_fallback_message(); Ok(vec![ dirs::home_dir() .unwrap_or_default() .join("Projects") .to_string_lossy() .to_string(), ]) } else { Ok(paths) } } fn prompt_for_max_depth() -> Result { ui::render_section_header("Scan Settings"); let input = ui::render_max_depth_prompt()?; Ok(ui::parse_max_depth_input(&input).unwrap_or(5)) } fn prompt_for_cache_enabled() -> Result { ui::render_section_header("Cache Settings"); let input = ui::render_cache_enabled_prompt()?; Ok(ui::parse_cache_enabled_input(&input).unwrap_or(true)) } fn prompt_for_cache_ttl() -> Result { let input = ui::render_cache_ttl_prompt()?; Ok(ui::parse_cache_ttl_input(&input).unwrap_or(24)) } fn prompt_for_windows() -> Result> { ui::render_section_header("Default Session"); let input = ui::render_windows_prompt()?; let window_names = ui::parse_comma_separated_list(&input); let names = if window_names.is_empty() { vec!["editor".to_string(), "terminal".to_string()] } else { window_names }; // Configure panes and layout for each window let mut windows = Vec::new(); for name in names { let panes = Self::prompt_for_panes(&name)?; let layout = if panes.len() > 1 { ui::render_layout_prompt(&name, panes.len())? } else { None }; windows.push(crate::session::Window { name, panes, layout, }); } Ok(windows) } fn prompt_for_panes(window_name: &str) -> Result> { let input = ui::render_panes_prompt(window_name)?; let pane_names = ui::parse_comma_separated_list(&input); if pane_names.is_empty() { // Single pane, no commands return Ok(vec![]); } // Ask for commands for each pane let mut panes = Vec::new(); for pane_name in pane_names { let command = ui::render_pane_command_prompt(&pane_name)?; panes.push(command); } Ok(panes) } fn parse_paths_input(input: &str) -> Vec { input .trim() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } fn default_config() -> Self { Config { paths: vec![ dirs::home_dir() .unwrap_or_default() .join("Projects") .to_string_lossy() .to_string(), ], max_depth: 5, cache_enabled: true, cache_ttl_hours: 24, update_check_interval_hours: default_update_check_interval_hours(), default_session: default_session_config(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn should_use_defaults_when_optional_fields_missing() { let toml_str = r#"paths = ["/home/user/projects"]"#; let config: Config = toml::from_str(toml_str).unwrap(); assert_eq!(config.max_depth, 5); assert!(config.cache_enabled); assert_eq!(config.cache_ttl_hours, 24); assert_eq!(config.update_check_interval_hours, 24); } #[test] fn should_parse_full_config_correctly() { let toml_str = r#" paths = ["/foo", "/bar"] max_depth = 3 cache_enabled = false cache_ttl_hours = 12 "#; let config: Config = toml::from_str(toml_str).unwrap(); assert_eq!(config.paths, vec!["/foo", "/bar"]); assert_eq!(config.max_depth, 3); assert!(!config.cache_enabled); assert_eq!(config.cache_ttl_hours, 12); } #[test] fn should_reject_invalid_toml() { let result: Result = toml::from_str("not valid toml ]][["); assert!(result.is_err()); } #[test] fn should_parse_single_path() { let input = "~/Projects"; let paths = Config::parse_paths_input(input); assert_eq!(paths, vec!["~/Projects"]); } #[test] fn should_parse_multiple_paths_with_commas() { let input = "~/Projects, ~/work, ~/repos"; let paths = Config::parse_paths_input(input); assert_eq!(paths, vec!["~/Projects", "~/work", "~/repos"]); } #[test] fn should_trim_whitespace_from_paths() { let input = " ~/Projects , ~/work "; let paths = Config::parse_paths_input(input); assert_eq!(paths, vec!["~/Projects", "~/work"]); } #[test] fn should_return_empty_vec_for_empty_input() { let input = ""; let paths = Config::parse_paths_input(input); assert!(paths.is_empty()); } #[test] fn should_return_empty_vec_for_whitespace_only() { let input = " "; let paths = Config::parse_paths_input(input); assert!(paths.is_empty()); } #[test] fn should_handle_empty_parts_between_commas() { let input = "~/Projects,,~/work"; let paths = Config::parse_paths_input(input); assert_eq!(paths, vec!["~/Projects", "~/work"]); } #[test] fn should_use_ui_parse_functions_for_max_depth() { // Test that our UI parsing produces expected results assert_eq!(ui::parse_max_depth_input(""), None); assert_eq!(ui::parse_max_depth_input("5"), Some(5)); assert_eq!(ui::parse_max_depth_input("invalid"), None); } #[test] fn should_use_ui_parse_functions_for_cache_enabled() { assert_eq!(ui::parse_cache_enabled_input(""), None); assert_eq!(ui::parse_cache_enabled_input("y"), Some(true)); assert_eq!(ui::parse_cache_enabled_input("n"), Some(false)); assert_eq!(ui::parse_cache_enabled_input("maybe"), None); } #[test] fn should_use_ui_parse_functions_for_cache_ttl() { assert_eq!(ui::parse_cache_ttl_input(""), None); assert_eq!(ui::parse_cache_ttl_input("24"), Some(24)); assert_eq!(ui::parse_cache_ttl_input("invalid"), None); } #[test] fn should_use_ui_parse_functions_for_window_names() { let result = ui::parse_comma_separated_list("editor, terminal, server"); assert_eq!(result, vec!["editor", "terminal", "server"]); } #[test] fn should_use_ui_parse_functions_for_layout() { assert_eq!(ui::parse_layout_input(""), None); assert_eq!( ui::parse_layout_input("1"), Some("main-horizontal".to_string()) ); assert_eq!( ui::parse_layout_input("main-vertical"), Some("main-vertical".to_string()) ); assert_eq!(ui::parse_layout_input("invalid"), None); } #[test] fn should_parse_config_with_windows_and_panes() { let toml_str = r#" paths = ["/projects"] max_depth = 3 cache_enabled = true cache_ttl_hours = 12 [default_session] [[default_session.windows]] name = "editor" panes = ["nvim .", "git status"] [[default_session.windows]] name = "terminal" panes = [] "#; let config: Config = toml::from_str(toml_str).unwrap(); assert_eq!(config.paths, vec!["/projects"]); assert_eq!(config.max_depth, 3); assert!(config.cache_enabled); assert_eq!(config.cache_ttl_hours, 12); assert_eq!(config.default_session.windows.len(), 2); assert_eq!(config.default_session.windows[0].name, "editor"); assert_eq!(config.default_session.windows[0].panes.len(), 2); assert_eq!(config.default_session.windows[0].panes[0], "nvim ."); assert_eq!(config.default_session.windows[0].panes[1], "git status"); assert_eq!(config.default_session.windows[1].name, "terminal"); assert!(config.default_session.windows[1].panes.is_empty()); } }