use std::path::PathBuf; use std::fs; use anyhow::{Result, Context}; use serde::{Deserialize, Serialize}; use log::{debug, error}; use crate::args::{Args}; use dirs; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ColumnAlignment { #[default] Left, Right, Center, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ContentArrangement { #[default] Dynamic, DynamicFullWidth, Disabled, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum TableStyle { Ascii, Utf8, Utf8Full, #[default] Nothing, Custom(String), } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TableColor { Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, Gray, DarkRed, DarkGreen, DarkYellow, DarkBlue, DarkMagenta, DarkCyan, Rgb(u8, u8, u8), } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TableAttribute { Bold, Dim, Italic, Underlined, SlowBlink, RapidBlink, Reverse, Hidden, CrossedOut, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TableConfig { #[serde(default)] pub style: TableStyle, #[serde(default)] pub modifiers: Vec, #[serde(default)] pub content_arrangement: ContentArrangement, #[serde(default)] pub truncation_indicator: String, } #[derive(Debug, Clone, Serialize, Default)] pub struct ColumnConfig { pub name: String, pub label: String, #[serde(default)] pub align: ColumnAlignment, #[serde(default)] pub max_len: Option, #[serde(default)] pub fg_color: Option, #[serde(default)] pub bg_color: Option, #[serde(default)] pub attributes: Vec, #[serde(default)] pub padding: Option<(u16, u16)>, } impl<'de> serde::Deserialize<'de> for ColumnConfig { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { #[derive(Deserialize)] struct Helper { name: String, label: Option, #[serde(default)] align: ColumnAlignment, #[serde(default)] max_len: Option, #[serde(default)] fg_color: Option, #[serde(default)] bg_color: Option, #[serde(default)] attributes: Vec, #[serde(default)] padding: Option<(u16, u16)>, } let helper = Helper::deserialize(deserializer)?; let label = helper.label.unwrap_or_else(|| helper.name.clone()); Ok(ColumnConfig { name: helper.name, label, align: helper.align, max_len: helper.max_len, fg_color: helper.fg_color, bg_color: helper.bg_color, attributes: helper.attributes, padding: helper.padding, }) } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerConfig { pub address: Option, pub port: Option, pub password_file: Option, pub password: Option, pub password_hash: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CompressionPluginConfig { pub name: String, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct MetaPluginConfig { pub name: String, #[schema(value_type = Object)] #[serde(default)] pub options: std::collections::HashMap, #[schema(value_type = Object)] #[serde(default)] pub outputs: std::collections::HashMap, } /// Unified settings that merges config file and CLI arguments #[derive(Debug, Clone, Deserialize)] pub struct Settings { #[serde(default)] pub dir: PathBuf, pub list_format: Vec, #[serde(default)] pub table_config: TableConfig, #[serde(default)] pub human_readable: bool, pub output_format: Option, #[serde(default)] pub quiet: bool, #[serde(default)] pub force: bool, pub server: Option, pub compression_plugin: Option, pub meta_plugins: Option>, } impl Settings { /// Create unified settings from config and args with proper priority pub fn new(args: &Args, default_dir: PathBuf) -> Result { debug!("CONFIG: Creating settings with default dir: {:?}", default_dir); let config_path = if let Some(config_path) = &args.options.config { config_path.clone() } else if let Ok(env_config) = std::env::var("KEEP_CONFIG") { PathBuf::from(env_config) } else { let default_path = if let Some(home_dir) = std::env::var("HOME").ok() { let mut path = PathBuf::from(home_dir); path.push(".config"); path.push("keep"); path.push("config.yml"); path } else { PathBuf::from("~/.config/keep/config.yml") }; debug!("CONFIG: Using default config path: {:?}", default_path); default_path }; debug!("CONFIG: Using config path: {:?}", config_path); let mut config_builder = config::Config::builder(); // Load config file if it exists if config_path.exists() { debug!("CONFIG: Loading config file: {:?}", config_path); config_builder = config_builder.add_source(config::File::from(config_path.clone()).required(false)); } else { debug!("CONFIG: Config file does not exist: {:?}", config_path); } // Add environment variables debug!("CONFIG: Adding environment variables"); let env_source = config::Environment::with_prefix("KEEP").separator("__").ignore_empty(true); config_builder = config_builder.add_source(env_source); // Override with CLI args if let Some(dir) = &args.options.dir { debug!("CONFIG: Overriding dir with CLI arg: {:?}", dir); config_builder = config_builder.set_override("dir", dir.to_str().unwrap())?; } if args.options.human_readable { config_builder = config_builder.set_override("human_readable", true)?; } if let Some(output_format) = &args.options.output_format { config_builder = config_builder.set_override("output_format", output_format.as_str())?; } if args.options.verbose > 0 { config_builder = config_builder.set_override("verbose", args.options.verbose)?; } if args.options.quiet { config_builder = config_builder.set_override("quiet", true)?; } if args.options.force { config_builder = config_builder.set_override("force", true)?; } if let Some(server_password) = &args.options.server_password { config_builder = config_builder.set_override("server.password", server_password.as_str())?; } if let Some(server_password_hash) = &args.options.server_password_hash { config_builder = config_builder.set_override("server.password_hash", server_password_hash.as_str())?; } if let Some(server_address) = &args.mode.server_address { config_builder = config_builder.set_override("server.address", server_address.as_str())?; } if let Some(server_port) = args.mode.server_port { config_builder = config_builder.set_override("server.port", server_port)?; } if let Some(compression) = &args.item.compression { config_builder = config_builder.set_override("compression_plugin.name", compression.as_str())?; } if !args.item.meta_plugins.is_empty() { let meta_plugins: Vec> = args.item.meta_plugins .iter() .map(|name| { let mut map = std::collections::HashMap::new(); map.insert("name".to_string(), name.clone()); map }) .collect(); config_builder = config_builder.set_override("meta_plugins", meta_plugins)?; } let config = config_builder.build()?; debug!("CONFIG: Built config, attempting to deserialize"); match config.try_deserialize::() { Ok(mut settings) => { debug!("CONFIG: Successfully deserialized settings: {:?}", settings); // Set defaults for list_format if not provided if settings.list_format.is_empty() { debug!("CONFIG: Setting default list_format"); settings.list_format = vec![ ColumnConfig { name: "id".to_string(), label: "Item".to_string(), align: ColumnAlignment::Right, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ColumnConfig { name: "time".to_string(), label: "Time".to_string(), align: ColumnAlignment::Right, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ColumnConfig { name: "size".to_string(), label: "Size".to_string(), align: ColumnAlignment::Right, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ColumnConfig { name: "meta:text_line_count".to_string(), label: "Lines".to_string(), align: ColumnAlignment::Right, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ColumnConfig { name: "tags".to_string(), label: "Tags".to_string(), align: ColumnAlignment::Left, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ColumnConfig { name: "meta:hostname_short".to_string(), label: "Host".to_string(), align: ColumnAlignment::Left, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ColumnConfig { name: "meta:command".to_string(), label: "Command".to_string(), align: ColumnAlignment::Left, max_len: None, fg_color: None, bg_color: None, attributes: Vec::new(), padding: None, }, ]; } // Set default meta_plugins to include 'env' if not provided if settings.meta_plugins.is_none() { debug!("CONFIG: Setting default meta_plugins to include 'env'"); settings.meta_plugins = Some(vec![ MetaPluginConfig { name: "env".to_string(), options: std::collections::HashMap::new(), outputs: std::collections::HashMap::new(), } ]); } // Set dir to default if not provided or is empty if settings.dir == PathBuf::new() { debug!("CONFIG: Setting default dir: {:?}", default_dir); settings.dir = default_dir; } debug!("CONFIG: Final settings: {:?}", settings); Ok(settings) } Err(e) => { error!("CONFIG: Failed to deserialize settings: {}", e); Err(e.into()) } } } pub fn default_dir() -> anyhow::Result { let mut path = dirs::home_dir() .ok_or_else(|| anyhow::anyhow!("No home directory found"))?; path.push(".keep"); if !path.exists() { std::fs::create_dir_all(&path)?; } Ok(path) } /// Get server password from password_file or directly from config if configured pub fn get_server_password(&self) -> Result> { if let Some(server) = &self.server { // First check for password_file if let Some(password_file) = &server.password_file { debug!("CONFIG: Reading password from file: {:?}", password_file); let password = fs::read_to_string(password_file) .with_context(|| format!("Failed to read password file: {:?}", password_file))? .trim() .to_string(); return Ok(Some(password)); } // Fall back to direct password field if let Some(password) = &server.password { debug!("CONFIG: Using password from config"); return Ok(Some(password.clone())); } } Ok(None) } // Helper methods to access configuration values pub fn server_password(&self) -> Option { self.get_server_password().ok().flatten() } pub fn server_password_hash(&self) -> Option { self.server.as_ref().and_then(|s| s.password_hash.clone()) } pub fn server_address(&self) -> Option { self.server.as_ref().and_then(|s| s.address.clone()) } pub fn server_port(&self) -> Option { self.server.as_ref().and_then(|s| s.port) } pub fn compression(&self) -> Option { self.compression_plugin.as_ref().map(|c| c.name.clone()) } pub fn meta_plugins_names(&self) -> Vec { self.meta_plugins.as_ref() .map(|plugins| plugins.iter().map(|p| p.name.clone()).collect()) .unwrap_or_default() } }