Files
keep/src/config.rs
Andrew Phillips 8a8a6e1c4b fix: correct critical bugs and improve pipe streaming performance
Critical bug fixes:
- save_item now returns real Item from database, not a hardcoded fake
- AsyncDataService::save() reuses self.sync_service instead of creating redundant instance
- GenerateStatus trait signature mismatch fixed (CLI/API decoupling)

Performance improvements (pipe path untouched):
- CompressionEngine::open() returns Box<dyn Read + Send> enabling true streaming
- mode_get eliminates triple full-file read (was sampling then re-reading entire file)
- FilteringReader adds fast-path bypass when no filters, pre-allocates temp buffer
- text.rs meta plugin processes &[u8] slice directly, eliminates data.to_vec() clone

API correctness:
- Tag parse errors now return 400 instead of being silently discarded
- compute_diff uses similar crate (LCS-based) instead of naive positional comparison

Cleanup:
- Modernize string formatting (format!({x})) across codebase
- Remove redundant DB query in get mode
- Derive Debug/ToSchema on public types
- Delete placeholder test files with no real assertions
- Extract parse_comma_tags utility function
2026-03-11 20:45:05 -03:00

467 lines
16 KiB
Rust

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<String>,
#[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<String>,
#[serde(default)]
pub fg_color: Option<TableColor>,
#[serde(default)]
pub bg_color: Option<TableColor>,
#[serde(default)]
pub attributes: Vec<TableAttribute>,
#[serde(default)]
pub padding: Option<(u16, u16)>,
}
impl<'de> serde::Deserialize<'de> for ColumnConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
name: String,
label: Option<String>,
#[serde(default)]
align: ColumnAlignment,
#[serde(default)]
max_len: Option<String>,
#[serde(default)]
fg_color: Option<TableColor>,
#[serde(default)]
bg_color: Option<TableColor>,
#[serde(default)]
attributes: Vec<TableAttribute>,
#[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<String>,
pub port: Option<u16>,
pub password_file: Option<PathBuf>,
pub password: Option<String>,
pub password_hash: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompressionPluginConfig {
pub name: String,
}
#[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<String, serde_yaml::Value>,
#[serde(default)]
#[cfg_attr(feature = "server", schema(value_type = Object))]
pub outputs: std::collections::HashMap<String, String>,
}
/// 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<ColumnConfig>,
#[serde(default)]
pub table_config: TableConfig,
#[serde(default)]
pub human_readable: bool,
pub output_format: Option<String>,
#[serde(default)]
pub quiet: bool,
#[serde(default)]
pub force: bool,
pub server: Option<ServerConfig>,
pub compression_plugin: Option<CompressionPluginConfig>,
pub meta_plugins: Option<Vec<MetaPluginConfig>>,
}
impl Settings {
/// Create unified settings from config and args with proper priority
pub fn new(args: &Args, default_dir: PathBuf) -> Result<Self> {
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().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<std::collections::HashMap<String, String>> = 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::<Settings>() {
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<PathBuf> {
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<Option<String>> {
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<String> {
self.get_server_password().ok().flatten()
}
pub fn server_password_hash(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.password_hash.clone())
}
pub fn server_address(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.address.clone())
}
pub fn server_port(&self) -> Option<u16> {
self.server.as_ref().and_then(|s| s.port)
}
pub fn compression(&self) -> Option<String> {
self.compression_plugin.as_ref().map(|c| c.name.clone())
}
pub fn meta_plugins_names(&self) -> Vec<String> {
self.meta_plugins
.as_ref()
.map(|plugins| plugins.iter().map(|p| p.name.clone()).collect())
.unwrap_or_default()
}
}