use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AllocationState {
#[default]
Live,
Freed {
at_ns: u64,
},
Unknown {
reason: String,
},
}
impl AllocationState {
#[must_use]
pub fn live() -> Self {
Self::Live
}
#[must_use]
pub fn freed(at_ns: u64) -> Self {
Self::Freed { at_ns }
}
#[must_use]
pub fn unknown(reason: impl Into<String>) -> Self {
Self::Unknown {
reason: reason.into(),
}
}
#[must_use]
pub fn is_live(&self) -> bool {
matches!(self, Self::Live)
}
#[must_use]
pub fn is_freed(&self) -> bool {
matches!(self, Self::Freed { .. })
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Live => "live",
Self::Freed { .. } => "freed",
Self::Unknown { .. } => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LeakStatus {
#[default]
NotAssessed,
NoEvidence,
Suspected,
Confirmed,
}
impl LeakStatus {
#[must_use]
pub fn is_reported_leak(self) -> bool {
matches!(self, Self::Suspected | Self::Confirmed)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NotAssessed => "not_assessed",
Self::NoEvidence => "no_evidence",
Self::Suspected => "suspected",
Self::Confirmed => "confirmed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct LeakAssessment {
pub status: LeakStatus,
pub rule_id: Option<String>,
pub reason: Option<String>,
}
impl LeakAssessment {
#[must_use]
pub fn not_assessed() -> Self {
Self {
status: LeakStatus::NotAssessed,
rule_id: None,
reason: None,
}
}
#[must_use]
pub fn no_evidence() -> Self {
Self {
status: LeakStatus::NoEvidence,
rule_id: None,
reason: None,
}
}
#[must_use]
pub fn suspected(rule_id: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
status: LeakStatus::Suspected,
rule_id: Some(rule_id.into()),
reason: Some(reason.into()),
}
}
#[must_use]
pub fn confirmed(rule_id: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
status: LeakStatus::Confirmed,
rule_id: Some(rule_id.into()),
reason: Some(reason.into()),
}
}
#[must_use]
pub fn is_reported_leak(&self) -> bool {
self.status.is_reported_leak()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_state_is_not_a_leak() {
let state = AllocationState::live();
assert!(state.is_live(), "fresh observation must be Live");
assert!(!state.is_freed(), "Live must not claim a release");
assert_eq!(state.as_str(), "live");
let assessment = LeakAssessment::not_assessed();
assert!(
!assessment.is_reported_leak(),
"an unassessed allocation must never be reported as leaked"
);
assert_eq!(assessment.status.as_str(), "not_assessed");
}
#[test]
fn freed_state_preserves_release_timestamp() {
let state = AllocationState::freed(4_242);
assert!(!state.is_live(), "a released allocation must not be Live");
match state {
AllocationState::Freed { at_ns } => {
assert_eq!(at_ns, 4_242, "release timestamp must be preserved");
}
other => panic!("expected Freed state, got {other:?}"),
}
assert_eq!(state.as_str(), "freed");
}
#[test]
fn unknown_state_keeps_reason() {
let state = AllocationState::unknown("capture stopped before release");
assert!(!state.is_live(), "unknown observations are not live");
assert!(!state.is_freed(), "unknown observations are not freed");
match &state {
AllocationState::Unknown { reason } => assert_eq!(
reason, "capture stopped before release",
"unknown state must keep the diagnostic reason"
),
other => panic!("expected Unknown state, got {other:?}"),
}
assert_eq!(state.as_str(), "unknown");
}
#[test]
fn only_suspected_and_confirmed_are_reported() {
assert!(!LeakStatus::NotAssessed.is_reported_leak());
assert!(!LeakStatus::NoEvidence.is_reported_leak());
assert!(LeakStatus::Suspected.is_reported_leak());
assert!(LeakStatus::Confirmed.is_reported_leak());
}
#[test]
fn assessments_keep_rule_provenance() {
let suspected = LeakAssessment::suspected("leak.unreleased_at_cut", "still live at cut");
assert!(suspected.is_reported_leak());
assert_eq!(suspected.rule_id.as_deref(), Some("leak.unreleased_at_cut"));
assert_eq!(suspected.reason.as_deref(), Some("still live at cut"));
let confirmed = LeakAssessment::confirmed("leak.proof", "independent evidence");
assert!(confirmed.is_reported_leak());
assert_eq!(confirmed.status, LeakStatus::Confirmed);
let no_evidence = LeakAssessment::no_evidence();
assert!(!no_evidence.is_reported_leak());
assert!(
no_evidence.rule_id.is_none(),
"no-evidence assessments carry no rule id"
);
}
#[test]
fn states_round_trip_through_json() {
let state = AllocationState::freed(7);
let json = serde_json::to_string(&state).expect("state must serialize");
assert!(
json.contains("freed"),
"serialized state must use snake_case label, got {json}"
);
let back: AllocationState = serde_json::from_str(&json).expect("state must deserialize");
assert_eq!(back, state, "round-trip must preserve the state");
let status = LeakStatus::NoEvidence;
let json = serde_json::to_string(&status).expect("status must serialize");
assert_eq!(json, "\"no_evidence\"", "status label must be stable");
let default_state = AllocationState::default();
assert!(
default_state.is_live(),
"default allocation state must be Live, not freed"
);
assert_eq!(
LeakStatus::default(),
LeakStatus::NotAssessed,
"default leak status must be NotAssessed"
);
}
}