use std::error::Error;
use std::path::PathBuf;
use std::{fmt, io};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CassError {
InvalidBinary { binary: PathBuf, reason: String },
BinaryNotFound { binary: PathBuf },
FoundButUntrusted { found_at: PathBuf },
Io { message: String },
EmptyStdout,
InvalidStdoutJson { hint: String },
ContractMismatch { required: String, observed: String },
Degraded { kind: String, repair_hint: String },
Runtime { kind: String, message: String },
Unknown { kind: String, message: String },
}
impl CassError {
#[must_use]
pub fn kind_str(&self) -> &str {
match self {
Self::InvalidBinary { .. } => "invalid_binary",
Self::BinaryNotFound { .. } => "binary_not_found",
Self::FoundButUntrusted { .. } => "found_but_untrusted",
Self::Io { .. } => "io",
Self::EmptyStdout => "empty_stdout",
Self::InvalidStdoutJson { .. } => "invalid_stdout_json",
Self::ContractMismatch { .. } => "external_adapter_schema_mismatch",
Self::Degraded { .. } => "degraded",
Self::Runtime { kind, .. } | Self::Unknown { kind, .. } => kind.as_str(),
}
}
#[must_use]
pub const fn is_degraded(&self) -> bool {
matches!(self, Self::Degraded { .. })
}
#[must_use]
pub fn repair_hint(&self) -> Option<&str> {
match self {
Self::InvalidBinary { .. } => Some(
"set EE_CASS_BINARY to an absolute, trusted cass executable (e.g. `EE_CASS_BINARY=$(command -v cass)`) or set [cass.binary] in config; ee only auto-runs a bare `cass` on PATH or a trusted absolute path to a file named `cass`",
),
Self::BinaryNotFound { .. } => Some("install cass or set [cass.binary] in config"),
Self::FoundButUntrusted { .. } => Some(
"cass is already installed but outside ee's trusted allowlist; set EE_CASS_BINARY to its absolute path (or move the cass binary into a system bin such as /usr/local/bin) to let ee use it",
),
Self::ContractMismatch { .. } => Some("upgrade cass to a compatible contract version"),
Self::Degraded { repair_hint, .. } => Some(repair_hint.as_str()),
Self::EmptyStdout
| Self::InvalidStdoutJson { .. }
| Self::Io { .. }
| Self::Runtime { .. }
| Self::Unknown { .. } => None,
}
}
}
impl fmt::Display for CassError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidBinary { binary, reason } => {
write!(
f,
"cass binary '{}' is not allowed: {reason}",
binary.display()
)
}
Self::BinaryNotFound { binary } => {
write!(f, "cass binary not found at '{}'", binary.display())
}
Self::FoundButUntrusted { found_at } => write!(
f,
"cass is installed at '{}' but outside ee's trusted execution allowlist",
found_at.display()
),
Self::Io { message } => write!(f, "cass subprocess io error: {message}"),
Self::EmptyStdout => f.write_str("cass produced no stdout payload"),
Self::InvalidStdoutJson { hint } => write!(f, "cass stdout was not valid JSON: {hint}"),
Self::ContractMismatch { required, observed } => write!(
f,
"cass contract mismatch: required {required}, observed {observed}",
),
Self::Degraded { kind, repair_hint } => {
write!(
f,
"cass reports degraded capability '{kind}': {repair_hint}"
)
}
Self::Runtime { kind, message } => write!(f, "cass runtime error '{kind}': {message}"),
Self::Unknown { kind, message } => {
write!(f, "cass reported unknown error kind '{kind}': {message}")
}
}
}
}
impl Error for CassError {}
impl From<io::Error> for CassError {
fn from(error: io::Error) -> Self {
Self::Io {
message: error.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::CassError;
#[test]
fn kind_strings_are_stable_identifiers() {
let cases = [
(
CassError::InvalidBinary {
binary: PathBuf::from("/tmp/cass"),
reason: "outside allowlist".into(),
},
"invalid_binary",
),
(
CassError::BinaryNotFound {
binary: PathBuf::from("cass"),
},
"binary_not_found",
),
(
CassError::Io {
message: "broken pipe".into(),
},
"io",
),
(CassError::EmptyStdout, "empty_stdout"),
(
CassError::InvalidStdoutJson {
hint: "expected '{'".into(),
},
"invalid_stdout_json",
),
(
CassError::ContractMismatch {
required: "1".into(),
observed: "2".into(),
},
"external_adapter_schema_mismatch",
),
(
CassError::Degraded {
kind: "stale_index".into(),
repair_hint: "cass index --full".into(),
},
"degraded",
),
(
CassError::FoundButUntrusted {
found_at: PathBuf::from("/home/u/.local/bin/cass"),
},
"found_but_untrusted",
),
(
CassError::Runtime {
kind: "session_not_found".into(),
message: "no such id".into(),
},
"session_not_found",
),
(
CassError::Unknown {
kind: "future_kind".into(),
message: "unmapped".into(),
},
"future_kind",
),
];
for (error, expected) in cases {
assert_eq!(error.kind_str(), expected, "kind for {error:?}");
}
}
#[test]
fn degraded_is_the_only_recoverable_variant() {
assert!(
CassError::Degraded {
kind: "stale".into(),
repair_hint: "rebuild".into(),
}
.is_degraded()
);
assert!(!CassError::EmptyStdout.is_degraded());
assert!(
!CassError::Runtime {
kind: "x".into(),
message: "y".into(),
}
.is_degraded()
);
}
#[test]
fn repair_hints_are_present_for_actionable_variants() {
let invalid_binary = CassError::InvalidBinary {
binary: PathBuf::from("/tmp/cass"),
reason: "outside allowlist".into(),
};
assert!(invalid_binary.repair_hint().is_some());
let binary_missing = CassError::BinaryNotFound {
binary: PathBuf::from("cass"),
};
assert!(binary_missing.repair_hint().is_some());
let mismatch = CassError::ContractMismatch {
required: "1".into(),
observed: "2".into(),
};
assert!(mismatch.repair_hint().is_some());
let degraded = CassError::Degraded {
kind: "k".into(),
repair_hint: "fix it".into(),
};
assert_eq!(degraded.repair_hint(), Some("fix it"));
let opaque = CassError::Runtime {
kind: "x".into(),
message: "y".into(),
};
assert_eq!(opaque.repair_hint(), None);
}
#[test]
fn found_but_untrusted_never_claims_cass_is_missing() {
let error = CassError::FoundButUntrusted {
found_at: PathBuf::from("/home/u/.local/bin/cass"),
};
let message = error.to_string();
assert!(
message.contains("/home/u/.local/bin/cass"),
"message must surface the detected path: {message}"
);
assert!(
message.contains("installed"),
"message must acknowledge cass IS installed: {message}"
);
assert!(
!message.contains("not found"),
"message must not claim cass is missing: {message}"
);
let repair = error.repair_hint().expect("untrusted has a repair hint");
assert!(
repair.contains("EE_CASS_BINARY"),
"repair must point at the opt-in env var: {repair}"
);
assert!(
!repair.to_lowercase().contains("install cass"),
"repair must not tell the agent to install already-installed cass: {repair}"
);
assert!(!error.is_degraded());
}
#[test]
fn display_includes_kind_and_context() {
let error = CassError::Degraded {
kind: "stale_lexical".into(),
repair_hint: "ee index rebuild".into(),
};
let rendered = error.to_string();
assert!(rendered.contains("stale_lexical"), "{rendered}");
assert!(rendered.contains("ee index rebuild"), "{rendered}");
}
#[test]
fn io_error_round_trips_through_from() {
let raw = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let cass: CassError = raw.into();
assert_eq!(cass.kind_str(), "io");
assert!(cass.to_string().contains("denied"));
}
}