1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::package::Runner;
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SortMode {
14 #[default]
16 Recent,
17 Alpha,
19 Category,
21}
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ColumnDirection {
27 #[default]
29 Horizontal,
30 Vertical,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum Theme {
38 #[default]
40 Default,
41 Minimal,
43 None,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct GeneralConfig {
50 #[serde(default)]
52 pub runner: Option<Runner>,
53 #[serde(default)]
55 pub default_sort: SortMode,
56 #[serde(default)]
58 pub column_direction: ColumnDirection,
59 #[serde(default = "default_true")]
61 pub show_command_preview: bool,
62 #[serde(default)]
64 pub max_items: usize,
65}
66
67impl Default for GeneralConfig {
68 fn default() -> Self {
69 Self {
70 runner: None,
71 default_sort: SortMode::default(),
72 column_direction: ColumnDirection::default(),
73 show_command_preview: true,
74 max_items: 0,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FilterConfig {
82 #[serde(default = "default_true")]
84 pub search_descriptions: bool,
85 #[serde(default = "default_true")]
87 pub fuzzy: bool,
88 #[serde(default)]
90 pub case_sensitive: bool,
91}
92
93impl Default for FilterConfig {
94 fn default() -> Self {
95 Self {
96 search_descriptions: true,
97 fuzzy: true,
98 case_sensitive: false,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct HistoryConfig {
106 #[serde(default = "default_true")]
108 pub enabled: bool,
109 #[serde(default = "default_max_projects")]
111 pub max_projects: usize,
112 #[serde(default = "default_max_scripts")]
114 pub max_scripts: usize,
115}
116
117impl Default for HistoryConfig {
118 fn default() -> Self {
119 Self {
120 enabled: true,
121 max_projects: 100,
122 max_scripts: 50,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129pub struct ExcludeConfig {
130 #[serde(default)]
132 pub patterns: Vec<String>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct AppearanceConfig {
138 #[serde(default)]
140 pub theme: Theme,
141 #[serde(default = "default_true")]
143 pub icons: bool,
144 #[serde(default = "default_true")]
146 pub show_footer: bool,
147 #[serde(default)]
149 pub compact: bool,
150}
151
152impl Default for AppearanceConfig {
153 fn default() -> Self {
154 Self {
155 theme: Theme::default(),
156 icons: true,
157 show_footer: true,
158 compact: false,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct KeybindingsConfig {
166 #[serde(default)]
168 pub quit: Vec<String>,
169 #[serde(default)]
171 pub run: Vec<String>,
172 #[serde(default)]
174 pub filter: Vec<String>,
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ScriptsConfig {
180 #[serde(default)]
182 pub descriptions: HashMap<String, String>,
183 #[serde(default)]
185 pub aliases: HashMap<String, String>,
186}
187
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190pub struct Config {
191 #[serde(default)]
193 pub general: GeneralConfig,
194 #[serde(default)]
196 pub filter: FilterConfig,
197 #[serde(default)]
199 pub history: HistoryConfig,
200 #[serde(default)]
202 pub exclude: ExcludeConfig,
203 #[serde(default)]
205 pub appearance: AppearanceConfig,
206 #[serde(default)]
208 pub keybindings: KeybindingsConfig,
209 #[serde(default)]
211 pub scripts: ScriptsConfig,
212}
213
214impl Config {
215 pub fn new() -> Self {
217 Self::default()
218 }
219
220 pub fn user_config_path() -> Option<PathBuf> {
222 dirs::config_dir().map(|p| p.join("nrs").join("config.toml"))
223 }
224
225 pub fn merge(&mut self, other: Config) {
227 if other.general.runner.is_some() {
229 self.general.runner = other.general.runner;
230 }
231 self.general.default_sort = other.general.default_sort;
232 self.general.column_direction = other.general.column_direction;
233 self.general.show_command_preview = other.general.show_command_preview;
234 if other.general.max_items > 0 {
235 self.general.max_items = other.general.max_items;
236 }
237
238 self.filter = other.filter;
240
241 self.history = other.history;
243
244 self.exclude.patterns.extend(other.exclude.patterns);
246
247 self.appearance = other.appearance;
249
250 if !other.keybindings.quit.is_empty() {
252 self.keybindings.quit = other.keybindings.quit;
253 }
254 if !other.keybindings.run.is_empty() {
255 self.keybindings.run = other.keybindings.run;
256 }
257 if !other.keybindings.filter.is_empty() {
258 self.keybindings.filter = other.keybindings.filter;
259 }
260
261 self.scripts.descriptions.extend(other.scripts.descriptions);
263 self.scripts.aliases.extend(other.scripts.aliases);
264 }
265}
266
267fn default_true() -> bool {
268 true
269}
270
271fn default_max_projects() -> usize {
272 100
273}
274
275fn default_max_scripts() -> usize {
276 50
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn test_default_config() {
285 let config = Config::default();
286 assert!(config.filter.fuzzy);
287 assert!(config.filter.search_descriptions);
288 assert!(!config.filter.case_sensitive);
289 assert!(config.history.enabled);
290 assert_eq!(config.history.max_projects, 100);
291 assert_eq!(config.history.max_scripts, 50);
292 assert!(config.appearance.icons);
293 assert!(config.appearance.show_footer);
294 assert!(!config.appearance.compact);
295 assert_eq!(config.appearance.theme, Theme::Default);
296 }
297
298 #[test]
299 fn test_sort_mode_serialization() {
300 let json = serde_json::to_string(&SortMode::Alpha).unwrap();
301 assert_eq!(json, "\"alpha\"");
302
303 let mode: SortMode = serde_json::from_str("\"category\"").unwrap();
304 assert_eq!(mode, SortMode::Category);
305 }
306
307 #[test]
308 fn test_column_direction_serialization() {
309 let json = serde_json::to_string(&ColumnDirection::Vertical).unwrap();
310 assert_eq!(json, "\"vertical\"");
311
312 let dir: ColumnDirection = serde_json::from_str("\"horizontal\"").unwrap();
313 assert_eq!(dir, ColumnDirection::Horizontal);
314 }
315
316 #[test]
317 fn test_theme_serialization() {
318 let json = serde_json::to_string(&Theme::Minimal).unwrap();
319 assert_eq!(json, "\"minimal\"");
320
321 let theme: Theme = serde_json::from_str("\"none\"").unwrap();
322 assert_eq!(theme, Theme::None);
323 }
324
325 #[test]
326 fn test_config_merge() {
327 let mut base = Config::default();
328 base.exclude.patterns.push("test*".to_string());
329
330 let mut override_config = Config::default();
331 override_config.general.runner = Some(Runner::Pnpm);
332 override_config.exclude.patterns.push("lint*".to_string());
333 override_config
334 .scripts
335 .descriptions
336 .insert("dev".to_string(), "Start dev server".to_string());
337
338 base.merge(override_config);
339
340 assert_eq!(base.general.runner, Some(Runner::Pnpm));
341 assert_eq!(base.exclude.patterns.len(), 2);
342 assert!(base.exclude.patterns.contains(&"test*".to_string()));
343 assert!(base.exclude.patterns.contains(&"lint*".to_string()));
344 assert_eq!(
345 base.scripts.descriptions.get("dev"),
346 Some(&"Start dev server".to_string())
347 );
348 }
349}