use crate::args::Args; use anyhow::{Context, Result}; use dirs; use log::{debug, error}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; #[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 username: Option, pub password_file: Option, pub password: Option, pub password_hash: Option, pub jwt_secret: Option, pub jwt_secret_file: Option, pub cert_file: Option, pub key_file: Option, pub cors_origin: Option, pub max_body_size: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CompressionPluginConfig { pub name: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ClientConfig { pub url: Option, pub username: Option, pub password: Option, pub jwt: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "server", derive(utoipa::ToSchema))] pub struct MetaPluginConfig { pub name: String, #[serde(default)] #[cfg_attr(feature = "server", schema(value_type = Object))] pub options: std::collections::HashMap, #[serde(default)] #[cfg_attr(feature = "server", schema(value_type = Object))] pub outputs: std::collections::HashMap, } /// Unified settings that merges config file and CLI arguments #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Settings { #[serde(default)] pub dir: PathBuf, #[serde(default)] pub list_format: Vec, #[serde(default)] pub table_config: TableConfig, #[serde(default)] pub human_readable: bool, #[serde(default)] pub ids_only: 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>, pub client: Option, // Non-serializable fields populated from CLI args #[serde(skip)] pub client_url: Option, #[serde(skip)] pub client_username: Option, #[serde(skip)] pub client_password: Option, #[serde(skip)] pub client_jwt: Option, // Metadata key-value pairs from --meta CLI flag #[serde(skip)] pub meta: Vec<(String, Option)>, // Export filename format template (--export-filename-format) #[serde(skip)] pub export_filename_format: String, // Import data file path (--import-data-file) #[serde(skip)] pub import_data_file: 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 Ok(home_dir) = std::env::var("HOME") { 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() .ok_or_else(|| anyhow::anyhow!("non-UTF-8 directory path"))?, )?; } if args.options.human_readable { config_builder = config_builder.set_override("human_readable", true)?; } if args.options.ids_only { config_builder = config_builder.set_override("ids_only", 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_username) = &args.options.server_username { config_builder = config_builder.set_override("server.username", server_username.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)?; } #[cfg(feature = "tls")] if let Some(server_cert) = &args.mode.server_cert { config_builder = config_builder .set_override("server.cert_file", server_cert.to_string_lossy().as_ref())?; } #[cfg(feature = "tls")] if let Some(server_key) = &args.mode.server_key { config_builder = config_builder .set_override("server.key_file", server_key.to_string_lossy().as_ref())?; } if let Some(max_body_size) = args.options.server_max_body_size { config_builder = config_builder.set_override("server.max_body_size", max_body_size)?; } if let Some(compression) = &args.item.compression { config_builder = config_builder.set_override("compression_plugin.name", compression.as_str())?; } // Build MetaPluginConfig entries from --meta-plugin args (name[:json]) // These are handled after config deserialization (see below). 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(), }]); } // Override meta_plugins from --meta-plugin CLI args if !args.item.meta_plugins.is_empty() { debug!("CONFIG: Overriding meta_plugins from --meta-plugin CLI args"); let cli_plugins: Vec = args .item .meta_plugins .iter() .map(|arg| { let mut options = std::collections::HashMap::new(); let mut outputs = std::collections::HashMap::new(); if let Some(serde_json::Value::Object(obj)) = &arg.options { // Extract options and outputs from JSON value if let Some(serde_json::Value::Object(opts_obj)) = obj.get("options") { for (k, v) in opts_obj { let yaml_str = serde_json::to_string(v).unwrap_or_default(); let yaml_val: serde_yaml::Value = serde_yaml::from_str(&yaml_str) .unwrap_or(serde_yaml::Value::Null); options.insert(k.clone(), yaml_val); } } if let Some(serde_json::Value::Object(outs_obj)) = obj.get("outputs") { for (k, v) in outs_obj { let val_str = match v { serde_json::Value::String(s) => s.clone(), _ => v.to_string(), }; outputs.insert(k.clone(), val_str); } } } MetaPluginConfig { name: arg.name.clone(), options, outputs, } }) .collect(); settings.meta_plugins = Some(cli_plugins); } // Override list_format from --list-format CLI arg if args.options.list_format != "id,time,size,tags,meta:hostname" { debug!("CONFIG: Overriding list_format from --list-format CLI arg"); settings.list_format = Settings::parse_list_format(&args.options.list_format); } // 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; } // Populate client settings from CLI args and config #[cfg(feature = "client")] { settings.client_url = args .options .client_url .clone() .or_else(|| settings.client.as_ref().and_then(|c| c.url.clone())); settings.client_username = args .options .client_username .clone() .or_else(|| settings.client.as_ref().and_then(|c| c.username.clone())); settings.client_password = args .options .client_password .clone() .or_else(|| settings.client.as_ref().and_then(|c| c.password.clone())); settings.client_jwt = args .options .client_jwt .clone() .or_else(|| settings.client.as_ref().and_then(|c| c.jwt.clone())); } // Parse --meta key=value and bare key arguments settings.meta = args .item .meta .iter() .map(|s| { if let Some((key, value)) = s.split_once('=') { (key.to_string(), Some(value.to_string())) } else { (s.to_string(), None) } }) .collect(); // Set export filename format from CLI args settings.export_filename_format = args.item.export_filename_format.clone(); settings.import_data_file = args.item.import_data_file.clone(); 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(password_file) .with_context(|| format!("Failed to read password file: {password_file:?}"))?; let end = password.len().min(4096); let password = String::from_utf8_lossy(&password[..end]).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_username(&self) -> Option { self.server.as_ref().and_then(|s| s.username.clone()) } /// Get JWT secret from jwt_secret_file or directly from config if configured pub fn get_server_jwt_secret(&self) -> Result> { if let Some(server) = &self.server { // First check for jwt_secret_file if let Some(jwt_secret_file) = &server.jwt_secret_file { debug!("CONFIG: Reading JWT secret from file: {jwt_secret_file:?}"); let secret = fs::read(jwt_secret_file).with_context(|| { format!("Failed to read JWT secret file: {jwt_secret_file:?}") })?; let end = secret.len().min(4096); let secret = String::from_utf8_lossy(&secret[..end]).trim().to_string(); return Ok(Some(secret)); } // Fall back to direct jwt_secret field if let Some(secret) = &server.jwt_secret { debug!("CONFIG: Using JWT secret from config"); return Ok(Some(secret.clone())); } } Ok(None) } pub fn server_jwt_secret(&self) -> Option { self.get_server_jwt_secret().ok().flatten() } 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 server_cert_file(&self) -> Option { self.server.as_ref().and_then(|s| s.cert_file.clone()) } pub fn server_key_file(&self) -> Option { self.server.as_ref().and_then(|s| s.key_file.clone()) } pub fn server_cors_origin(&self) -> Option { self.server.as_ref().and_then(|s| s.cors_origin.clone()) } 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() } /// Validates the configuration against plugin schemas. /// /// Checks that: /// - All configured meta plugin names are valid and registered /// - Required options are present for each meta plugin /// - Compression plugin name (if set) is a valid compression type /// /// Returns a list of warning strings. An empty list means the config is valid. pub fn validate_config(&self) -> Vec { use crate::common::schema::gather_meta_plugin_schemas; use crate::compression_engine::CompressionType; use strum::IntoEnumIterator; let mut warnings = Vec::new(); // Validate compression plugin if let Some(ref comp) = self.compression_plugin { let valid_types: Vec = CompressionType::iter().map(|ct| ct.to_string()).collect(); if !valid_types.contains(&comp.name) { warnings.push(format!( "Unknown compression_plugin.name: '{}'. Valid types: {}", comp.name, valid_types.join(", ") )); } } // Validate meta plugins if let Some(ref plugins) = self.meta_plugins { let schemas = gather_meta_plugin_schemas(); let schema_map: std::collections::HashMap<&str, &crate::common::schema::PluginSchema> = schemas.iter().map(|s| (s.name.as_str(), s)).collect(); for plugin in plugins { match schema_map.get(plugin.name.as_str()) { Some(schema) => { // Check required options for opt in &schema.options { if opt.required && !plugin.options.contains_key(&opt.name) { warnings.push(format!( "Meta plugin '{}': missing required option '{}'", plugin.name, opt.name )); } } } None => { warnings.push(format!( "Unknown meta plugin: '{}'. Available: {}", plugin.name, schema_map.keys().copied().collect::>().join(", ") )); } } } } warnings } /// Parse a comma-separated column list string into Vec. /// /// Maps known column names to their default labels and alignment. /// For unknown names (including meta:* columns), uses the name as its own label. fn parse_list_format(input: &str) -> Vec { input .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|name| { let (label, align) = match name { "id" => ("Item", ColumnAlignment::Right), "time" => ("Time", ColumnAlignment::Right), "size" => ("Size", ColumnAlignment::Right), "meta:text_line_count" => ("Lines", ColumnAlignment::Right), "meta:token_count" => ("Tokens", ColumnAlignment::Right), "tags" => ("Tags", ColumnAlignment::Left), "meta:hostname_short" => ("Host", ColumnAlignment::Left), "meta:hostname" => ("Host", ColumnAlignment::Left), "meta:command" => ("Command", ColumnAlignment::Left), "compression" => ("Compression", ColumnAlignment::Left), other if other.starts_with("meta:") => { let sub = other.strip_prefix("meta:").unwrap_or(other); (sub, ColumnAlignment::Left) } other => (other, ColumnAlignment::Left), }; ColumnConfig { name: name.to_string(), label: label.to_string(), align, ..Default::default() } }) .collect() } }