use std::fmt;
use serde::{Deserialize, Serialize};
use crate::freshness::signature_hash;
use crate::integrity_canary::report::{
IntegrityCanaryReport, IntegrityRiskClassification, IntegrityRiskScore,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrendSeverity {
Clean,
Suspected,
Confirmed,
}
impl TrendSeverity {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Clean => "clean",
Self::Suspected => "suspected",
Self::Confirmed => "confirmed",
}
}
#[must_use]
pub const fn from_score(score: &IntegrityRiskScore) -> Self {
match score.classification {
IntegrityRiskClassification::Confirmed => Self::Confirmed,
IntegrityRiskClassification::Suspected => Self::Suspected,
IntegrityRiskClassification::Clean => Self::Clean,
}
}
}
impl fmt::Display for TrendSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CanaryTrendObservation {
pub signature: String,
pub score: f64,
pub severity: TrendSeverity,
pub contributing_findings: usize,
pub skipped_findings: usize,
pub trap_count: usize,
pub confirmed_count: usize,
pub fired_probe_ids: Vec<String>,
pub captured_at_epoch_ms: u64,
}
impl CanaryTrendObservation {
#[must_use]
pub fn from_report(report: &IntegrityCanaryReport) -> Self {
let fired_probe_ids: Vec<String> =
report.trap_findings.iter().map(|f| f.id.clone()).collect();
let mut signature_parts: Vec<String> = Vec::with_capacity(report.findings.len() * 3);
for f in &report.findings {
signature_parts.push(f.id.clone());
signature_parts.push(f.outcome.label().to_string());
signature_parts.push(format!("{:.6}", f.weight));
}
let borrowed: Vec<&str> = signature_parts.iter().map(String::as_str).collect();
let signature = signature_hash(&borrowed);
Self {
signature,
score: report.score.value,
severity: TrendSeverity::from_score(&report.score),
contributing_findings: report.score.contributing_findings,
skipped_findings: report.score.skipped_findings,
trap_count: report.trap_count(),
confirmed_count: report.confirmed_count(),
fired_probe_ids,
captured_at_epoch_ms: crate::freshness::unix_epoch_ms(),
}
}
#[must_use]
pub const fn has_trap_signal(&self) -> bool {
!matches!(self.severity, TrendSeverity::Clean)
}
#[must_use]
pub const fn is_confirmed(&self) -> bool {
matches!(self.severity, TrendSeverity::Confirmed)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use crate::integrity_canary::probes::{IntegrityProbe, IntegrityProbeOutcome, ProbeFinding};
use crate::integrity_canary::report::{
IntegrityCanaryPolicy, IntegrityCanaryReport, IntegrityRiskClassification,
};
#[test]
fn observation_signature_is_deterministic_for_same_findings() {
let report = IntegrityCanaryReport::from_findings(vec![
IntegrityProbe::confirmed_finding("a", 0.5, "x"),
IntegrityProbe::confirmed_finding("b", 0.5, "y"),
]);
let obs_a = CanaryTrendObservation::from_report(&report);
let obs_b = CanaryTrendObservation::from_report(&report);
assert_eq!(obs_a.signature, obs_b.signature);
assert!((obs_a.score - obs_b.score).abs() < 1e-9);
}
#[test]
fn observation_signature_changes_with_finding_set() {
let report_a =
IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
"a", 0.5, "x",
)]);
let report_b =
IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
"b", 0.5, "y",
)]);
let obs_a = CanaryTrendObservation::from_report(&report_a);
let obs_b = CanaryTrendObservation::from_report(&report_b);
assert_ne!(obs_a.signature, obs_b.signature);
}
#[test]
fn observation_severity_tracks_classification() {
let report = IntegrityCanaryReport::from_findings(Vec::new());
let obs = CanaryTrendObservation::from_report(&report);
assert_eq!(obs.severity, TrendSeverity::Clean);
assert!(!obs.has_trap_signal());
assert!(!obs.is_confirmed());
let report = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
"a", 1.0, "x",
)]);
let obs = CanaryTrendObservation::from_report(&report);
assert_eq!(obs.severity, TrendSeverity::Confirmed);
assert!(obs.has_trap_signal());
assert!(obs.is_confirmed());
}
#[test]
fn observation_carries_fired_probe_ids_in_evaluation_order() {
let findings = vec![
ProbeFinding {
id: "probe_one".to_string(),
outcome: IntegrityProbeOutcome::TrapConfirmed,
weight: 0.20,
evidence: "x".to_string(),
mitigation_hint: String::new(),
},
ProbeFinding {
id: "probe_two".to_string(),
outcome: IntegrityProbeOutcome::TrapSuspected,
weight: 0.15,
evidence: "y".to_string(),
mitigation_hint: String::new(),
},
ProbeFinding {
id: "probe_three".to_string(),
outcome: IntegrityProbeOutcome::Clean,
weight: 0.10,
evidence: "z".to_string(),
mitigation_hint: String::new(),
},
];
let report = IntegrityCanaryReport::from_findings(findings);
let obs = CanaryTrendObservation::from_report(&report);
assert_eq!(
obs.fired_probe_ids,
vec!["probe_one".to_string(), "probe_two".to_string()]
);
assert_eq!(obs.confirmed_count, 1);
assert_eq!(obs.trap_count, 2);
}
#[test]
fn observation_skipped_count_reflects_findings() {
let findings = vec![
ProbeFinding {
id: "a".to_string(),
outcome: IntegrityProbeOutcome::Skipped,
weight: 0.5,
evidence: "x".to_string(),
mitigation_hint: String::new(),
},
ProbeFinding {
id: "b".to_string(),
outcome: IntegrityProbeOutcome::TrapConfirmed,
weight: 0.5,
evidence: "y".to_string(),
mitigation_hint: String::new(),
},
];
let report = IntegrityCanaryReport::from_findings(findings);
let obs = CanaryTrendObservation::from_report(&report);
assert_eq!(obs.skipped_findings, 1);
assert_eq!(obs.contributing_findings, 1);
assert_eq!(obs.confirmed_count, 1);
}
#[test]
fn observation_handles_strict_thresholds() {
let policy = IntegrityCanaryPolicy::try_with_thresholds(0.10, 0.20).expect("policy");
let findings = vec![ProbeFinding {
id: "a".to_string(),
outcome: IntegrityProbeOutcome::TrapSuspected,
weight: 1.0,
evidence: "x".to_string(),
mitigation_hint: String::new(),
}];
let report = IntegrityCanaryReport::with_policy(findings, policy);
let obs = CanaryTrendObservation::from_report(&report);
assert_eq!(obs.severity, TrendSeverity::Confirmed);
}
#[test]
fn observation_serializes_with_snake_case_keys() {
let report = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
"a", 0.5, "x",
)]);
let obs = CanaryTrendObservation::from_report(&report);
let json = serde_json::to_string(&obs).expect("serialize");
assert!(json.contains("\"signature\""), "got: {json}");
assert!(json.contains("\"score\""), "got: {json}");
assert!(json.contains("\"severity\""), "got: {json}");
assert!(json.contains("\"trap_count\""), "got: {json}");
assert!(json.contains("\"confirmed_count\""), "got: {json}");
assert!(json.contains("\"fired_probe_ids\""), "got: {json}");
assert!(json.contains("\"captured_at_epoch_ms\""), "got: {json}");
}
#[test]
fn trend_severity_labels_are_stable() {
assert_eq!(TrendSeverity::Clean.label(), "clean");
assert_eq!(TrendSeverity::Suspected.label(), "suspected");
assert_eq!(TrendSeverity::Confirmed.label(), "confirmed");
}
#[test]
fn trend_severity_from_score_round_trips_classification() {
let mut score = IntegrityRiskScore::clean();
assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Clean);
score.classification = IntegrityRiskClassification::Suspected;
assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Suspected);
score.classification = IntegrityRiskClassification::Confirmed;
assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Confirmed);
}
}