use std::path::PathBuf;
use thiserror::Error;
pub mod exit_code {
pub const SUCCESS: i32 = 0;
pub const GENERAL_ERROR: i32 = 1;
pub const NO_PACKAGE_JSON: i32 = 2;
pub const NO_SCRIPTS: i32 = 3;
pub const SCRIPT_FAILED: i32 = 4;
pub const INVALID_CONFIG: i32 = 5;
pub const INTERRUPTED: i32 = 130;
}
#[derive(Error, Debug)]
pub enum NrsError {
#[error(
"No package.json found in {path} or any parent directory (searched up to {depth} levels)"
)]
NoPackageJson { path: PathBuf, depth: usize },
#[error("Failed to parse package.json at {path}:\n {message}")]
ParseErrorWithContext {
path: PathBuf,
message: String,
line: Option<usize>,
column: Option<usize>,
},
#[error("Failed to parse package.json: {0}")]
ParseError(#[from] serde_json::Error),
#[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 }}")]
NoScriptsAt { path: PathBuf },
#[error("No scripts defined in package.json")]
NoScripts,
#[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 }}")]
EmptyScripts { path: PathBuf },
#[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\" }}")]
InvalidScriptsType { path: PathBuf, actual_type: String },
#[error("Script '{name}' not found in package.json")]
ScriptNotFound { name: String },
#[error("Script '{name}' not found\n\nDid you mean: {suggestions}?\n\nRun 'nrs --list' to see all available scripts.")]
ScriptNotFoundWithSuggestions { name: String, suggestions: String },
#[error("Script '{name}' failed with exit code {code}")]
ScriptFailed { name: String, code: i32 },
#[error("Configuration error: {message}")]
ConfigError { message: String },
#[error("Invalid config at {path}:\n {message}\n\nTip: Check the config file syntax and ensure all values are valid.")]
InvalidConfig { path: PathBuf, message: String },
#[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.")]
TerminalTooSmall {
width: u16,
height: u16,
min_width: u16,
min_height: u16,
},
#[error("No previous script found for this project\n\nTip: Run 'nrs' first to execute a script, then use 'nrs --last' to rerun it.")]
NoHistory,
#[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.")]
AllScriptsExcluded { total: usize, patterns: String },
#[error("No scripts match the filter '{filter}'\n\nTip: Press Escape to clear the filter, or try a different search term.")]
NoFilterMatch { filter: String },
#[error("Failed to {operation} '{path}': {source}")]
IoWithContext {
operation: String,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl NrsError {
pub fn exit_code(&self) -> i32 {
match self {
NrsError::NoPackageJson { .. } => exit_code::NO_PACKAGE_JSON,
NrsError::ParseError(_) => exit_code::NO_PACKAGE_JSON,
NrsError::ParseErrorWithContext { .. } => exit_code::NO_PACKAGE_JSON,
NrsError::NoScripts => exit_code::NO_SCRIPTS,
NrsError::NoScriptsAt { .. } => exit_code::NO_SCRIPTS,
NrsError::EmptyScripts { .. } => exit_code::NO_SCRIPTS,
NrsError::InvalidScriptsType { .. } => exit_code::NO_PACKAGE_JSON,
NrsError::ScriptNotFound { .. } => exit_code::GENERAL_ERROR,
NrsError::ScriptNotFoundWithSuggestions { .. } => exit_code::GENERAL_ERROR,
NrsError::ScriptFailed { .. } => exit_code::SCRIPT_FAILED,
NrsError::ConfigError { .. } => exit_code::INVALID_CONFIG,
NrsError::InvalidConfig { .. } => exit_code::INVALID_CONFIG,
NrsError::TerminalTooSmall { .. } => exit_code::GENERAL_ERROR,
NrsError::NoHistory => exit_code::GENERAL_ERROR,
NrsError::AllScriptsExcluded { .. } => exit_code::NO_SCRIPTS,
NrsError::NoFilterMatch { .. } => exit_code::GENERAL_ERROR,
NrsError::IoWithContext { .. } => exit_code::GENERAL_ERROR,
NrsError::Io(_) => exit_code::GENERAL_ERROR,
}
}
pub fn script_not_found_with_suggestions(name: &str, scripts: &[&str]) -> Self {
let suggestions = find_similar_scripts(name, scripts);
if suggestions.is_empty() {
NrsError::ScriptNotFound {
name: name.to_string(),
}
} else {
NrsError::ScriptNotFoundWithSuggestions {
name: name.to_string(),
suggestions: suggestions.join(", "),
}
}
}
}
fn find_similar_scripts(name: &str, scripts: &[&str]) -> Vec<String> {
let name_lower = name.to_lowercase();
let mut matches: Vec<(String, usize)> = scripts
.iter()
.filter_map(|&s| {
let s_lower = s.to_lowercase();
let dist = simple_distance(&name_lower, &s_lower);
if dist <= 3 || s_lower.contains(&name_lower) || name_lower.contains(&s_lower) {
Some((s.to_string(), dist))
} else {
None
}
})
.collect();
matches.sort_by_key(|(_, d)| *d);
matches
.into_iter()
.take(3)
.map(|(s, _)| format!("'{}'", s))
.collect()
}
fn simple_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let len_a = a_chars.len();
let len_b = b_chars.len();
if len_a == 0 {
return len_b;
}
if len_b == 0 {
return len_a;
}
if len_a > 20 || len_b > 20 {
let common_prefix = a_chars
.iter()
.zip(b_chars.iter())
.take_while(|(a, b)| a == b)
.count();
return len_a.abs_diff(len_b) + (len_a.min(len_b) - common_prefix);
}
let mut matrix = vec![vec![0; len_b + 1]; len_a + 1];
for (i, row) in matrix.iter_mut().enumerate().take(len_a + 1) {
row[0] = i;
}
for (j, cell) in matrix[0].iter_mut().enumerate().take(len_b + 1) {
*cell = j;
}
for i in 1..=len_a {
for j in 1..=len_b {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
matrix[i][j] = (matrix[i - 1][j] + 1)
.min(matrix[i][j - 1] + 1)
.min(matrix[i - 1][j - 1] + cost);
}
}
matrix[len_a][len_b]
}
pub type Result<T> = std::result::Result<T, NrsError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_exit_codes() {
let err = NrsError::NoPackageJson {
path: PathBuf::from("."),
depth: 10,
};
assert_eq!(err.exit_code(), exit_code::NO_PACKAGE_JSON);
let err = NrsError::NoScripts;
assert_eq!(err.exit_code(), exit_code::NO_SCRIPTS);
let err = NrsError::ScriptFailed {
name: "test".to_string(),
code: 1,
};
assert_eq!(err.exit_code(), exit_code::SCRIPT_FAILED);
let err = NrsError::NoScriptsAt {
path: PathBuf::from("/test"),
};
assert_eq!(err.exit_code(), exit_code::NO_SCRIPTS);
let err = NrsError::AllScriptsExcluded {
total: 5,
patterns: "pre*, post*".to_string(),
};
assert_eq!(err.exit_code(), exit_code::NO_SCRIPTS);
}
#[test]
fn test_error_messages() {
let err = NrsError::ScriptNotFound {
name: "dev".to_string(),
};
assert!(err.to_string().contains("Script 'dev' not found"));
let err = NrsError::NoScripts;
assert_eq!(err.to_string(), "No scripts defined in package.json");
let err = NrsError::NoHistory;
assert!(err.to_string().contains("No previous script found"));
assert!(err.to_string().contains("Tip:")); }
#[test]
fn test_script_not_found_with_suggestions() {
let scripts = vec!["dev", "build", "test", "lint", "format"];
let err = NrsError::script_not_found_with_suggestions("devv", &scripts);
let msg = err.to_string();
assert!(msg.contains("'dev'"), "Should suggest 'dev' for 'devv'");
let err = NrsError::script_not_found_with_suggestions("xyz123", &scripts);
let msg = err.to_string();
assert!(msg.contains("xyz123"));
}
#[test]
fn test_simple_distance() {
assert_eq!(simple_distance("", ""), 0);
assert_eq!(simple_distance("abc", "abc"), 0);
assert_eq!(simple_distance("abc", ""), 3);
assert_eq!(simple_distance("", "abc"), 3);
assert_eq!(simple_distance("abc", "abd"), 1);
assert_eq!(simple_distance("dev", "devv"), 1);
assert_eq!(simple_distance("build", "biuld"), 2);
}
#[test]
fn test_find_similar_scripts() {
let scripts = vec!["dev", "build", "test", "lint", "format"];
let similar = find_similar_scripts("dev", &scripts);
assert!(similar.iter().any(|s| s.contains("dev")));
let similar = find_similar_scripts("buid", &scripts);
assert!(similar.iter().any(|s| s.contains("build")));
let similar = find_similar_scripts("tes", &scripts);
assert!(similar.iter().any(|s| s.contains("test")));
}
#[test]
fn test_error_with_path_context() {
let err = NrsError::NoPackageJson {
path: PathBuf::from("/home/user/project"),
depth: 10,
};
let msg = err.to_string();
assert!(msg.contains("/home/user/project"));
assert!(msg.contains("10"));
}
}