From 067cba703bc7a6a7ebe3fcf5e648c265ab809182 Mon Sep 17 00:00:00 2001 From: Andrew Phillips Date: Fri, 15 Aug 2025 16:31:57 -0300 Subject: [PATCH] feat: add config system with --config argument and priority-based configuration Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) --- Cargo.toml | 1 + src/args.rs | 4 +++ src/config.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 82 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 030ef60..9b8918e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } uzers = "0.12.1" which = "8.0.0" +xdg = "2.5.2" [dev-dependencies] tempfile = "3.3.0" diff --git a/src/args.rs b/src/args.rs index a1cd19d..3ed2dfb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -99,6 +99,10 @@ pub struct ItemArgs { */ #[derive(Parser, Debug, Default, Clone)] pub struct OptionsArgs { + #[arg(long, env("KEEP_CONFIG"))] + #[arg(help("Specify the configuration file to use"))] + pub config: Option, + #[arg(long, env("KEEP_DIR"))] #[arg(help("Specify the directory to use for storage"))] pub dir: Option, diff --git a/src/config.rs b/src/config.rs index e69de29..51726e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -0,0 +1,96 @@ +use std::path::PathBuf; +use std::fs; +use anyhow::{Result, Context}; +use serde::{Deserialize, Serialize}; +use log::debug; + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Config { + pub dir: Option, + pub list_format: Option, + pub human_readable: Option, + pub server: Option, + pub compression_plugin: Option, + pub meta_plugins: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ServerConfig { + pub address: Option, + pub port: Option, + pub password_file: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CompressionPluginConfig { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MetaPluginConfig { + pub name: String, +} + +impl Config { + /// Load configuration from a file + pub fn from_file(path: &PathBuf) -> Result { + debug!("CONFIG: Loading config from {:?}", path); + + if !path.exists() { + debug!("CONFIG: Config file does not exist, using defaults"); + return Ok(Config::default()); + } + + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {:?}", path))?; + + let config: Config = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse config file: {:?}", path))?; + + debug!("CONFIG: Loaded config: {:?}", config); + Ok(config) + } + + /// Get the default config file path + pub fn default_config_path() -> Result { + let xdg_dirs = xdg::BaseDirectories::with_prefix("keep")?; + Ok(xdg_dirs.get_config_home().join("config.yml")) + } + + /// Read password from password_file if configured + pub fn get_server_password(&self) -> Result> { + if let Some(server) = &self.server { + 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)); + } + } + Ok(None) + } + + /// Merge this config with another, giving priority to the other config + pub fn merge_with(&mut self, other: &Config) { + if other.dir.is_some() { + self.dir = other.dir.clone(); + } + if other.list_format.is_some() { + self.list_format = other.list_format.clone(); + } + if other.human_readable.is_some() { + self.human_readable = other.human_readable; + } + if other.server.is_some() { + self.server = other.server.clone(); + } + if other.compression_plugin.is_some() { + self.compression_plugin = other.compression_plugin.clone(); + } + if other.meta_plugins.is_some() { + self.meta_plugins = other.meta_plugins.clone(); + } + } +} diff --git a/src/main.rs b/src/main.rs index 2b8b0ec..3ca6a16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod args; +mod config; mod modes; +use std::path::PathBuf; use anyhow::{Context, Error, Result, anyhow}; use clap::*; use clap::error::ErrorKind; @@ -27,6 +29,7 @@ extern crate serde_yaml; extern crate serde; use args::{Args, NumberOrString}; +use config::Config; /** * Main function to handle command-line arguments and execute the appropriate mode. @@ -48,6 +51,22 @@ fn main() -> Result<(), Error> { debug!("MAIN: Start"); + // Load configuration with priority: CLI args > env vars > config file > defaults + 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 { + Config::default_config_path().unwrap_or_else(|_| PathBuf::from("~/.config/keep/config.yml")) + }; + + let mut config = Config::from_file(&config_path).unwrap_or_else(|e| { + debug!("CONFIG: Failed to load config: {}, using defaults", e); + Config::default() + }); + + debug!("MAIN: Loaded config: {:?}", config); + let ids = &mut Vec::new(); let tags = &mut Vec::new(); @@ -95,6 +114,23 @@ fn main() -> Result<(), Error> { mode = KeepModes::Status; } else if args.mode.server.is_some() { mode = KeepModes::Server; + } else if config.server.is_some() && args.mode.server.is_none() { + // If server is configured in config file but not specified via CLI + if let Some(server_config) = &config.server { + let mut server_addr = String::new(); + if let Some(address) = &server_config.address { + server_addr.push_str(address); + } else { + server_addr.push_str("127.0.0.1"); + } + if let Some(port) = server_config.port { + server_addr.push_str(&format!(":{}", port)); + } else { + server_addr.push_str(":8080"); + } + args.mode.server = Some(server_addr); + mode = KeepModes::Server; + } } if mode == KeepModes::Unknown { @@ -136,10 +172,50 @@ fn main() -> Result<(), Error> { debug!("MAIN: tags: {:?}", tags); debug!("MAIN: mode: {:?}", mode); + // Apply configuration priority: CLI args > env vars > config file > defaults if args.options.dir.is_none() { - match proj_dirs { - Some(proj_dirs) => args.options.dir = Some(proj_dirs.data_dir().to_path_buf()), - None => return Err(anyhow!("Unable to determine data directory")), + if let Some(config_dir) = &config.dir { + args.options.dir = Some(config_dir.clone()); + } else { + match proj_dirs { + Some(proj_dirs) => args.options.dir = Some(proj_dirs.data_dir().to_path_buf()), + None => return Err(anyhow!("Unable to determine data directory")), + } + } + } + + // Apply list_format from config if not set via CLI/env + if args.options.list_format == "id,time,size,tags,meta:hostname" { + if let Some(config_list_format) = &config.list_format { + args.options.list_format = config_list_format.clone(); + } + } + + // Apply human_readable from config if not set via CLI + if !args.options.human_readable { + if let Some(config_human_readable) = config.human_readable { + args.options.human_readable = config_human_readable; + } + } + + // Apply server password from config file if not set via CLI/env + if args.options.server_password.is_none() { + if let Ok(Some(password)) = config.get_server_password() { + args.options.server_password = Some(password); + } + } + + // Apply compression from config if not set via CLI/env + if args.item.compression.is_none() { + if let Some(compression_plugin) = &config.compression_plugin { + args.item.compression = Some(compression_plugin.name.clone()); + } + } + + // Apply meta_plugins from config if not set via CLI/env + if args.item.meta_plugins.is_empty() { + if let Some(meta_plugins) = &config.meta_plugins { + args.item.meta_plugins = meta_plugins.iter().map(|p| p.name.clone()).collect(); } }