use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("parse error in {path}{}: {message}", line.map_or_else(String::new, |l| format!(" (line {l})")))]
ParseError {
path: PathBuf,
line: Option<usize>,
message: String,
},
#[error("validation failed for {path}")]
ValidationError {
path: String,
errors: Vec<ValidationIssue>,
},
#[error("file not found: {path}")]
MissingFile {
path: PathBuf,
},
#[error("invalid value for '{field}': got '{value}', expected {expected}")]
InvalidValue {
field: String,
value: String,
expected: String,
},
#[error("{count} file(s) failed validation")]
ValidationFailed {
count: usize,
},
}
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub path: String,
pub message: String,
pub severity: Severity,
}
impl std::fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let prefix = match self.severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
write!(f, "{}: {} at {}", prefix, self.message, self.path)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
pub struct ExitCode;
impl ExitCode {
pub const NOT_EXPLOITED: i32 = 0;
pub const SUCCESS: i32 = Self::NOT_EXPLOITED;
pub const EXPLOITED: i32 = 1;
pub const EXPLOITED_LOCAL_ACTION: i32 = 2;
pub const EXPLOITED_BOUNDARY_BREACH: i32 = 3;
pub const PARTIAL: i32 = 4;
pub const ERROR: i32 = 5;
pub const RUNTIME_ERROR: i32 = 10;
pub const USAGE_ERROR: i32 = 64;
pub const INTERRUPTED: i32 = 130;
pub const TERMINATED: i32 = 143;
}
#[derive(Debug, Error)]
pub enum EngineError {
#[error("phase error: {0}")]
Phase(String),
#[error("driver error: {0}")]
Driver(String),
#[error("extractor error: {0}")]
Extractor(String),
#[error("entry action error: {0}")]
EntryAction(String),
#[error("synthesize validation error: {0}")]
SynthesizeValidation(String),
#[error("OATF SDK error: {0}")]
Oatf(String),
}
#[derive(Debug, Error)]
pub enum LoaderError {
#[error("OATF load error: {0}")]
OatfLoad(String),
#[error("preprocess error: {0}")]
Preprocess(String),
#[error("circular await_extractors dependency: {0}")]
CyclicDependency(String),
}
#[derive(Debug, Error)]
pub enum ThoughtJackError {
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Transport(#[from] TransportError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("YAML error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("{0}")]
Usage(String),
#[error(transparent)]
Engine(#[from] EngineError),
#[error(transparent)]
Loader(#[from] LoaderError),
#[error("orchestration error: {0}")]
Orchestration(String),
#[error("{message}")]
Verdict {
message: String,
code: i32,
},
}
impl ThoughtJackError {
#[must_use]
pub const fn exit_code(&self) -> i32 {
match self {
Self::Usage(_) => ExitCode::USAGE_ERROR,
Self::Verdict { code, .. } => *code,
Self::Config(_)
| Self::Transport(_)
| Self::Io(_)
| Self::Json(_)
| Self::Yaml(_)
| Self::Engine(_)
| Self::Loader(_)
| Self::Orchestration(_) => ExitCode::RUNTIME_ERROR,
}
}
}
#[derive(Debug, Error)]
pub enum TransportError {
#[error("transport I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("connection closed: {0}")]
ConnectionClosed(String),
#[error("internal transport error: {0}")]
InternalError(String),
}
pub type Result<T> = std::result::Result<T, ThoughtJackError>;
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn test_exit_codes() {
assert_eq!(ExitCode::NOT_EXPLOITED, 0);
assert_eq!(ExitCode::SUCCESS, 0);
assert_eq!(ExitCode::EXPLOITED, 1);
assert_eq!(ExitCode::EXPLOITED_LOCAL_ACTION, 2);
assert_eq!(ExitCode::EXPLOITED_BOUNDARY_BREACH, 3);
assert_eq!(ExitCode::PARTIAL, 4);
assert_eq!(ExitCode::ERROR, 5);
assert_eq!(ExitCode::RUNTIME_ERROR, 10);
assert_eq!(ExitCode::USAGE_ERROR, 64);
assert_eq!(ExitCode::INTERRUPTED, 130);
assert_eq!(ExitCode::TERMINATED, 143);
}
#[test]
fn test_config_error_exit_code() {
let err: ThoughtJackError = ConfigError::MissingFile {
path: PathBuf::from("/test"),
}
.into();
assert_eq!(err.exit_code(), ExitCode::RUNTIME_ERROR);
}
#[test]
fn test_transport_error_exit_code() {
let err: ThoughtJackError = TransportError::ConnectionFailed("test".to_string()).into();
assert_eq!(err.exit_code(), ExitCode::RUNTIME_ERROR);
}
#[test]
fn test_io_error_exit_code() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let err: ThoughtJackError = io_err.into();
assert_eq!(err.exit_code(), ExitCode::RUNTIME_ERROR);
}
#[test]
fn test_usage_error_exit_code() {
let err = ThoughtJackError::Usage("bad args".to_string());
assert_eq!(err.exit_code(), ExitCode::USAGE_ERROR);
}
#[test]
fn test_validation_issue_display() {
let issue = ValidationIssue {
path: "phases[0].advance".to_string(),
message: "missing trigger".to_string(),
severity: Severity::Error,
};
assert_eq!(
issue.to_string(),
"error: missing trigger at phases[0].advance"
);
}
#[test]
fn test_validation_issue_warning_display() {
let issue = ValidationIssue {
path: "server.name".to_string(),
message: "name is empty".to_string(),
severity: Severity::Warning,
};
assert_eq!(issue.to_string(), "warning: name is empty at server.name");
}
#[test]
fn test_engine_error_exit_code() {
let err: ThoughtJackError = EngineError::Phase("test".to_string()).into();
assert_eq!(err.exit_code(), ExitCode::RUNTIME_ERROR);
}
#[test]
fn test_loader_error_exit_code() {
let err: ThoughtJackError = LoaderError::OatfLoad("test".to_string()).into();
assert_eq!(err.exit_code(), ExitCode::RUNTIME_ERROR);
}
#[test]
fn test_orchestration_error_exit_code() {
let err = ThoughtJackError::Orchestration("actor failed".to_string());
assert_eq!(err.exit_code(), ExitCode::RUNTIME_ERROR);
}
#[test]
fn test_verdict_error_exit_code() {
let err = ThoughtJackError::Verdict {
message: "exploited".to_string(),
code: ExitCode::EXPLOITED,
};
assert_eq!(err.exit_code(), ExitCode::EXPLOITED);
let err = ThoughtJackError::Verdict {
message: "partial".to_string(),
code: ExitCode::PARTIAL,
};
assert_eq!(err.exit_code(), ExitCode::PARTIAL);
}
#[test]
fn test_config_error_display() {
let err = ConfigError::ParseError {
path: PathBuf::from("config.yaml"),
line: Some(42),
message: "unexpected token".to_string(),
};
assert!(err.to_string().contains("config.yaml"));
assert!(err.to_string().contains("unexpected token"));
}
}