use crate::engine::InvalidThreshold;
use crate::output::LintResult;
use marque_scheme::{CategoryId, RewriteId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineConstructionError {
RewriteCycle {
axis: CategoryId,
members: Box<[RewriteId]>,
},
UnannotatedCustomAxes { rewrite: RewriteId },
UnknownRuleOverride {
key: String,
did_you_mean: Option<String>,
},
ConflictingRuleOverride {
rule_id: String,
keys: Box<[String]>,
severities: Box<[String]>,
},
}
impl EngineConstructionError {
pub fn exit_code(&self) -> i32 {
match self {
Self::RewriteCycle { .. } | Self::UnannotatedCustomAxes { .. } => 69,
Self::UnknownRuleOverride { .. } | Self::ConflictingRuleOverride { .. } => 65,
}
}
}
impl std::fmt::Display for EngineConstructionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RewriteCycle { axis, members } => {
write!(f, "page-rewrite cycle on category {axis:?}: {members:?}")
}
Self::UnannotatedCustomAxes { rewrite } => write!(
f,
"custom page-rewrite {rewrite:?} was declared without explicit reads/writes"
),
Self::UnknownRuleOverride { key, did_you_mean } => {
write!(
f,
"unknown rule {key:?} in [rules] — no registered rule has this ID or name"
)?;
if let Some(hint) = did_you_mean {
write!(f, " (did you mean {hint:?}?)")?;
}
Ok(())
}
Self::ConflictingRuleOverride {
rule_id,
keys,
severities,
} => {
write!(f, "conflicting severity overrides for rule {rule_id}: ")?;
let mut first = true;
for (k, s) in keys.iter().zip(severities.iter()) {
if !first {
write!(f, ", ")?;
}
write!(f, "{k:?} = {s:?}")?;
first = false;
}
write!(
f,
" — specify only one form (either the rule ID or the rule name), not both with different severities"
)
}
}
}
}
impl std::error::Error for EngineConstructionError {}
#[non_exhaustive]
#[derive(Debug)]
pub enum EngineError {
DeadlineExceeded { partial_lint: LintResult },
InvalidThreshold(InvalidThreshold),
}
impl std::fmt::Display for EngineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DeadlineExceeded { partial_lint } => write!(
f,
"engine deadline exceeded after processing {}/{} candidates",
partial_lint.candidates_processed, partial_lint.candidates_total
),
Self::InvalidThreshold(it) => it.fmt(f),
}
}
}
impl std::error::Error for EngineError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::DeadlineExceeded { .. } => None,
Self::InvalidThreshold(it) => Some(it),
}
}
}
impl From<InvalidThreshold> for EngineError {
fn from(value: InvalidThreshold) -> Self {
Self::InvalidThreshold(value)
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use marque_scheme::CategoryId;
#[test]
fn unannotated_custom_axes_exit_code_is_unavailable() {
let err = EngineConstructionError::UnannotatedCustomAxes { rewrite: "bad" };
assert_eq!(
err.exit_code(),
69,
"scheme defects (not user-config) → EX_UNAVAILABLE"
);
}
#[test]
fn rewrite_cycle_display_names_axis_and_members() {
let err = EngineConstructionError::RewriteCycle {
axis: CategoryId(0),
members: Box::new(["alpha", "beta"]),
};
let msg = err.to_string();
assert!(msg.contains("page-rewrite cycle"), "got: {msg}");
assert!(msg.contains("alpha"), "got: {msg}");
assert!(msg.contains("beta"), "got: {msg}");
}
#[test]
fn unannotated_custom_axes_display_names_rewrite() {
let err = EngineConstructionError::UnannotatedCustomAxes {
rewrite: "noforn-clears-rel-to",
};
let msg = err.to_string();
assert!(msg.contains("noforn-clears-rel-to"), "got: {msg}");
assert!(msg.contains("explicit reads/writes"), "got: {msg}");
}
#[test]
fn unknown_rule_override_display_with_suggestion() {
let err = EngineConstructionError::UnknownRuleOverride {
key: "E00l".into(),
did_you_mean: Some("E001".into()),
};
let msg = err.to_string();
assert!(msg.contains("E00l"), "got: {msg}");
assert!(msg.contains("E001"), "suggestion missing: {msg}");
assert!(msg.contains("did you mean"), "got: {msg}");
}
#[test]
fn unknown_rule_override_display_without_suggestion_omits_did_you_mean() {
let err = EngineConstructionError::UnknownRuleOverride {
key: "totally-unknown".into(),
did_you_mean: None,
};
let msg = err.to_string();
assert!(msg.contains("totally-unknown"), "got: {msg}");
assert!(
!msg.contains("did you mean"),
"no suggestion → no hint phrase: {msg}"
);
}
#[test]
fn conflicting_rule_override_display_lists_all_keys_and_severities() {
let err = EngineConstructionError::ConflictingRuleOverride {
rule_id: "E001".into(),
keys: Box::new(["E001".into(), "portion-mark-in-banner".into()]),
severities: Box::new(["warn".into(), "error".into()]),
};
let msg = err.to_string();
assert!(msg.contains("E001"), "got: {msg}");
assert!(msg.contains("portion-mark-in-banner"), "got: {msg}");
assert!(msg.contains("warn"), "got: {msg}");
assert!(msg.contains("error"), "got: {msg}");
}
#[test]
fn engine_construction_error_has_no_source() {
let err = EngineConstructionError::UnannotatedCustomAxes { rewrite: "bad" };
let as_error: &dyn std::error::Error = &err;
assert!(as_error.source().is_none());
}
fn lint_result_with_counts(processed: usize, total: usize) -> LintResult {
LintResult {
diagnostics: Vec::new(),
truncated: true,
candidates_processed: processed,
candidates_total: total,
..Default::default()
}
}
#[test]
fn deadline_exceeded_display_carries_processed_over_total() {
let err = EngineError::DeadlineExceeded {
partial_lint: lint_result_with_counts(7, 42),
};
let msg = err.to_string();
assert!(msg.contains("deadline exceeded"), "got: {msg}");
assert!(msg.contains("7/42"), "counts must appear as N/M: got {msg}");
}
#[test]
fn deadline_exceeded_with_zero_counts_renders_zero_over_zero() {
let err = EngineError::DeadlineExceeded {
partial_lint: lint_result_with_counts(0, 0),
};
let msg = err.to_string();
assert!(msg.contains("0/0"), "got: {msg}");
}
#[test]
fn invalid_threshold_display_delegates_to_inner() {
let inner = InvalidThreshold(1.5);
let wrapped = EngineError::InvalidThreshold(InvalidThreshold(1.5));
assert_eq!(inner.to_string(), wrapped.to_string());
}
#[test]
fn invalid_threshold_display_renders_nan() {
let err = EngineError::InvalidThreshold(InvalidThreshold(f32::NAN));
let msg = err.to_string();
assert!(msg.contains("NaN"), "got: {msg}");
}
#[test]
fn deadline_exceeded_source_is_none() {
let err = EngineError::DeadlineExceeded {
partial_lint: lint_result_with_counts(0, 0),
};
let as_error: &dyn std::error::Error = &err;
assert!(as_error.source().is_none());
}
#[test]
fn invalid_threshold_source_chains_to_inner() {
let err = EngineError::InvalidThreshold(InvalidThreshold(2.0));
let as_error: &dyn std::error::Error = &err;
let source = as_error.source().expect("InvalidThreshold has a source");
assert_eq!(source.to_string(), InvalidThreshold(2.0).to_string());
}
#[test]
fn from_invalid_threshold_constructs_invalid_threshold_variant() {
let it = InvalidThreshold(-0.5);
let err: EngineError = it.into();
match err {
EngineError::InvalidThreshold(inner) => {
assert!(inner.0 == -0.5 || inner.0.is_nan());
}
other => panic!("expected InvalidThreshold variant, got {other:?}"),
}
}
}