use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
PathNotFound,
InvalidPath,
ScanError,
AnalysisError,
InvalidJson,
UnknownMode,
InvalidSettings,
IoError,
InternalError,
NotImplemented,
GitNotAvailable,
NotGitRepository,
GitOperationFailed,
ConfigNotFound,
ConfigInvalid,
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorCode::PathNotFound => write!(f, "path_not_found"),
ErrorCode::InvalidPath => write!(f, "invalid_path"),
ErrorCode::ScanError => write!(f, "scan_error"),
ErrorCode::AnalysisError => write!(f, "analysis_error"),
ErrorCode::InvalidJson => write!(f, "invalid_json"),
ErrorCode::UnknownMode => write!(f, "unknown_mode"),
ErrorCode::InvalidSettings => write!(f, "invalid_settings"),
ErrorCode::IoError => write!(f, "io_error"),
ErrorCode::InternalError => write!(f, "internal_error"),
ErrorCode::NotImplemented => write!(f, "not_implemented"),
ErrorCode::GitNotAvailable => write!(f, "git_not_available"),
ErrorCode::NotGitRepository => write!(f, "not_git_repository"),
ErrorCode::GitOperationFailed => write!(f, "git_operation_failed"),
ErrorCode::ConfigNotFound => write!(f, "config_not_found"),
ErrorCode::ConfigInvalid => write!(f, "config_invalid"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokmdError {
pub code: ErrorCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestions: Option<Vec<String>>,
}
impl TokmdError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
details: None,
suggestions: None,
}
}
pub fn with_details(
code: ErrorCode,
message: impl Into<String>,
details: impl Into<String>,
) -> Self {
Self {
code,
message: message.into(),
details: Some(details.into()),
suggestions: None,
}
}
pub fn with_suggestions(
code: ErrorCode,
message: impl Into<String>,
suggestions: Vec<String>,
) -> Self {
Self {
code,
message: message.into(),
details: None,
suggestions: Some(suggestions),
}
}
pub fn with_details_and_suggestions(
code: ErrorCode,
message: impl Into<String>,
details: impl Into<String>,
suggestions: Vec<String>,
) -> Self {
Self {
code,
message: message.into(),
details: Some(details.into()),
suggestions: Some(suggestions),
}
}
pub fn git_not_available() -> Self {
Self::with_suggestions(
ErrorCode::GitNotAvailable,
"git is not available on PATH".to_string(),
vec![
"Install git from https://git-scm.com/downloads".to_string(),
"Ensure git is in your system PATH".to_string(),
"Verify installation by running: git --version".to_string(),
],
)
}
pub fn not_git_repository(path: &str) -> Self {
Self::with_details_and_suggestions(
ErrorCode::NotGitRepository,
format!("Not inside a git repository: {}", path),
"The current directory is not a git repository".to_string(),
vec![
"Initialize a git repository: git init".to_string(),
"Navigate to a git repository directory".to_string(),
"Use --no-git flag to disable git features".to_string(),
],
)
}
pub fn git_operation_failed(operation: &str, reason: &str) -> Self {
Self::with_details(
ErrorCode::GitOperationFailed,
format!("Git operation failed: {}", operation),
format!("Reason: {}", reason),
)
}
pub fn config_not_found(path: &str) -> Self {
Self::with_suggestions(
ErrorCode::ConfigNotFound,
format!("Configuration file not found: {}", path),
vec![
"Create a tokmd.toml configuration file".to_string(),
"Run 'tokmd init' to generate a template".to_string(),
"Use default settings by omitting --config flag".to_string(),
],
)
}
pub fn config_invalid(path: &str, reason: &str) -> Self {
Self::with_details_and_suggestions(
ErrorCode::ConfigInvalid,
format!("Invalid configuration file: {}", path),
format!("Reason: {}", reason),
vec![
"Check the configuration file syntax".to_string(),
"Refer to documentation for valid options".to_string(),
"Run 'tokmd init' to generate a valid template".to_string(),
],
)
}
pub fn path_not_found_with_suggestions(path: &str) -> Self {
Self::with_details_and_suggestions(
ErrorCode::PathNotFound,
format!("Path not found: {}", path),
"The specified path does not exist or is not accessible".to_string(),
vec![
"Check the path spelling".to_string(),
"Verify the path exists: ls -la".to_string(),
"Ensure you have read permissions".to_string(),
],
)
}
pub fn path_not_found(path: &str) -> Self {
Self::new(ErrorCode::PathNotFound, format!("Path not found: {}", path))
}
pub fn invalid_path(message: impl Into<String>) -> Self {
Self::with_suggestions(
ErrorCode::InvalidPath,
message.into(),
vec![
"Use paths inside the selected scan root".to_string(),
"Avoid parent traversal (`..`) in root-relative paths".to_string(),
],
)
}
pub fn invalid_json(err: impl fmt::Display) -> Self {
Self::new(ErrorCode::InvalidJson, format!("Invalid JSON: {}", err))
}
pub fn unknown_mode(mode: &str) -> Self {
Self::new(ErrorCode::UnknownMode, format!("Unknown mode: {}", mode))
}
pub fn scan_error(err: impl fmt::Display) -> Self {
Self::new(ErrorCode::ScanError, format!("Scan failed: {}", err))
}
pub fn analysis_error(err: impl fmt::Display) -> Self {
Self::new(
ErrorCode::AnalysisError,
format!("Analysis failed: {}", err),
)
}
pub fn io_error(err: impl fmt::Display) -> Self {
Self::new(ErrorCode::IoError, format!("I/O error: {}", err))
}
pub fn internal(err: impl fmt::Display) -> Self {
Self::new(ErrorCode::InternalError, format!("Internal error: {}", err))
}
pub fn not_implemented(feature: impl Into<String>) -> Self {
Self::new(ErrorCode::NotImplemented, feature)
}
pub fn invalid_field(field: &str, expected: &str) -> Self {
Self::new(
ErrorCode::InvalidSettings,
format!("Invalid value for '{}': expected {}", field, expected),
)
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
format!(r#"{{"code":"{}","message":"{}"}}"#, self.code, self.message)
})
}
fn from_anyhow(err: anyhow::Error) -> Self {
let chain: Vec<String> = err.chain().map(|e| e.to_string()).collect();
let primary = chain.first().cloned().unwrap_or_else(|| err.to_string());
let haystack = chain.join(" | ").to_ascii_lowercase();
if let Some(path) = extract_path_not_found(&chain) {
return Self::path_not_found_with_suggestions(&path);
}
if is_bounded_path_violation(&haystack) {
return Self::invalid_path(primary);
}
Self::internal(primary)
}
}
impl fmt::Display for TokmdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(details) = &self.details {
write!(f, "[{}] {}: {}", self.code, self.message, details)
} else {
write!(f, "[{}] {}", self.code, self.message)
}
}
}
impl std::error::Error for TokmdError {}
impl From<anyhow::Error> for TokmdError {
fn from(err: anyhow::Error) -> Self {
Self::from_anyhow(err)
}
}
fn extract_path_not_found(chain: &[String]) -> Option<String> {
for message in chain {
if let Some((_, path)) = message.split_once("Path not found: ") {
return Some(path.trim().to_string());
}
}
None
}
fn is_bounded_path_violation(haystack: &str) -> bool {
haystack.contains("scan root must not be empty")
|| haystack.contains("bounded path must not be empty")
|| haystack.contains("bounded path must be relative")
|| haystack.contains("bounded path must not contain parent traversal")
|| haystack.contains("bounded path escapes scan root")
}
impl From<serde_json::Error> for TokmdError {
fn from(err: serde_json::Error) -> Self {
Self::invalid_json(err)
}
}
impl From<std::io::Error> for TokmdError {
fn from(err: std::io::Error) -> Self {
Self::io_error(err)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorDetails {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl From<&TokmdError> for ErrorDetails {
fn from(err: &TokmdError) -> Self {
Self {
code: err.code.to_string(),
message: err.message.clone(),
details: err.details.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseEnvelope {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorDetails>,
}
impl ResponseEnvelope {
pub fn success(data: Value) -> Self {
Self {
ok: true,
data: Some(data),
error: None,
}
}
pub fn error(err: &TokmdError) -> Self {
Self {
ok: false,
data: None,
error: Some(ErrorDetails::from(err)),
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
if self.ok {
r#"{"ok":true,"data":null}"#.to_string()
} else {
let (code, message) = self
.error
.as_ref()
.map(|e| (e.code.as_str(), e.message.as_str()))
.unwrap_or(("internal_error", "serialization failed"));
format!(
r#"{{"ok":false,"error":{{"code":"{}","message":"{}"}}}}"#,
code, message
)
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: bool,
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl From<TokmdError> for ErrorResponse {
fn from(err: TokmdError) -> Self {
Self {
error: true,
code: err.code.to_string(),
message: err.message,
details: err.details,
}
}
}
impl ErrorResponse {
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
format!(
r#"{{"error":true,"code":"{}","message":"{}"}}"#,
self.code, self.message
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_codes_serialize_to_snake_case() {
let err = TokmdError::path_not_found("/some/path");
let json = err.to_json();
assert!(json.contains("\"code\":\"path_not_found\""));
}
#[test]
fn error_response_has_error_true() {
let err = TokmdError::unknown_mode("foo");
let resp: ErrorResponse = err.into();
assert!(resp.error);
assert_eq!(resp.code, "unknown_mode");
}
#[test]
fn error_display_includes_code() {
let err = TokmdError::new(ErrorCode::ScanError, "test message");
let display = err.to_string();
assert!(display.contains("[scan_error]"));
assert!(display.contains("test message"));
}
#[test]
fn invalid_field_error() {
let err = TokmdError::invalid_field("children", "'collapse' or 'separate'");
assert_eq!(err.code, ErrorCode::InvalidSettings);
assert!(err.message.contains("children"));
assert!(err.message.contains("'collapse' or 'separate'"));
}
#[test]
fn response_envelope_success() {
let data = serde_json::json!({"rows": []});
let envelope = ResponseEnvelope::success(data.clone());
assert!(envelope.ok);
assert!(envelope.error.is_none());
assert_eq!(envelope.data, Some(data));
}
#[test]
fn error_with_suggestions() {
let err = TokmdError::git_not_available();
assert_eq!(err.code, ErrorCode::GitNotAvailable);
assert!(err.suggestions.is_some());
let suggestions = err.suggestions.expect("should have suggestions");
assert!(!suggestions.is_empty());
}
#[test]
fn error_with_details_and_suggestions() {
let err = TokmdError::not_git_repository("/some/path");
assert_eq!(err.code, ErrorCode::NotGitRepository);
assert!(err.details.is_some());
assert!(err.suggestions.is_some());
}
#[test]
fn anyhow_path_not_found_maps_to_path_not_found() {
let err: TokmdError = anyhow::anyhow!("Path not found: missing-dir").into();
assert_eq!(err.code, ErrorCode::PathNotFound);
assert!(err.message.contains("missing-dir"));
assert!(err.suggestions.is_some());
}
#[test]
fn anyhow_parent_traversal_maps_to_invalid_path() {
let err: TokmdError =
anyhow::anyhow!("Bounded path must not contain parent traversal: ../secret.txt").into();
assert_eq!(err.code, ErrorCode::InvalidPath);
assert!(err.message.contains("parent traversal"));
assert!(err.suggestions.is_some());
}
#[test]
fn anyhow_root_escape_maps_to_invalid_path() {
let err: TokmdError =
anyhow::anyhow!("Bounded path escapes scan root C:/repo: C:/secret.txt").into();
assert_eq!(err.code, ErrorCode::InvalidPath);
assert!(err.message.contains("escapes scan root"));
}
#[test]
fn anyhow_scan_root_resolve_failure_stays_internal() {
let err: TokmdError =
anyhow::anyhow!("Failed to resolve scan root C:/repo: permission denied").into();
assert_eq!(err.code, ErrorCode::InternalError);
assert!(err.message.contains("Failed to resolve scan root"));
assert!(err.suggestions.is_none());
}
#[test]
fn anyhow_bounded_path_resolve_failure_stays_internal() {
let err: TokmdError =
anyhow::anyhow!("Failed to resolve bounded path src/lib.rs: permission denied").into();
assert_eq!(err.code, ErrorCode::InternalError);
assert!(err.message.contains("Failed to resolve bounded path"));
assert!(err.suggestions.is_none());
}
#[test]
fn generic_anyhow_stays_internal() {
let err: TokmdError = anyhow::anyhow!("unexpected failure").into();
assert_eq!(err.code, ErrorCode::InternalError);
}
}