use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("validation error: {0}")]
Validation(String),
#[error("graph error: {0}")]
Graph(String),
#[error("config error: {0}")]
Config(String),
}
impl AppError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
}
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateFailureCause {
TemplateNotFound,
TemplateParseFailed,
TemplateVariableMissing,
TemplateFilterUnknown,
TemplateFrontmatterInvalid,
TemplateContextInvalid,
TemplateOutputPathInvalid,
TemplateIncludeNotFound,
TemplateSchemaIncompatible,
TemplateRenderInternal,
}
impl TemplateFailureCause {
#[must_use]
pub fn as_code(self) -> &'static str {
match self {
Self::TemplateNotFound => "TEMPLATE_NOT_FOUND",
Self::TemplateParseFailed => "TEMPLATE_PARSE_FAILED",
Self::TemplateVariableMissing => "TEMPLATE_VARIABLE_MISSING",
Self::TemplateFilterUnknown => "TEMPLATE_FILTER_UNKNOWN",
Self::TemplateFrontmatterInvalid => "TEMPLATE_FRONTMATTER_INVALID",
Self::TemplateContextInvalid => "TEMPLATE_CONTEXT_INVALID",
Self::TemplateOutputPathInvalid => "TEMPLATE_OUTPUT_PATH_INVALID",
Self::TemplateIncludeNotFound => "TEMPLATE_INCLUDE_NOT_FOUND",
Self::TemplateSchemaIncompatible => "TEMPLATE_SCHEMA_INCOMPATIBLE",
Self::TemplateRenderInternal => "TEMPLATE_RENDER_INTERNAL",
}
}
}
pub trait CliValidator: Send + Sync {
fn validate_run_args(&self, parallel: bool, jobs: usize) -> Result<()>;
}
pub struct DefaultCliValidator;
impl Default for DefaultCliValidator {
fn default() -> Self {
Self
}
}
impl CliValidator for DefaultCliValidator {
fn validate_run_args(&self, parallel: bool, jobs: usize) -> Result<()> {
if parallel && jobs == 0 {
return Err(AppError::validation(
"[FM-CLI-001] --parallel requires --jobs > 0. \
Remediation: pass --jobs <N> with a positive integer.",
));
}
Ok(())
}
}
pub static CLI_VALIDATOR: std::sync::LazyLock<DefaultCliValidator> =
std::sync::LazyLock::new(DefaultCliValidator::default);
impl AppError {
pub fn fm_cli(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-CLI-{code:03}] {}", msg.into()))
}
pub fn fm_chain(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-CHAIN-{code:03}] {}", msg.into()))
}
pub fn fm_graph(code: u16, msg: impl Into<String>) -> Self {
Self::Graph(format!("[FM-GRAPH-{code:03}] {}", msg.into()))
}
pub fn fm_tpl(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-TPL-{code:03}] {}", msg.into()))
}
pub fn fm_write(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-WRITE-{code:03}] {}", msg.into()))
}
pub fn fm_pack(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-PACK-{code:03}] {}", msg.into()))
}
pub fn fm_config(code: u16, msg: impl Into<String>) -> Self {
Self::Config(format!("[FM-CONFIG-{code:03}] {}", msg.into()))
}
pub fn fm_watch(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-WATCH-{code:03}] {}", msg.into()))
}
pub fn fm_shell(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-SHELL-{code:03}] {}", msg.into()))
}
pub fn fm_law(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-LAW-{code:03}] {}", msg.into()))
}
pub fn fm_gen(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-GEN-{code:03}] {}", msg.into()))
}
pub fn fm_gen_render_failure(
cause: TemplateFailureCause, example: &str, template: &str, rule: &str,
detail: impl Into<String>, location: Option<&str>,
) -> Self {
let loc = location.map(|l| format!(" (at {l})")).unwrap_or_default();
Self::Validation(format!(
"[FM-GEN-008][{}] example=`{example}` template=`{template}` rule=`{rule}`: {}{loc}",
cause.as_code(),
detail.into(),
))
}
pub fn fm_key(code: u16, msg: impl Into<String>) -> Self {
Self::Validation(format!("[FM-KEY-{code:03}] {}", msg.into()))
}
}
#[must_use]
pub fn extract_fm_code(message: &str) -> Option<&str> {
let start = message.find("[FM-")? + 1;
let end = message[start..].find(']')? + start;
Some(&message[start..end])
}
#[cfg(test)]
mod fm_code_extraction_tests {
use super::extract_fm_code;
#[test]
fn extracts_a_real_bracketed_code() {
assert_eq!(
extract_fm_code("[FM-PACK-013] every Dog must carry an ex:license"),
Some("FM-PACK-013")
);
}
#[test]
fn extracts_the_first_code_when_multiple_are_present() {
assert_eq!(
extract_fm_code("[FM-TPL-002] wrapped: [FM-GRAPH-001] inner cause"),
Some("FM-TPL-002")
);
}
#[test]
fn returns_none_for_text_with_no_bracketed_code() {
assert_eq!(extract_fm_code("plain error, no code here"), None);
}
#[test]
fn returns_none_for_an_unterminated_bracket() {
assert_eq!(
extract_fm_code("[FM-PACK-013 missing closing bracket"),
None
);
}
}
pub struct ValidationChain {
errors: Vec<String>,
}
impl ValidationChain {
#[must_use]
pub fn new() -> Self {
ValidationChain { errors: Vec::new() }
}
pub fn check(&mut self, result: Result<()>) -> &mut Self {
if let Err(e) = result {
self.errors.push(e.to_string());
}
self
}
pub fn require(&mut self, condition: bool, error: &AppError) -> &mut Self {
if !condition {
self.errors.push(error.to_string());
}
self
}
pub fn finish(self) -> Result<()> {
if self.errors.is_empty() {
Ok(())
} else {
Err(AppError::Validation(self.errors.join("; ")))
}
}
#[must_use]
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
#[must_use]
pub fn error_count(&self) -> usize {
self.errors.len()
}
}
impl Default for ValidationChain {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod validation_chain_tests {
use super::*;
#[test]
fn empty_chain_is_ok() {
assert!(ValidationChain::new().finish().is_ok());
}
#[test]
fn single_error_reported() {
let mut c = ValidationChain::new();
c.check(Err(AppError::fm_cli(1, "bad arg")));
let e = c.finish().unwrap_err();
assert!(e.to_string().contains("FM-CLI-001"));
}
#[test]
fn multiple_errors_joined() {
let mut c = ValidationChain::new();
c.check(Err(AppError::fm_chain(1, "empty chain")));
c.check(Ok(()));
c.check(Err(AppError::fm_graph(3, "hash mismatch")));
let e = c.finish().unwrap_err().to_string();
assert!(e.contains("FM-CHAIN-001"), "missing chain error: {e}");
assert!(e.contains("FM-GRAPH-003"), "missing graph error: {e}");
}
#[test]
fn require_records_error_on_false() {
let mut c = ValidationChain::new();
c.require(false, &AppError::fm_cli(99, "must be true"));
assert!(c.has_errors());
assert_eq!(c.error_count(), 1);
}
#[test]
fn require_passes_on_true() {
let mut c = ValidationChain::new();
c.require(true, &AppError::fm_cli(99, "should not appear"));
assert!(!c.has_errors());
}
#[test]
fn fm_code_constructors_embed_code() {
assert!(AppError::fm_cli(42, "msg")
.to_string()
.contains("FM-CLI-042"));
assert!(AppError::fm_chain(7, "msg")
.to_string()
.contains("FM-CHAIN-007"));
assert!(AppError::fm_graph(1, "msg")
.to_string()
.contains("FM-GRAPH-001"));
}
}