1use std::path::PathBuf;
6
7use thiserror::Error;
8
9pub mod exit_code {
11 pub const SUCCESS: i32 = 0;
13 pub const GENERAL_ERROR: i32 = 1;
15 pub const NO_PACKAGE_JSON: i32 = 2;
17 pub const NO_SCRIPTS: i32 = 3;
19 pub const SCRIPT_FAILED: i32 = 4;
21 pub const INVALID_CONFIG: i32 = 5;
23 pub const INTERRUPTED: i32 = 130;
25}
26
27#[derive(Error, Debug)]
29pub enum NrsError {
30 #[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 #[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 #[error("Failed to parse package.json: {0}")]
47 ParseError(#[from] serde_json::Error),
48
49 #[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 #[error("No scripts defined in package.json")]
55 NoScripts,
56
57 #[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 #[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 #[error("Script '{name}' not found in package.json")]
67 ScriptNotFound { name: String },
68
69 #[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 #[error("Script '{name}' failed with exit code {code}")]
75 ScriptFailed { name: String, code: i32 },
76
77 #[error("Configuration error: {message}")]
79 ConfigError { message: String },
80
81 #[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 #[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 #[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 #[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 #[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 #[error("Failed to {operation} '{path}': {source}")]
108 IoWithContext {
109 operation: String,
110 path: PathBuf,
111 #[source]
112 source: std::io::Error,
113 },
114
115 #[error(transparent)]
117 Io(#[from] std::io::Error),
118}
119
120impl NrsError {
121 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 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
161fn 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 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 matches.sort_by_key(|(_, d)| *d);
180
181 matches
183 .into_iter()
184 .take(3)
185 .map(|(s, _)| format!("'{}'", s))
186 .collect()
187}
188
189fn 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 if len_a > 20 || len_b > 20 {
210 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
244pub 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:")); }
294
295 #[test]
296 fn test_script_not_found_with_suggestions() {
297 let scripts = vec!["dev", "build", "test", "lint", "format"];
298
299 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 let err = NrsError::script_not_found_with_suggestions("xyz123", &scripts);
306 let msg = err.to_string();
307 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 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}