1use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum AllocationState {
34 #[default]
36 Live,
37 Freed {
39 at_ns: u64,
41 },
42 Unknown {
44 reason: String,
46 },
47}
48
49impl AllocationState {
50 #[must_use]
52 pub fn live() -> Self {
53 Self::Live
54 }
55
56 #[must_use]
58 pub fn freed(at_ns: u64) -> Self {
59 Self::Freed { at_ns }
60 }
61
62 #[must_use]
64 pub fn unknown(reason: impl Into<String>) -> Self {
65 Self::Unknown {
66 reason: reason.into(),
67 }
68 }
69
70 #[must_use]
72 pub fn is_live(&self) -> bool {
73 matches!(self, Self::Live)
74 }
75
76 #[must_use]
78 pub fn is_freed(&self) -> bool {
79 matches!(self, Self::Freed { .. })
80 }
81
82 #[must_use]
84 pub fn as_str(&self) -> &'static str {
85 match self {
86 Self::Live => "live",
87 Self::Freed { .. } => "freed",
88 Self::Unknown { .. } => "unknown",
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum LeakStatus {
97 #[default]
99 NotAssessed,
100 NoEvidence,
102 Suspected,
104 Confirmed,
106}
107
108impl LeakStatus {
109 #[must_use]
114 pub fn is_reported_leak(self) -> bool {
115 matches!(self, Self::Suspected | Self::Confirmed)
116 }
117
118 #[must_use]
120 pub fn as_str(self) -> &'static str {
121 match self {
122 Self::NotAssessed => "not_assessed",
123 Self::NoEvidence => "no_evidence",
124 Self::Suspected => "suspected",
125 Self::Confirmed => "confirmed",
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
135pub struct LeakAssessment {
136 pub status: LeakStatus,
138 pub rule_id: Option<String>,
140 pub reason: Option<String>,
142}
143
144impl LeakAssessment {
145 #[must_use]
147 pub fn not_assessed() -> Self {
148 Self {
149 status: LeakStatus::NotAssessed,
150 rule_id: None,
151 reason: None,
152 }
153 }
154
155 #[must_use]
157 pub fn no_evidence() -> Self {
158 Self {
159 status: LeakStatus::NoEvidence,
160 rule_id: None,
161 reason: None,
162 }
163 }
164
165 #[must_use]
167 pub fn suspected(rule_id: impl Into<String>, reason: impl Into<String>) -> Self {
168 Self {
169 status: LeakStatus::Suspected,
170 rule_id: Some(rule_id.into()),
171 reason: Some(reason.into()),
172 }
173 }
174
175 #[must_use]
177 pub fn confirmed(rule_id: impl Into<String>, reason: impl Into<String>) -> Self {
178 Self {
179 status: LeakStatus::Confirmed,
180 rule_id: Some(rule_id.into()),
181 reason: Some(reason.into()),
182 }
183 }
184
185 #[must_use]
187 pub fn is_reported_leak(&self) -> bool {
188 self.status.is_reported_leak()
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
201 fn live_state_is_not_a_leak() {
202 let state = AllocationState::live();
203 assert!(state.is_live(), "fresh observation must be Live");
204 assert!(!state.is_freed(), "Live must not claim a release");
205 assert_eq!(state.as_str(), "live");
206
207 let assessment = LeakAssessment::not_assessed();
208 assert!(
209 !assessment.is_reported_leak(),
210 "an unassessed allocation must never be reported as leaked"
211 );
212 assert_eq!(assessment.status.as_str(), "not_assessed");
213 }
214
215 #[test]
219 fn freed_state_preserves_release_timestamp() {
220 let state = AllocationState::freed(4_242);
221 assert!(!state.is_live(), "a released allocation must not be Live");
222 match state {
223 AllocationState::Freed { at_ns } => {
224 assert_eq!(at_ns, 4_242, "release timestamp must be preserved");
225 }
226 other => panic!("expected Freed state, got {other:?}"),
227 }
228 assert_eq!(state.as_str(), "freed");
229 }
230
231 #[test]
235 fn unknown_state_keeps_reason() {
236 let state = AllocationState::unknown("capture stopped before release");
237 assert!(!state.is_live(), "unknown observations are not live");
238 assert!(!state.is_freed(), "unknown observations are not freed");
239 match &state {
240 AllocationState::Unknown { reason } => assert_eq!(
241 reason, "capture stopped before release",
242 "unknown state must keep the diagnostic reason"
243 ),
244 other => panic!("expected Unknown state, got {other:?}"),
245 }
246 assert_eq!(state.as_str(), "unknown");
247 }
248
249 #[test]
253 fn only_suspected_and_confirmed_are_reported() {
254 assert!(!LeakStatus::NotAssessed.is_reported_leak());
255 assert!(!LeakStatus::NoEvidence.is_reported_leak());
256 assert!(LeakStatus::Suspected.is_reported_leak());
257 assert!(LeakStatus::Confirmed.is_reported_leak());
258 }
259
260 #[test]
264 fn assessments_keep_rule_provenance() {
265 let suspected = LeakAssessment::suspected("leak.unreleased_at_cut", "still live at cut");
266 assert!(suspected.is_reported_leak());
267 assert_eq!(suspected.rule_id.as_deref(), Some("leak.unreleased_at_cut"));
268 assert_eq!(suspected.reason.as_deref(), Some("still live at cut"));
269
270 let confirmed = LeakAssessment::confirmed("leak.proof", "independent evidence");
271 assert!(confirmed.is_reported_leak());
272 assert_eq!(confirmed.status, LeakStatus::Confirmed);
273
274 let no_evidence = LeakAssessment::no_evidence();
275 assert!(!no_evidence.is_reported_leak());
276 assert!(
277 no_evidence.rule_id.is_none(),
278 "no-evidence assessments carry no rule id"
279 );
280 }
281
282 #[test]
286 fn states_round_trip_through_json() {
287 let state = AllocationState::freed(7);
288 let json = serde_json::to_string(&state).expect("state must serialize");
289 assert!(
290 json.contains("freed"),
291 "serialized state must use snake_case label, got {json}"
292 );
293 let back: AllocationState = serde_json::from_str(&json).expect("state must deserialize");
294 assert_eq!(back, state, "round-trip must preserve the state");
295
296 let status = LeakStatus::NoEvidence;
297 let json = serde_json::to_string(&status).expect("status must serialize");
298 assert_eq!(json, "\"no_evidence\"", "status label must be stable");
299
300 let default_state = AllocationState::default();
301 assert!(
302 default_state.is_live(),
303 "default allocation state must be Live, not freed"
304 );
305 assert_eq!(
306 LeakStatus::default(),
307 LeakStatus::NotAssessed,
308 "default leak status must be NotAssessed"
309 );
310 }
311}