#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("invalid path '{path}': {reason}")]
InvalidPath {
path: String,
reason: String,
},
#[error("{command} failed: {reason}")]
CommandFailed {
command: String,
reason: String,
},
#[error("TUI error: {0}")]
TuiError(String),
#[error("{message} (path: {path})")]
IoWithPath {
message: String,
path: std::path::PathBuf,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error(
"refusing to auto-scan from a dangerous location: {path}\n\
\n\
This directory is on the per-OS dangerous-cwd denylist (e.g. $HOME, ~/Library, /, \
drive roots) and is not inside a git repository. Auto-scanning here would recursively \
walk a huge tree and consume excessive memory.\n\
\n\
{hint}"
)]
DangerousCwd {
path: std::path::PathBuf,
hint: String,
},
}
impl CliError {
pub fn scan(reason: impl std::fmt::Display) -> Self {
Self::CommandFailed {
command: "scan".to_owned(),
reason: reason.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_argument_display() {
let err = CliError::InvalidArgument("missing path".to_owned());
assert_eq!(err.to_string(), "invalid argument: missing path");
}
#[test]
fn invalid_path_display() {
let err = CliError::InvalidPath {
path: "/tmp/nope".to_owned(),
reason: "not a directory".to_owned(),
};
assert_eq!(err.to_string(), "invalid path '/tmp/nope': not a directory");
}
#[test]
fn command_failed_display() {
let err = CliError::CommandFailed {
command: "scan".to_owned(),
reason: "disk full".to_owned(),
};
assert_eq!(err.to_string(), "scan failed: disk full");
}
#[test]
fn tui_error_display() {
let err = CliError::TuiError("buffer overflow".to_owned());
assert_eq!(err.to_string(), "TUI error: buffer overflow");
}
#[test]
fn io_with_path_display() {
let err = CliError::IoWithPath {
message: "failed to read".to_owned(),
path: std::path::PathBuf::from("/tmp/file.txt"),
};
assert!(err.to_string().contains("failed to read"));
assert!(err.to_string().contains("/tmp/file.txt"));
}
#[test]
fn io_display() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let err = CliError::Io(io_err);
assert!(err.to_string().contains("IO error"));
assert!(err.to_string().contains("not found"));
}
#[test]
fn io_from_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let cli_err: CliError = io_err.into();
assert!(cli_err.to_string().contains("denied"));
}
#[test]
fn scan_constructor() {
let err = CliError::scan("no disk space");
assert_eq!(err.to_string(), "scan failed: no disk space");
}
#[test]
fn scan_constructor_with_number() {
let err = CliError::scan(42);
assert_eq!(err.to_string(), "scan failed: 42");
}
#[test]
fn error_is_std_error() {
fn takes_error(_: &dyn std::error::Error) {}
let err = CliError::InvalidArgument("x".to_owned());
takes_error(&err);
}
#[test]
fn dangerous_cwd_display_includes_path_explanation_and_hint() {
let err = CliError::DangerousCwd {
path: std::path::PathBuf::from("/Users/foo"),
hint: "Suggestions:\n • cd into a real project\n • run `seshat scan <path>`\n \
• pass an explicit `<repo>` path"
.to_owned(),
};
let msg = err.to_string();
assert!(msg.contains("/Users/foo"), "missing offending path: {msg}");
assert!(
msg.contains("dangerous-cwd denylist"),
"missing explanation: {msg}"
);
assert!(msg.contains("git repository"), "missing git context: {msg}");
assert!(
msg.contains("cd into a real project"),
"missing first hint: {msg}"
);
assert!(msg.contains("seshat scan"), "missing scan hint: {msg}");
assert!(msg.contains("repo"), "missing repo hint: {msg}");
assert!(
msg.lines().count() >= 3,
"expected multi-line message: {msg}"
);
}
}