use serde::{Deserialize, Serialize};
use crate::detect::DetectionOutput;
use crate::model::{Disposition, IssueType, Level};
use crate::projection::FailureModeView;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalKind {
Notify,
Clarify,
Remediate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
pub kind: SignalKind,
pub code: String,
pub message: String,
pub failure_mode_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_action: Option<String>,
}
pub(crate) fn compute_signals(
failure_modes: &[FailureModeView],
detection: &DetectionOutput,
) -> Vec<Signal> {
let mut signals = Vec::new();
for fmv in failure_modes
.iter()
.filter(|fmv| fmv.disposition != Disposition::Excluded)
{
if matches!(fmv.criticality, Some(Level::High) | Some(Level::Medium)) {
let level = match fmv.criticality {
Some(Level::High) => "High",
_ => "Medium",
};
signals.push(Signal {
kind: SignalKind::Notify,
code: "high_criticality".to_string(),
message: format!(
"Failure mode '{}' is {level} criticality.",
fmv.failure_mode.id
),
failure_mode_id: fmv.failure_mode.id.clone(),
suggested_action: None,
});
}
}
for issue in &detection.issues {
match issue.r#type {
IssueType::UnmitigatedHigh
| IssueType::UnmitigatedMedium
| IssueType::ResidualStillHigh => {
signals.push(Signal {
kind: SignalKind::Remediate,
code: "unmitigated_risk".to_string(),
message: issue.explanation.clone(),
failure_mode_id: issue.failure_mode_id.clone(),
suggested_action: Some(issue.suggested_action.clone()),
});
}
IssueType::WeakMitigationOrder => {
signals.push(Signal {
kind: SignalKind::Remediate,
code: "weak_mitigation_order".to_string(),
message: issue.explanation.clone(),
failure_mode_id: issue.failure_mode_id.clone(),
suggested_action: Some(issue.suggested_action.clone()),
});
}
_ => {}
}
}
for issue in &detection.issues {
match issue.r#type {
IssueType::MissingCause | IssueType::MissingEffect | IssueType::MissingScore => {
signals.push(Signal {
kind: SignalKind::Clarify,
code: "missing_analysis".to_string(),
message: issue.explanation.clone(),
failure_mode_id: issue.failure_mode_id.clone(),
suggested_action: Some(issue.suggested_action.clone()),
});
}
_ => {}
}
}
signals
}
pub fn next_risk(failure_modes: &[FailureModeView]) -> Option<&FailureModeView> {
failure_modes
.iter()
.filter(|fmv| {
fmv.disposition != Disposition::Excluded
&& fmv.disposition != Disposition::Accepted
&& !fmv.is_mitigated()
&& matches!(fmv.criticality, Some(Level::High) | Some(Level::Medium))
})
.max_by_key(|fmv| fmv.criticality.map(|c| c.rank()).unwrap_or(0))
}