use std::path::PathBuf;
use anyhow::Result;
use config::{Environment, File, FileFormat};
use serde::{Deserialize, Serialize};
use crate::paths::AppPaths;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppConfig {
pub theme: ThemeConfig,
pub history_limit: usize,
pub retention_days: u32,
pub max_log_lines_in_memory: usize,
pub max_persisted_log_lines: usize,
pub database_path: PathBuf,
pub detection_poll_interval_ms: u64,
pub auto_follow_running_session: bool,
pub capture_raw_log_storage: bool,
pub command_presets: Vec<CommandPreset>,
}
impl AppConfig {
pub fn default_for(paths: &AppPaths) -> Self {
Self {
theme: ThemeConfig::default(),
history_limit: 24,
retention_days: 30,
max_log_lines_in_memory: 4_000,
max_persisted_log_lines: 8_000,
database_path: paths.database_path.clone(),
detection_poll_interval_ms: 1_500,
auto_follow_running_session: true,
capture_raw_log_storage: true,
command_presets: vec![
CommandPreset::new("cargo check", "cargo check"),
CommandPreset::new("cargo build", "cargo build"),
CommandPreset::new("cargo test", "cargo test"),
CommandPreset::new("cargo clippy", "cargo clippy --workspace --all-targets"),
],
}
}
fn apply_partial(mut self, partial: PartialAppConfig) -> Self {
if let Some(theme) = partial.theme {
self.theme = self.theme.apply_partial(theme);
}
if let Some(history_limit) = partial.history_limit {
self.history_limit = history_limit;
}
if let Some(retention_days) = partial.retention_days {
self.retention_days = retention_days;
}
if let Some(max_log_lines_in_memory) = partial.max_log_lines_in_memory {
self.max_log_lines_in_memory = max_log_lines_in_memory;
}
if let Some(max_persisted_log_lines) = partial.max_persisted_log_lines {
self.max_persisted_log_lines = max_persisted_log_lines;
}
if let Some(database_path) = partial.database_path {
self.database_path = database_path;
}
if let Some(detection_poll_interval_ms) = partial.detection_poll_interval_ms {
self.detection_poll_interval_ms = detection_poll_interval_ms;
}
if let Some(auto_follow_running_session) = partial.auto_follow_running_session {
self.auto_follow_running_session = auto_follow_running_session;
}
if let Some(capture_raw_log_storage) = partial.capture_raw_log_storage {
self.capture_raw_log_storage = capture_raw_log_storage;
}
if let Some(command_presets) = partial.command_presets {
self.command_presets = command_presets;
}
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ThemeConfig {
pub accent: String,
pub success: String,
pub warning: String,
pub error: String,
pub info: String,
pub muted: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
accent: "#5BA3E8".to_string(),
success: "#58B368".to_string(),
warning: "#E5A33A".to_string(),
error: "#D95D5D".to_string(),
info: "#7BAFD4".to_string(),
muted: "#6C757D".to_string(),
}
}
}
impl ThemeConfig {
fn apply_partial(mut self, partial: PartialThemeConfig) -> Self {
if let Some(accent) = partial.accent {
self.accent = accent;
}
if let Some(success) = partial.success {
self.success = success;
}
if let Some(warning) = partial.warning {
self.warning = warning;
}
if let Some(error) = partial.error {
self.error = error;
}
if let Some(info) = partial.info {
self.info = info;
}
if let Some(muted) = partial.muted {
self.muted = muted;
}
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CommandPreset {
pub name: String,
pub command: String,
}
impl CommandPreset {
pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
name: name.into(),
command: command.into(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct PartialAppConfig {
pub theme: Option<PartialThemeConfig>,
pub history_limit: Option<usize>,
pub retention_days: Option<u32>,
pub max_log_lines_in_memory: Option<usize>,
pub max_persisted_log_lines: Option<usize>,
pub database_path: Option<PathBuf>,
pub detection_poll_interval_ms: Option<u64>,
pub auto_follow_running_session: Option<bool>,
pub capture_raw_log_storage: Option<bool>,
pub command_presets: Option<Vec<CommandPreset>>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct PartialThemeConfig {
pub accent: Option<String>,
pub success: Option<String>,
pub warning: Option<String>,
pub error: Option<String>,
pub info: Option<String>,
pub muted: Option<String>,
}
pub fn load_config(paths: &AppPaths) -> Result<AppConfig> {
let defaults = AppConfig::default_for(paths);
let layered = config::Config::builder()
.add_source(
File::new(
paths.config_file().to_string_lossy().as_ref(),
FileFormat::Toml,
)
.required(false),
)
.add_source(
Environment::with_prefix("CARGOWATCH")
.separator("__")
.try_parsing(true),
)
.build()?;
let partial = layered.try_deserialize::<PartialAppConfig>()?;
Ok(defaults.apply_partial(partial))
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn load_config_layers_file() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("cw-home");
let paths = AppPaths {
root: Some(root.clone()),
config_dir: root.join("config"),
data_dir: root.join("data"),
log_dir: root.join("logs"),
config_file: root.join("config").join("config.toml"),
database_path: root.join("data").join("cargowatch.db"),
};
paths.ensure_exists().expect("dirs");
fs::write(
paths.config_file(),
r##"
retention_days = 7
[theme]
accent = "#112233"
"##,
)
.expect("config file");
let config = load_config(&paths).expect("config");
assert_eq!(config.retention_days, 7);
assert_eq!(config.history_limit, 24);
assert_eq!(config.theme.accent, "#112233");
}
}