1use serde::{Deserialize, Serialize};
16use utoipa::ToSchema;
17
18use crate::posture::Posture;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum CheckStatus {
24 Ok,
26 Warn,
28 Red,
30 NotApplicable,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum DiagnosisStatus {
38 Healthy,
40 Degraded,
42 Red,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
50pub struct DiagnosisCheck {
51 pub name: String,
53 pub status: CheckStatus,
55 pub detail: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub remedy: Option<String>,
61}
62
63impl DiagnosisCheck {
64 fn new(name: &str, status: CheckStatus, detail: impl Into<String>) -> Self {
65 Self {
66 name: name.to_string(),
67 status,
68 detail: detail.into(),
69 remedy: None,
70 }
71 }
72
73 pub fn ok(name: &str, detail: impl Into<String>) -> Self {
75 Self::new(name, CheckStatus::Ok, detail)
76 }
77
78 pub fn not_applicable(name: &str, detail: impl Into<String>) -> Self {
80 Self::new(name, CheckStatus::NotApplicable, detail)
81 }
82
83 pub fn warn(name: &str, detail: impl Into<String>) -> Self {
85 Self::new(name, CheckStatus::Warn, detail)
86 }
87
88 pub fn red(name: &str, detail: impl Into<String>) -> Self {
90 Self::new(name, CheckStatus::Red, detail)
91 }
92
93 pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
95 self.remedy = Some(remedy.into());
96 self
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
102pub struct TrustDiagnosis {
103 pub posture: Posture,
105 pub overall: DiagnosisStatus,
107 pub checks: Vec<DiagnosisCheck>,
109}
110
111impl TrustDiagnosis {
112 pub fn from_checks(posture: Posture, checks: Vec<DiagnosisCheck>) -> Self {
115 let overall = if checks.iter().any(|c| c.status == CheckStatus::Red) {
116 DiagnosisStatus::Red
117 } else if checks.iter().any(|c| c.status == CheckStatus::Warn) {
118 DiagnosisStatus::Degraded
119 } else {
120 DiagnosisStatus::Healthy
121 };
122 Self {
123 posture,
124 overall,
125 checks,
126 }
127 }
128
129 pub fn is_red(&self) -> bool {
131 self.overall == DiagnosisStatus::Red
132 }
133
134 pub fn exit_code(&self) -> i32 {
137 if self.is_red() {
138 1
139 } else {
140 0
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn rollup_is_worst_check_wins() {
151 let healthy = TrustDiagnosis::from_checks(
152 Posture::OPEN,
153 vec![
154 DiagnosisCheck::ok("a", "fine"),
155 DiagnosisCheck::not_applicable("b", "n/a"),
156 ],
157 );
158 assert_eq!(healthy.overall, DiagnosisStatus::Healthy);
159 assert_eq!(healthy.exit_code(), 0);
160 assert!(!healthy.is_red());
161
162 let degraded = TrustDiagnosis::from_checks(
163 Posture::OPEN,
164 vec![
165 DiagnosisCheck::ok("a", "fine"),
166 DiagnosisCheck::warn("b", "soon"),
167 ],
168 );
169 assert_eq!(degraded.overall, DiagnosisStatus::Degraded);
170 assert_eq!(
171 degraded.exit_code(),
172 0,
173 "warnings are loud but not a failure"
174 );
175
176 let red = TrustDiagnosis::from_checks(
177 Posture::OPEN,
178 vec![
179 DiagnosisCheck::warn("a", "soon"),
180 DiagnosisCheck::red("b", "broken"),
181 ],
182 );
183 assert_eq!(red.overall, DiagnosisStatus::Red);
184 assert_eq!(red.exit_code(), 1, "RED must fail loud (non-zero)");
185 assert!(red.is_red());
186 }
187
188 #[test]
189 fn check_remedy_is_optional_and_omitted_when_absent() {
190 let c = DiagnosisCheck::ok("posture", "Authenticated");
191 let json = serde_json::to_value(&c).unwrap();
192 assert!(json.get("remedy").is_none(), "no remedy field when None");
193
194 let c =
195 DiagnosisCheck::red("renewal", "expired").with_remedy("koi certmesh join <endpoint>");
196 let json = serde_json::to_value(&c).unwrap();
197 assert_eq!(json["remedy"], "koi certmesh join <endpoint>");
198 assert_eq!(json["status"], "red");
199 }
200
201 #[test]
202 fn status_serializes_snake_case() {
203 assert_eq!(
204 serde_json::to_string(&CheckStatus::NotApplicable).unwrap(),
205 r#""not_applicable""#
206 );
207 assert_eq!(
208 serde_json::to_string(&DiagnosisStatus::Degraded).unwrap(),
209 r#""degraded""#
210 );
211 }
212
213 #[test]
214 fn diagnosis_round_trips() {
215 let d = TrustDiagnosis::from_checks(
216 Posture::new(true, false),
217 vec![DiagnosisCheck::ok("posture", "Authenticated")],
218 );
219 let json = serde_json::to_string(&d).unwrap();
220 let back: TrustDiagnosis = serde_json::from_str(&json).unwrap();
221 assert_eq!(d, back);
222 }
223}