use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
gen_platform::Discriminant,
gen_platform::IsVariant,
)]
#[discriminant(method = "kind", case = "lower")]
#[non_exhaustive]
pub enum FailureKind {
Transient,
Declarative,
}
impl Default for FailureKind {
fn default() -> Self {
Self::Transient
}
}
impl fmt::Display for FailureKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Transient => "Transient",
Self::Declarative => "Declarative",
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Failure {
pub kind: FailureKind,
pub message: String,
pub signature: String,
}
impl Failure {
#[must_use]
pub fn from_raw(raw: &str) -> Self {
let kind = classify(raw);
let message = truncate(raw, 256);
let signature = signature(raw);
Self { kind, message, signature }
}
}
#[must_use]
pub fn classify(raw: &str) -> FailureKind {
const DECLARATIVE_PATTERNS: &[&str] = &[
"does not provide attribute",
"does not exist",
"evaluating the attribute",
"infinite recursion encountered",
"syntax error",
"attribute set is missing the attribute",
"value is null while a set was expected",
"value is a function while a set was expected",
"is not allowed to refer to a store path",
"cannot coerce",
"while evaluating definitions from",
"in the condition of the assert statement",
"assertion failed",
"The option `",
"is missing the attribute `",
"missing secret",
"could not decrypt",
"could not find Cargo.toml",
"schema validation failed",
"unknown attribute",
"field required",
"preflight failed (Declarative)",
"[Declarative]",
"Invalid virtual machine configuration",
"storage device attachment is invalid",
];
if DECLARATIVE_PATTERNS.iter().any(|pat| raw.contains(pat)) {
FailureKind::Declarative
} else {
FailureKind::Transient
}
}
#[must_use]
pub fn signature(raw: &str) -> String {
let trimmed = raw
.strip_prefix("error: ")
.or_else(|| raw.strip_prefix("warning: "))
.or_else(|| raw.strip_prefix("error:"))
.or_else(|| raw.strip_prefix("warning:"))
.unwrap_or(raw);
let core = trimmed
.lines()
.map(str::trim)
.find(|l| !l.is_empty() && *l != "error:" && *l != "warning:")
.unwrap_or("")
.trim();
truncate(core, 80)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect::<String>() + "…"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_missing_flake_attribute_as_declarative() {
let err = "nix build failed: error: flake does not provide attribute 'packages.aarch64-linux.engenho-local-image'";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_missing_source_file_as_declarative() {
let err = "error: path '/nix/store/abc-source/images/cluster-image.nix' does not exist";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_nixos_eval_error_as_declarative() {
let err = "error: The option `blackmatter` does not exist. Definition values: …";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_terraform_schema_mismatch_as_declarative() {
let err = "schema validation failed: unknown attribute 'foo' in resource 'bar'";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_builder_unreachable_as_transient() {
let err = "ssh: connect to host rio port 22: Connection refused";
assert_eq!(classify(err), FailureKind::Transient);
}
#[test]
fn classifies_network_timeout_as_transient() {
let err = "curl: (28) Operation timed out after 30000 milliseconds";
assert_eq!(classify(err), FailureKind::Transient);
}
#[test]
fn classifies_unknown_as_transient_by_default() {
let err = "something went wrong, nobody knows what";
assert_eq!(classify(err), FailureKind::Transient);
}
#[test]
fn classifies_nix_assert_statement_as_declarative() {
let err = "error:\n … in the condition of the assert statement\n at /nix/store/.../lib/customisation.nix:433:9:";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_typed_wrapper_marker_as_declarative() {
let err = "preflight failed (Declarative): nix eval failed";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_vz_invalid_configuration_as_declarative() {
let err = "kasou start failed: VM operation failed: start failed: \
Invalid virtual machine configuration. The storage device \
attachment is invalid.";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_vz_storage_attachment_rejection_as_declarative() {
let err = "kasou: storage device attachment is invalid";
assert_eq!(classify(err), FailureKind::Declarative);
}
#[test]
fn classifies_vz_runtime_stop_as_transient() {
let err = "kasou: Internal Virtualization error. The virtual \
machine stopped unexpectedly.";
assert_eq!(classify(err), FailureKind::Transient);
}
#[test]
fn signature_walks_past_bare_error_prefix() {
let err = "error:\n … in the condition of the assert statement\n at /nix/store/...:433:9:";
let sig = signature(err);
assert!(sig.contains("assert statement"), "got: {sig}");
assert_ne!(sig, "error:");
}
#[test]
fn signature_strips_error_prefix() {
let raw = "error: does not provide attribute 'packages.aarch64-linux.engenho-local-image'";
let sig = signature(raw);
assert!(!sig.starts_with("error:"));
assert!(sig.contains("does not provide"));
}
#[test]
fn signature_is_stable_across_runs() {
let raw = "error: flake does not provide attribute 'x'";
assert_eq!(signature(raw), signature(raw));
}
#[test]
fn signature_truncates_long_messages() {
let raw = "error: ".to_string() + &"a".repeat(500);
let sig = signature(&raw);
assert!(sig.chars().count() <= 81); }
#[test]
fn failure_from_raw_classifies_and_summarizes() {
let f = Failure::from_raw("error: does not provide attribute 'foo'");
assert_eq!(f.kind, FailureKind::Declarative);
assert!(f.signature.contains("does not provide"));
}
#[test]
fn failure_serializes_via_serde() {
let f = Failure::from_raw("error: connection refused");
let json = serde_json::to_string(&f).expect("serialize");
let back: Failure = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, f);
}
#[test]
fn failure_truncates_long_message() {
let f = Failure::from_raw(&"x".repeat(1000));
assert!(f.message.chars().count() <= 257);
}
#[test]
fn classify_is_deterministic() {
for input in [
"does not provide attribute",
"Connection refused",
"anything else",
"evaluating the attribute",
"schema validation failed",
] {
assert_eq!(classify(input), classify(input));
}
}
#[test]
fn failure_kind_displays() {
assert_eq!(FailureKind::Transient.to_string(), "Transient");
assert_eq!(FailureKind::Declarative.to_string(), "Declarative");
}
}