1use std::path::PathBuf;
4
5use anyhow::Result;
6use config::{Environment, File, FileFormat};
7use serde::{Deserialize, Serialize};
8
9use crate::paths::AppPaths;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct AppConfig {
14 pub theme: ThemeConfig,
16 pub history_limit: usize,
18 pub retention_days: u32,
20 pub max_log_lines_in_memory: usize,
22 pub max_persisted_log_lines: usize,
24 pub database_path: PathBuf,
26 pub detection_poll_interval_ms: u64,
28 pub auto_follow_running_session: bool,
30 pub capture_raw_log_storage: bool,
32 pub command_presets: Vec<CommandPreset>,
34}
35
36impl AppConfig {
37 pub fn default_for(paths: &AppPaths) -> Self {
39 Self {
40 theme: ThemeConfig::default(),
41 history_limit: 24,
42 retention_days: 30,
43 max_log_lines_in_memory: 4_000,
44 max_persisted_log_lines: 8_000,
45 database_path: paths.database_path.clone(),
46 detection_poll_interval_ms: 1_500,
47 auto_follow_running_session: true,
48 capture_raw_log_storage: true,
49 command_presets: vec![
50 CommandPreset::new("cargo check", "cargo check"),
51 CommandPreset::new("cargo build", "cargo build"),
52 CommandPreset::new("cargo test", "cargo test"),
53 CommandPreset::new("cargo clippy", "cargo clippy --workspace --all-targets"),
54 ],
55 }
56 }
57
58 fn apply_partial(mut self, partial: PartialAppConfig) -> Self {
59 if let Some(theme) = partial.theme {
60 self.theme = self.theme.apply_partial(theme);
61 }
62 if let Some(history_limit) = partial.history_limit {
63 self.history_limit = history_limit;
64 }
65 if let Some(retention_days) = partial.retention_days {
66 self.retention_days = retention_days;
67 }
68 if let Some(max_log_lines_in_memory) = partial.max_log_lines_in_memory {
69 self.max_log_lines_in_memory = max_log_lines_in_memory;
70 }
71 if let Some(max_persisted_log_lines) = partial.max_persisted_log_lines {
72 self.max_persisted_log_lines = max_persisted_log_lines;
73 }
74 if let Some(database_path) = partial.database_path {
75 self.database_path = database_path;
76 }
77 if let Some(detection_poll_interval_ms) = partial.detection_poll_interval_ms {
78 self.detection_poll_interval_ms = detection_poll_interval_ms;
79 }
80 if let Some(auto_follow_running_session) = partial.auto_follow_running_session {
81 self.auto_follow_running_session = auto_follow_running_session;
82 }
83 if let Some(capture_raw_log_storage) = partial.capture_raw_log_storage {
84 self.capture_raw_log_storage = capture_raw_log_storage;
85 }
86 if let Some(command_presets) = partial.command_presets {
87 self.command_presets = command_presets;
88 }
89 self
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct ThemeConfig {
96 pub accent: String,
98 pub success: String,
100 pub warning: String,
102 pub error: String,
104 pub info: String,
106 pub muted: String,
108}
109
110impl Default for ThemeConfig {
111 fn default() -> Self {
112 Self {
113 accent: "#5BA3E8".to_string(),
114 success: "#58B368".to_string(),
115 warning: "#E5A33A".to_string(),
116 error: "#D95D5D".to_string(),
117 info: "#7BAFD4".to_string(),
118 muted: "#6C757D".to_string(),
119 }
120 }
121}
122
123impl ThemeConfig {
124 fn apply_partial(mut self, partial: PartialThemeConfig) -> Self {
125 if let Some(accent) = partial.accent {
126 self.accent = accent;
127 }
128 if let Some(success) = partial.success {
129 self.success = success;
130 }
131 if let Some(warning) = partial.warning {
132 self.warning = warning;
133 }
134 if let Some(error) = partial.error {
135 self.error = error;
136 }
137 if let Some(info) = partial.info {
138 self.info = info;
139 }
140 if let Some(muted) = partial.muted {
141 self.muted = muted;
142 }
143 self
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct CommandPreset {
150 pub name: String,
152 pub command: String,
154}
155
156impl CommandPreset {
157 pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
159 Self {
160 name: name.into(),
161 command: command.into(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
168pub struct PartialAppConfig {
169 pub theme: Option<PartialThemeConfig>,
171 pub history_limit: Option<usize>,
173 pub retention_days: Option<u32>,
175 pub max_log_lines_in_memory: Option<usize>,
177 pub max_persisted_log_lines: Option<usize>,
179 pub database_path: Option<PathBuf>,
181 pub detection_poll_interval_ms: Option<u64>,
183 pub auto_follow_running_session: Option<bool>,
185 pub capture_raw_log_storage: Option<bool>,
187 pub command_presets: Option<Vec<CommandPreset>>,
189}
190
191#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
193pub struct PartialThemeConfig {
194 pub accent: Option<String>,
196 pub success: Option<String>,
198 pub warning: Option<String>,
200 pub error: Option<String>,
202 pub info: Option<String>,
204 pub muted: Option<String>,
206}
207
208pub fn load_config(paths: &AppPaths) -> Result<AppConfig> {
210 let defaults = AppConfig::default_for(paths);
211 let layered = config::Config::builder()
212 .add_source(
213 File::new(
214 paths.config_file().to_string_lossy().as_ref(),
215 FileFormat::Toml,
216 )
217 .required(false),
218 )
219 .add_source(
220 Environment::with_prefix("CARGOWATCH")
221 .separator("__")
222 .try_parsing(true),
223 )
224 .build()?;
225 let partial = layered.try_deserialize::<PartialAppConfig>()?;
226 Ok(defaults.apply_partial(partial))
227}
228
229#[cfg(test)]
230mod tests {
231 use std::fs;
232
233 use tempfile::tempdir;
234
235 use super::*;
236
237 #[test]
238 fn load_config_layers_file() {
239 let temp = tempdir().expect("tempdir");
240 let root = temp.path().join("cw-home");
241 let paths = AppPaths {
242 root: Some(root.clone()),
243 config_dir: root.join("config"),
244 data_dir: root.join("data"),
245 log_dir: root.join("logs"),
246 config_file: root.join("config").join("config.toml"),
247 database_path: root.join("data").join("cargowatch.db"),
248 };
249 paths.ensure_exists().expect("dirs");
250 fs::write(
251 paths.config_file(),
252 r##"
253retention_days = 7
254[theme]
255accent = "#112233"
256"##,
257 )
258 .expect("config file");
259
260 let config = load_config(&paths).expect("config");
261
262 assert_eq!(config.retention_days, 7);
263 assert_eq!(config.history_limit, 24);
264 assert_eq!(config.theme.accent, "#112233");
265 }
266}