npm_run_scripts/config/
file.rs

1//! Configuration file loading and parsing.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7
8use super::types::Config;
9
10/// Load configuration from the specified path.
11///
12/// # Errors
13///
14/// Returns an error if the file cannot be read or parsed.
15fn 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
25/// Load configuration with proper priority and merging.
26///
27/// Searches for config files in order of priority (lowest to highest):
28/// 1. `~/.config/nrs/config.toml` (user-level, lowest priority)
29/// 2. `.nrsrc.toml` in project root (project-level)
30/// 3. CLI argument `--config <path>` (highest priority)
31///
32/// Configs are merged with higher priority configs overriding lower priority ones.
33/// Missing config files are handled gracefully (defaults are used).
34///
35/// # Arguments
36///
37/// * `cli_config_path` - Optional path to config file specified via CLI argument
38/// * `project_dir` - The project directory (where package.json is located)
39///
40/// # Errors
41///
42/// Returns an error if a specified config file (via CLI) cannot be read or parsed.
43/// Missing default config files are not treated as errors.
44pub fn load_config(cli_config_path: Option<&Path>, project_dir: &Path) -> Result<Config> {
45    let mut config = Config::default();
46
47    // Load user-level config (lowest priority)
48    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                    // Log warning but don't fail - use defaults
54                    eprintln!(
55                        "Warning: Failed to load user config at {}: {}",
56                        user_config_path.display(),
57                        e
58                    );
59                }
60            }
61        }
62    }
63
64    // Load project-level config (medium priority)
65    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                // Log warning but don't fail - use what we have so far
71                eprintln!(
72                    "Warning: Failed to load project config at {}: {}",
73                    project_config_path.display(),
74                    e
75                );
76            }
77        }
78    }
79
80    // Load CLI-specified config (highest priority)
81    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
94/// Generate an example configuration file with all options documented.
95pub 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        // Create project config
236        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        // Create CLI config that also specifies appearance to preserve the setting
246        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        // CLI config should override project config for runner
259        assert_eq!(config.general.runner, Some(crate::package::Runner::Pnpm));
260        // CLI config preserves the icons setting
261        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        // Create project config with custom appearance
269        let project_config = r#"
270[appearance]
271icons = false
272compact = true
273"#;
274        fs::write(temp.path().join(".nrsrc.toml"), project_config).unwrap();
275
276        // CLI config doesn't specify appearance, so it will use defaults
277        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        // Note: When CLI config is loaded, sections not specified use defaults.
287        // These defaults then merge over the project config.
288        // This means project-specific settings may be overwritten by CLI defaults.
289        assert_eq!(config.general.runner, Some(crate::package::Runner::Pnpm));
290        // Appearance uses CLI defaults (icons = true) because CLI config didn't specify it
291        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        // Config that only specifies some values
326        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        // Specified value should be set
336        assert_eq!(config.history.max_projects, 50);
337        // Other values should use defaults
338        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        // Create project config with exclude patterns
348        let project_config = r#"
349[exclude]
350patterns = ["test*", "lint*"]
351"#;
352        fs::write(temp.path().join(".nrsrc.toml"), project_config).unwrap();
353
354        // Create CLI config with additional patterns
355        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        // Patterns should be merged, not replaced
365        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        // Verify it contains key sections
401        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        // Verify it's valid TOML (should parse without error)
412        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        // Verify all fields have sensible defaults
421        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}