npm_run_scripts/config/
file.rs1use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7
8use super::types::Config;
9
10fn load_config_from_path(path: &Path) -> Result<Config> {
16 let content = fs::read_to_string(path)
17 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
18
19 let config: Config = toml::from_str(&content)
20 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
21
22 Ok(config)
23}
24
25pub fn load_config(cli_config_path: Option<&Path>, project_dir: &Path) -> Result<Config> {
45 let mut config = Config::default();
46
47 if let Some(user_config_path) = Config::user_config_path() {
49 if user_config_path.exists() {
50 match load_config_from_path(&user_config_path) {
51 Ok(user_config) => config.merge(user_config),
52 Err(e) => {
53 eprintln!(
55 "Warning: Failed to load user config at {}: {}",
56 user_config_path.display(),
57 e
58 );
59 }
60 }
61 }
62 }
63
64 let project_config_path = project_dir.join(".nrsrc.toml");
66 if project_config_path.exists() {
67 match load_config_from_path(&project_config_path) {
68 Ok(project_config) => config.merge(project_config),
69 Err(e) => {
70 eprintln!(
72 "Warning: Failed to load project config at {}: {}",
73 project_config_path.display(),
74 e
75 );
76 }
77 }
78 }
79
80 if let Some(cli_path) = cli_config_path {
82 let cli_config = load_config_from_path(cli_path).with_context(|| {
83 format!(
84 "Failed to load config from CLI-specified path: {}",
85 cli_path.display()
86 )
87 })?;
88 config.merge(cli_config);
89 }
90
91 Ok(config)
92}
93
94pub fn generate_example_config() -> String {
96 r#"# nrs Configuration File
97# Place this file at ~/.config/nrs/config.toml for global settings
98# or .nrsrc.toml in your project directory for project-specific settings
99
100# General settings
101[general]
102# Default package manager (overrides auto-detection)
103# Options: "npm", "yarn", "pnpm", "bun"
104# runner = "pnpm"
105
106# Default sort mode: "recent", "alpha", "category"
107default_sort = "recent"
108
109# Column direction: "horizontal", "vertical"
110# horizontal: 1 2 3 4 / 5 6 7 8
111# vertical: 1 4 7 / 2 5 8 / 3 6 9
112column_direction = "horizontal"
113
114# Show command preview in description panel
115show_command_preview = true
116
117# Maximum items to show (0 = unlimited)
118max_items = 0
119
120# Filter settings
121[filter]
122# Search in descriptions too
123search_descriptions = true
124
125# Fuzzy matching
126fuzzy = true
127
128# Case sensitive search
129case_sensitive = false
130
131# History settings
132[history]
133# Enable history tracking
134enabled = true
135
136# Max projects to remember
137max_projects = 100
138
139# Max scripts per project
140max_scripts = 50
141
142# Exclude patterns
143[exclude]
144# Global patterns to exclude (glob syntax)
145patterns = [
146 # "pre*",
147 # "post*",
148]
149
150# Appearance settings
151[appearance]
152# Color theme: "default", "minimal", "none"
153theme = "default"
154
155# Show icons
156icons = true
157
158# Show help footer
159show_footer = true
160
161# Compact mode (less padding)
162compact = false
163
164# Keybindings (advanced)
165[keybindings]
166# Custom keybindings
167# quit = ["q", "Ctrl+c"]
168# run = ["Enter", "o"]
169# filter = ["/", "Ctrl+f"]
170
171# Script customizations
172[scripts]
173
174# Custom descriptions for scripts (override package.json)
175[scripts.descriptions]
176# dev = "Start dev server on port 3000"
177# build = "Production build with minification"
178
179# Script aliases
180[scripts.aliases]
181# d = "dev"
182# b = "build"
183# t = "test"
184"#
185 .to_string()
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use std::fs;
192 use tempfile::TempDir;
193
194 fn create_temp_dir() -> TempDir {
195 TempDir::new().expect("Failed to create temp directory")
196 }
197
198 #[test]
199 fn test_load_default_config_returns_defaults() {
200 let temp = create_temp_dir();
201 let config = load_config(None, temp.path()).unwrap();
202
203 assert!(config.history.enabled);
204 assert_eq!(config.history.max_projects, 100);
205 assert!(config.filter.fuzzy);
206 assert!(config.appearance.icons);
207 }
208
209 #[test]
210 fn test_load_project_config() {
211 let temp = create_temp_dir();
212
213 let config_content = r#"
214[general]
215runner = "yarn"
216default_sort = "alpha"
217
218[filter]
219fuzzy = false
220"#;
221
222 fs::write(temp.path().join(".nrsrc.toml"), config_content).unwrap();
223
224 let config = load_config(None, temp.path()).unwrap();
225
226 assert_eq!(config.general.runner, Some(crate::package::Runner::Yarn));
227 assert_eq!(config.general.default_sort, super::super::SortMode::Alpha);
228 assert!(!config.filter.fuzzy);
229 }
230
231 #[test]
232 fn test_load_cli_config_overrides() {
233 let temp = create_temp_dir();
234
235 let project_config = r#"
237[general]
238runner = "yarn"
239
240[appearance]
241icons = false
242"#;
243 fs::write(temp.path().join(".nrsrc.toml"), project_config).unwrap();
244
245 let cli_config_path = temp.path().join("cli-config.toml");
247 let cli_config = r#"
248[general]
249runner = "pnpm"
250
251[appearance]
252icons = false
253"#;
254 fs::write(&cli_config_path, cli_config).unwrap();
255
256 let config = load_config(Some(&cli_config_path), temp.path()).unwrap();
257
258 assert_eq!(config.general.runner, Some(crate::package::Runner::Pnpm));
260 assert!(!config.appearance.icons);
262 }
263
264 #[test]
265 fn test_cli_config_uses_defaults_when_section_not_specified() {
266 let temp = create_temp_dir();
267
268 let project_config = r#"
270[appearance]
271icons = false
272compact = true
273"#;
274 fs::write(temp.path().join(".nrsrc.toml"), project_config).unwrap();
275
276 let cli_config_path = temp.path().join("cli-config.toml");
278 let cli_config = r#"
279[general]
280runner = "pnpm"
281"#;
282 fs::write(&cli_config_path, cli_config).unwrap();
283
284 let config = load_config(Some(&cli_config_path), temp.path()).unwrap();
285
286 assert_eq!(config.general.runner, Some(crate::package::Runner::Pnpm));
290 assert!(config.appearance.icons);
292 }
293
294 #[test]
295 fn test_load_cli_config_file_not_found() {
296 let temp = create_temp_dir();
297 let non_existent = temp.path().join("does-not-exist.toml");
298
299 let result = load_config(Some(&non_existent), temp.path());
300
301 assert!(result.is_err());
302 assert!(result
303 .unwrap_err()
304 .to_string()
305 .contains("Failed to load config"));
306 }
307
308 #[test]
309 fn test_invalid_toml_handling() {
310 let temp = create_temp_dir();
311
312 let invalid_toml = "this is not valid { toml }}}";
313 let cli_config_path = temp.path().join("invalid.toml");
314 fs::write(&cli_config_path, invalid_toml).unwrap();
315
316 let result = load_config(Some(&cli_config_path), temp.path());
317
318 assert!(result.is_err());
319 }
320
321 #[test]
322 fn test_partial_config() {
323 let temp = create_temp_dir();
324
325 let config_content = r#"
327[history]
328max_projects = 50
329"#;
330
331 fs::write(temp.path().join(".nrsrc.toml"), config_content).unwrap();
332
333 let config = load_config(None, temp.path()).unwrap();
334
335 assert_eq!(config.history.max_projects, 50);
337 assert!(config.history.enabled);
339 assert_eq!(config.history.max_scripts, 50);
340 assert!(config.filter.fuzzy);
341 }
342
343 #[test]
344 fn test_exclude_patterns_merge() {
345 let temp = create_temp_dir();
346
347 let project_config = r#"
349[exclude]
350patterns = ["test*", "lint*"]
351"#;
352 fs::write(temp.path().join(".nrsrc.toml"), project_config).unwrap();
353
354 let cli_config_path = temp.path().join("cli.toml");
356 let cli_config = r#"
357[exclude]
358patterns = ["debug*"]
359"#;
360 fs::write(&cli_config_path, cli_config).unwrap();
361
362 let config = load_config(Some(&cli_config_path), temp.path()).unwrap();
363
364 assert_eq!(config.exclude.patterns.len(), 3);
366 assert!(config.exclude.patterns.contains(&"test*".to_string()));
367 assert!(config.exclude.patterns.contains(&"lint*".to_string()));
368 assert!(config.exclude.patterns.contains(&"debug*".to_string()));
369 }
370
371 #[test]
372 fn test_scripts_config() {
373 let temp = create_temp_dir();
374
375 let config_content = r#"
376[scripts.descriptions]
377dev = "Start development server"
378build = "Build for production"
379
380[scripts.aliases]
381d = "dev"
382b = "build"
383"#;
384
385 fs::write(temp.path().join(".nrsrc.toml"), config_content).unwrap();
386
387 let config = load_config(None, temp.path()).unwrap();
388
389 assert_eq!(
390 config.scripts.descriptions.get("dev"),
391 Some(&"Start development server".to_string())
392 );
393 assert_eq!(config.scripts.aliases.get("d"), Some(&"dev".to_string()));
394 }
395
396 #[test]
397 fn test_generate_example_config() {
398 let example = generate_example_config();
399
400 assert!(example.contains("[general]"));
402 assert!(example.contains("[filter]"));
403 assert!(example.contains("[history]"));
404 assert!(example.contains("[exclude]"));
405 assert!(example.contains("[appearance]"));
406 assert!(example.contains("[keybindings]"));
407 assert!(example.contains("[scripts]"));
408 assert!(example.contains("[scripts.descriptions]"));
409 assert!(example.contains("[scripts.aliases]"));
410
411 let result: Result<Config, _> = toml::from_str(&example);
413 assert!(result.is_ok(), "Example config should be valid TOML");
414 }
415
416 #[test]
417 fn test_all_config_options_have_defaults() {
418 let config = Config::default();
419
420 assert!(config.general.runner.is_none());
422 assert_eq!(config.general.default_sort, super::super::SortMode::Recent);
423 assert!(config.general.show_command_preview);
424 assert!(config.filter.search_descriptions);
425 assert!(config.history.enabled);
426 assert!(config.exclude.patterns.is_empty());
427 assert!(config.appearance.icons);
428 assert!(config.keybindings.quit.is_empty());
429 assert!(config.scripts.descriptions.is_empty());
430 }
431}