npm_run_scripts/
error.rs

1//! Custom error types for nrs.
2//!
3//! Uses thiserror for ergonomic error definitions.
4
5use std::path::PathBuf;
6
7use thiserror::Error;
8
9/// Exit codes for nrs.
10pub mod exit_code {
11    /// Success.
12    pub const SUCCESS: i32 = 0;
13    /// General error.
14    pub const GENERAL_ERROR: i32 = 1;
15    /// No package.json found.
16    pub const NO_PACKAGE_JSON: i32 = 2;
17    /// No scripts defined.
18    pub const NO_SCRIPTS: i32 = 3;
19    /// Script execution failed.
20    pub const SCRIPT_FAILED: i32 = 4;
21    /// Invalid configuration.
22    pub const INVALID_CONFIG: i32 = 5;
23    /// Interrupted (Ctrl+C).
24    pub const INTERRUPTED: i32 = 130;
25}
26
27/// Main error type for nrs.
28#[derive(Error, Debug)]
29pub enum NrsError {
30    /// No package.json found.
31    #[error(
32        "No package.json found in {path} or any parent directory (searched up to {depth} levels)"
33    )]
34    NoPackageJson { path: PathBuf, depth: usize },
35
36    /// Failed to parse package.json with location details.
37    #[error("Failed to parse package.json at {path}:\n  {message}")]
38    ParseErrorWithContext {
39        path: PathBuf,
40        message: String,
41        line: Option<usize>,
42        column: Option<usize>,
43    },
44
45    /// Failed to parse package.json (legacy, kept for From impl).
46    #[error("Failed to parse package.json: {0}")]
47    ParseError(#[from] serde_json::Error),
48
49    /// No scripts defined in package.json.
50    #[error("No scripts defined in package.json at {path}\n\nTip: Add scripts to your package.json:\n  {{\n    \"scripts\": {{\n      \"dev\": \"your-command\",\n      \"build\": \"your-build-command\"\n    }}\n  }}")]
51    NoScriptsAt { path: PathBuf },
52
53    /// No scripts defined in package.json (legacy).
54    #[error("No scripts defined in package.json")]
55    NoScripts,
56
57    /// Empty scripts object.
58    #[error("The scripts object in {path} is empty\n\nTip: Add scripts to your package.json:\n  {{\n    \"scripts\": {{\n      \"dev\": \"your-command\"\n    }}\n  }}")]
59    EmptyScripts { path: PathBuf },
60
61    /// Scripts field is not an object.
62    #[error("Invalid scripts field in {path}: expected an object, got {actual_type}\n\nTip: The scripts field must be an object:\n  \"scripts\": {{ \"name\": \"command\" }}")]
63    InvalidScriptsType { path: PathBuf, actual_type: String },
64
65    /// Script not found.
66    #[error("Script '{name}' not found in package.json")]
67    ScriptNotFound { name: String },
68
69    /// Script not found with suggestions.
70    #[error("Script '{name}' not found\n\nDid you mean: {suggestions}?\n\nRun 'nrs --list' to see all available scripts.")]
71    ScriptNotFoundWithSuggestions { name: String, suggestions: String },
72
73    /// Script execution failed.
74    #[error("Script '{name}' failed with exit code {code}")]
75    ScriptFailed { name: String, code: i32 },
76
77    /// Configuration error.
78    #[error("Configuration error: {message}")]
79    ConfigError { message: String },
80
81    /// Invalid configuration file.
82    #[error("Invalid config at {path}:\n  {message}\n\nTip: Check the config file syntax and ensure all values are valid.")]
83    InvalidConfig { path: PathBuf, message: String },
84
85    /// Terminal too small.
86    #[error("Terminal too small (minimum: {min_width}x{min_height}, current: {width}x{height})\n\nTip: Resize your terminal window or use --list for non-interactive mode.")]
87    TerminalTooSmall {
88        width: u16,
89        height: u16,
90        min_width: u16,
91        min_height: u16,
92    },
93
94    /// No history found for rerun.
95    #[error("No previous script found for this project\n\nTip: Run 'nrs' first to execute a script, then use 'nrs --last' to rerun it.")]
96    NoHistory,
97
98    /// All scripts excluded by patterns.
99    #[error("All {total} scripts are excluded by your exclude patterns\n\nActive exclude patterns: {patterns}\n\nTip: Review your exclude patterns in config or use --exclude flag.")]
100    AllScriptsExcluded { total: usize, patterns: String },
101
102    /// No scripts match filter.
103    #[error("No scripts match the filter '{filter}'\n\nTip: Press Escape to clear the filter, or try a different search term.")]
104    NoFilterMatch { filter: String },
105
106    /// IO error with path context.
107    #[error("Failed to {operation} '{path}': {source}")]
108    IoWithContext {
109        operation: String,
110        path: PathBuf,
111        #[source]
112        source: std::io::Error,
113    },
114
115    /// IO error.
116    #[error(transparent)]
117    Io(#[from] std::io::Error),
118}
119
120impl NrsError {
121    /// Get the exit code for this error.
122    pub fn exit_code(&self) -> i32 {
123        match self {
124            NrsError::NoPackageJson { .. } => exit_code::NO_PACKAGE_JSON,
125            NrsError::ParseError(_) => exit_code::NO_PACKAGE_JSON,
126            NrsError::ParseErrorWithContext { .. } => exit_code::NO_PACKAGE_JSON,
127            NrsError::NoScripts => exit_code::NO_SCRIPTS,
128            NrsError::NoScriptsAt { .. } => exit_code::NO_SCRIPTS,
129            NrsError::EmptyScripts { .. } => exit_code::NO_SCRIPTS,
130            NrsError::InvalidScriptsType { .. } => exit_code::NO_PACKAGE_JSON,
131            NrsError::ScriptNotFound { .. } => exit_code::GENERAL_ERROR,
132            NrsError::ScriptNotFoundWithSuggestions { .. } => exit_code::GENERAL_ERROR,
133            NrsError::ScriptFailed { .. } => exit_code::SCRIPT_FAILED,
134            NrsError::ConfigError { .. } => exit_code::INVALID_CONFIG,
135            NrsError::InvalidConfig { .. } => exit_code::INVALID_CONFIG,
136            NrsError::TerminalTooSmall { .. } => exit_code::GENERAL_ERROR,
137            NrsError::NoHistory => exit_code::GENERAL_ERROR,
138            NrsError::AllScriptsExcluded { .. } => exit_code::NO_SCRIPTS,
139            NrsError::NoFilterMatch { .. } => exit_code::GENERAL_ERROR,
140            NrsError::IoWithContext { .. } => exit_code::GENERAL_ERROR,
141            NrsError::Io(_) => exit_code::GENERAL_ERROR,
142        }
143    }
144
145    /// Create a script not found error with suggestions based on available scripts.
146    pub fn script_not_found_with_suggestions(name: &str, scripts: &[&str]) -> Self {
147        let suggestions = find_similar_scripts(name, scripts);
148        if suggestions.is_empty() {
149            NrsError::ScriptNotFound {
150                name: name.to_string(),
151            }
152        } else {
153            NrsError::ScriptNotFoundWithSuggestions {
154                name: name.to_string(),
155                suggestions: suggestions.join(", "),
156            }
157        }
158    }
159}
160
161/// Find similar script names using simple string distance.
162fn find_similar_scripts(name: &str, scripts: &[&str]) -> Vec<String> {
163    let name_lower = name.to_lowercase();
164    let mut matches: Vec<(String, usize)> = scripts
165        .iter()
166        .filter_map(|&s| {
167            let s_lower = s.to_lowercase();
168            let dist = simple_distance(&name_lower, &s_lower);
169            // Include if distance is small enough or contains the search term
170            if dist <= 3 || s_lower.contains(&name_lower) || name_lower.contains(&s_lower) {
171                Some((s.to_string(), dist))
172            } else {
173                None
174            }
175        })
176        .collect();
177
178    // Sort by distance
179    matches.sort_by_key(|(_, d)| *d);
180
181    // Return top 3 suggestions
182    matches
183        .into_iter()
184        .take(3)
185        .map(|(s, _)| format!("'{}'", s))
186        .collect()
187}
188
189/// Simple Levenshtein-like distance calculation.
190fn simple_distance(a: &str, b: &str) -> usize {
191    if a == b {
192        return 0;
193    }
194
195    let a_chars: Vec<char> = a.chars().collect();
196    let b_chars: Vec<char> = b.chars().collect();
197
198    let len_a = a_chars.len();
199    let len_b = b_chars.len();
200
201    if len_a == 0 {
202        return len_b;
203    }
204    if len_b == 0 {
205        return len_a;
206    }
207
208    // Simple Levenshtein for short strings
209    if len_a > 20 || len_b > 20 {
210        // For long strings, just use length difference + common prefix check
211        let common_prefix = a_chars
212            .iter()
213            .zip(b_chars.iter())
214            .take_while(|(a, b)| a == b)
215            .count();
216        return len_a.abs_diff(len_b) + (len_a.min(len_b) - common_prefix);
217    }
218
219    let mut matrix = vec![vec![0; len_b + 1]; len_a + 1];
220
221    for (i, row) in matrix.iter_mut().enumerate().take(len_a + 1) {
222        row[0] = i;
223    }
224    for (j, cell) in matrix[0].iter_mut().enumerate().take(len_b + 1) {
225        *cell = j;
226    }
227
228    for i in 1..=len_a {
229        for j in 1..=len_b {
230            let cost = if a_chars[i - 1] == b_chars[j - 1] {
231                0
232            } else {
233                1
234            };
235            matrix[i][j] = (matrix[i - 1][j] + 1)
236                .min(matrix[i][j - 1] + 1)
237                .min(matrix[i - 1][j - 1] + cost);
238        }
239    }
240
241    matrix[len_a][len_b]
242}
243
244/// Result type alias for nrs operations.
245pub type Result<T> = std::result::Result<T, NrsError>;
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_error_exit_codes() {
253        let err = NrsError::NoPackageJson {
254            path: PathBuf::from("."),
255            depth: 10,
256        };
257        assert_eq!(err.exit_code(), exit_code::NO_PACKAGE_JSON);
258
259        let err = NrsError::NoScripts;
260        assert_eq!(err.exit_code(), exit_code::NO_SCRIPTS);
261
262        let err = NrsError::ScriptFailed {
263            name: "test".to_string(),
264            code: 1,
265        };
266        assert_eq!(err.exit_code(), exit_code::SCRIPT_FAILED);
267
268        let err = NrsError::NoScriptsAt {
269            path: PathBuf::from("/test"),
270        };
271        assert_eq!(err.exit_code(), exit_code::NO_SCRIPTS);
272
273        let err = NrsError::AllScriptsExcluded {
274            total: 5,
275            patterns: "pre*, post*".to_string(),
276        };
277        assert_eq!(err.exit_code(), exit_code::NO_SCRIPTS);
278    }
279
280    #[test]
281    fn test_error_messages() {
282        let err = NrsError::ScriptNotFound {
283            name: "dev".to_string(),
284        };
285        assert!(err.to_string().contains("Script 'dev' not found"));
286
287        let err = NrsError::NoScripts;
288        assert_eq!(err.to_string(), "No scripts defined in package.json");
289
290        let err = NrsError::NoHistory;
291        assert!(err.to_string().contains("No previous script found"));
292        assert!(err.to_string().contains("Tip:")); // Should have helpful tip
293    }
294
295    #[test]
296    fn test_script_not_found_with_suggestions() {
297        let scripts = vec!["dev", "build", "test", "lint", "format"];
298
299        // Test with close match
300        let err = NrsError::script_not_found_with_suggestions("devv", &scripts);
301        let msg = err.to_string();
302        assert!(msg.contains("'dev'"), "Should suggest 'dev' for 'devv'");
303
304        // Test with no close match
305        let err = NrsError::script_not_found_with_suggestions("xyz123", &scripts);
306        let msg = err.to_string();
307        // Should be simple not found without suggestions
308        assert!(msg.contains("xyz123"));
309    }
310
311    #[test]
312    fn test_simple_distance() {
313        assert_eq!(simple_distance("", ""), 0);
314        assert_eq!(simple_distance("abc", "abc"), 0);
315        assert_eq!(simple_distance("abc", ""), 3);
316        assert_eq!(simple_distance("", "abc"), 3);
317        assert_eq!(simple_distance("abc", "abd"), 1);
318        assert_eq!(simple_distance("dev", "devv"), 1);
319        assert_eq!(simple_distance("build", "biuld"), 2);
320    }
321
322    #[test]
323    fn test_find_similar_scripts() {
324        let scripts = vec!["dev", "build", "test", "lint", "format"];
325
326        let similar = find_similar_scripts("dev", &scripts);
327        assert!(similar.iter().any(|s| s.contains("dev")));
328
329        let similar = find_similar_scripts("buid", &scripts);
330        assert!(similar.iter().any(|s| s.contains("build")));
331
332        // Substring match
333        let similar = find_similar_scripts("tes", &scripts);
334        assert!(similar.iter().any(|s| s.contains("test")));
335    }
336
337    #[test]
338    fn test_error_with_path_context() {
339        let err = NrsError::NoPackageJson {
340            path: PathBuf::from("/home/user/project"),
341            depth: 10,
342        };
343        let msg = err.to_string();
344        assert!(msg.contains("/home/user/project"));
345        assert!(msg.contains("10"));
346    }
347}