//! Shared domain types — health snapshots, test runs, and target info. use std::fmt; use serde::{Deserialize, Serialize}; /// Alert category — identifies the type of alert being sent. /// Stored as the `alert_type` column in the `alerts` table. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AlertCategory { Health, Recovery, TlsExpiry, TlsError, TlsRecovery, PeerMissing, PeerRecovery, RouteFailure, RouteRecovery, DnsMismatch, DnsRecovery, WhoisExpiry, WhoisError, LatencyDrift, LatencyRecovery, TestDurationDrift, CorsFailure, CorsRecovery, MonitoringOffline, MonitoringRecovery, } impl fmt::Display for AlertCategory { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Health => write!(f, "health"), Self::Recovery => write!(f, "recovery"), Self::TlsExpiry => write!(f, "tls_expiry"), Self::TlsError => write!(f, "tls_error"), Self::TlsRecovery => write!(f, "tls_recovery"), Self::PeerMissing => write!(f, "peer_missing"), Self::PeerRecovery => write!(f, "peer_recovery"), Self::RouteFailure => write!(f, "route_failure"), Self::RouteRecovery => write!(f, "route_recovery"), Self::DnsMismatch => write!(f, "dns_mismatch"), Self::DnsRecovery => write!(f, "dns_recovery"), Self::WhoisExpiry => write!(f, "whois_expiry"), Self::WhoisError => write!(f, "whois_error"), Self::LatencyDrift => write!(f, "latency_drift"), Self::LatencyRecovery => write!(f, "latency_recovery"), Self::TestDurationDrift => write!(f, "test_duration_drift"), Self::CorsFailure => write!(f, "cors_failure"), Self::CorsRecovery => write!(f, "cors_recovery"), Self::MonitoringOffline => write!(f, "monitoring_offline"), Self::MonitoringRecovery => write!(f, "monitoring_recovery"), } } } impl std::str::FromStr for AlertCategory { type Err = String; fn from_str(s: &str) -> Result { match s { "health" => Ok(Self::Health), "recovery" => Ok(Self::Recovery), "tls_expiry" => Ok(Self::TlsExpiry), "tls_error" => Ok(Self::TlsError), "tls_recovery" => Ok(Self::TlsRecovery), "peer_missing" => Ok(Self::PeerMissing), "peer_recovery" => Ok(Self::PeerRecovery), "route_failure" => Ok(Self::RouteFailure), "route_recovery" => Ok(Self::RouteRecovery), "dns_mismatch" => Ok(Self::DnsMismatch), "dns_recovery" => Ok(Self::DnsRecovery), "whois_expiry" => Ok(Self::WhoisExpiry), "whois_error" => Ok(Self::WhoisError), "latency_drift" => Ok(Self::LatencyDrift), "latency_recovery" => Ok(Self::LatencyRecovery), "test_duration_drift" => Ok(Self::TestDurationDrift), "cors_failure" => Ok(Self::CorsFailure), "cors_recovery" => Ok(Self::CorsRecovery), "monitoring_offline" => Ok(Self::MonitoringOffline), "monitoring_recovery" => Ok(Self::MonitoringRecovery), other => Err(format!("unknown alert category: {other}")), } } } /// DNS record type for configuration and checks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum DnsRecordType { A, #[serde(rename = "AAAA")] Aaaa, #[serde(rename = "CNAME")] Cname, #[serde(rename = "MX")] Mx, #[serde(rename = "TXT")] Txt, } impl fmt::Display for DnsRecordType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::A => write!(f, "A"), Self::Aaaa => write!(f, "AAAA"), Self::Cname => write!(f, "CNAME"), Self::Mx => write!(f, "MX"), Self::Txt => write!(f, "TXT"), } } } impl std::str::FromStr for DnsRecordType { type Err = String; fn from_str(s: &str) -> Result { match s { "A" => Ok(Self::A), "AAAA" => Ok(Self::Aaaa), "CNAME" => Ok(Self::Cname), "MX" => Ok(Self::Mx), "TXT" => Ok(Self::Txt), other => Err(format!("unsupported DNS record type: {other}")), } } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum HealthStatus { /// All checks passed — endpoint responded with expected status and content. Operational, /// Check responded but with an unexpected status code or content mismatch. Degraded, /// Check responded with a server error (5xx). Error, /// Connection failed or timed out — endpoint did not respond at all. Unreachable, } impl HealthStatus { /// Short label for CLI output. pub fn icon(&self) -> &'static str { match self { Self::Operational => "OK", Self::Degraded => "WARN", Self::Error => "ERR", Self::Unreachable => "DOWN", } } } impl std::fmt::Display for HealthStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Operational => write!(f, "operational"), Self::Degraded => write!(f, "degraded"), Self::Error => write!(f, "error"), Self::Unreachable => write!(f, "unreachable"), } } } impl std::str::FromStr for HealthStatus { type Err = String; fn from_str(s: &str) -> Result { match s { "operational" => Ok(Self::Operational), "degraded" => Ok(Self::Degraded), "error" => Ok(Self::Error), "unreachable" => Ok(Self::Unreachable), other => Err(format!("unknown health status: {other}")), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthSnapshot { /// Database row ID. `None` before the snapshot is inserted into SQLite. pub id: Option, /// Config key identifying the monitored target (e.g. "mnw"). pub target: String, /// Derived health status for this check cycle. pub status: HealthStatus, /// Timestamp of the check in RFC 3339 format (UTC). pub checked_at: String, /// Round-trip time for the HTTP health request, in milliseconds. pub response_time_ms: i64, /// Structured data extracted from the health endpoint response body, if any. pub details: Option, /// Human-readable error message when the check failed. pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthDetails { /// Application version string reported by the health endpoint. pub version: Option, /// Human-readable uptime string from the health endpoint (e.g. "3d 12h"). pub uptime: Option, /// Subsystem check results as freeform JSON (e.g. `{"db": "ok", "redis": "ok"}`). pub checks: Option, /// Monitoring metadata as freeform JSON (e.g. `{"connections": 42, "queue_depth": 0}`). pub monitoring: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestRun { /// Database row ID. `None` before the run is inserted into SQLite. pub id: Option, /// Config key identifying the target whose tests were run. pub target: String, /// When the test run began, in RFC 3339 format (UTC). pub started_at: String, /// When the test run completed. `None` if still in progress. pub finished_at: Option, /// Wall-clock duration of the test run. `None` if still in progress. pub duration_secs: Option, /// Process exit code. `None` if the process was killed or timed out. pub exit_code: Option, /// Whether the overall test run passed. pub passed: bool, /// Parsed test step results and pass/fail counts. pub summary: TestSummary, /// Full stdout+stderr captured from the test command. pub raw_output: String, /// Optional test filter expression (e.g. a specific test name or module). pub filter: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestSummary { /// Individual step results parsed from the test command output. pub steps: Vec, /// Total number of passing tests, if parseable from the output. pub total_passed: Option, /// Total number of failing tests, if parseable from the output. pub total_failed: Option, /// Per-test results parsed from individual `test ... ok/FAILED` lines. #[serde(default)] pub details: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestDetail { /// Fully qualified test name (e.g. "workflows::endorsements::toggle"). pub test_name: String, /// Whether this individual test passed. pub passed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StepResult { /// Name or label of the test step (e.g. "check", "test", "clippy"). pub name: String, /// Whether this step passed. pub passed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TlsStatus { /// Config key identifying the monitored target. pub target: String, /// Hostname checked for TLS (e.g. "makenot.work"). pub host: String, /// TCP port used for the TLS connection (typically 443). pub port: u16, /// Whether the certificate chain is valid and trusted. pub valid: bool, /// Days until the leaf certificate expires. Negative if already expired. pub days_remaining: i64, /// Certificate "not before" date in RFC 3339 format. pub not_before: String, /// Certificate "not after" (expiry) date in RFC 3339 format. pub not_after: String, /// Certificate subject common name or distinguished name. pub subject: String, /// Certificate issuer organization or distinguished name. pub issuer: String, /// When this TLS check was performed, in RFC 3339 format (UTC). pub checked_at: String, /// Human-readable error if the TLS handshake or validation failed. pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TargetInfo { /// Config key for this target (e.g. "mnw", "go"). pub name: String, /// Human-readable display label (e.g. "MakeNotWork"). pub label: String, /// Whether a health check URL is configured for this target. pub has_health: bool, /// Whether a test command is configured for this target. pub has_tests: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LatencyStats { /// Minimum observed response time in milliseconds. pub min_ms: i64, /// Maximum observed response time in milliseconds. pub max_ms: i64, /// Arithmetic mean of response times in milliseconds. pub avg_ms: f64, /// 95th percentile response time in milliseconds. pub p95_ms: i64, /// Number of samples used to compute these statistics. pub sample_count: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LatencyBucket { /// Start of this time bucket in RFC 3339 format (UTC). pub period_start: String, /// Minimum response time within this bucket, in milliseconds. pub min_ms: i64, /// Maximum response time within this bucket, in milliseconds. pub max_ms: i64, /// Mean response time within this bucket, in milliseconds. pub avg_ms: f64, /// 95th percentile response time within this bucket, in milliseconds. pub p95_ms: i64, /// Number of health checks that fell within this bucket. pub sample_count: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestStaleness { /// Whether the test results are considered stale. When `true`, `reason` is always `Some`. pub stale: bool, /// Human-readable explanation of why tests are stale (version mismatch, age, etc.). /// Always `Some` when `stale` is `true`, always `None` when `stale` is `false`. pub reason: Option, /// Version string currently reported by the health endpoint. pub current_version: Option, /// Version that was deployed when the last test ran. pub tested_version: Option, /// When the most recent test run started, in RFC 3339 format. pub last_test_at: Option, /// Number of whole days since the last test run. pub days_since_test: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsCheckResult { /// Config key identifying the monitored target. pub target: String, /// Queried hostname (e.g. "makenot.work"). pub name: String, /// DNS record type (A, AAAA, CNAME, MX, TXT). pub record_type: DnsRecordType, /// Expected values from config. pub expected: Vec, /// Actually resolved values. pub actual: Vec, /// Whether all expected values were found in actual (expected ⊆ actual). pub matches: bool, /// When this check was performed, in RFC 3339 format (UTC). pub checked_at: String, /// Error message if resolution failed. pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WhoisResult { /// Config key identifying the monitored target. pub target: String, /// Domain that was queried. pub domain: String, /// Domain registrar name, if parsed. pub registrar: Option, /// Registration expiry date (RFC 3339 or raw date string). pub expiry_date: Option, /// Days until expiry. Negative if already expired. pub days_remaining: Option, /// Nameservers from WHOIS response. pub nameservers: Vec, /// When this check was performed, in RFC 3339 format (UTC). pub checked_at: String, /// Error message if WHOIS query failed. pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CorsCheckResult { /// Config key identifying the monitored target. pub target: String, /// URL that was checked. pub url: String, /// Origin sent in the preflight request. pub origin: String, /// HTTP method sent in `Access-Control-Request-Method`. pub method: String, /// Whether the preflight response allows the origin and method. pub passes: bool, /// When this check was performed, in RFC 3339 format (UTC). pub checked_at: String, /// Error message if the preflight request failed. pub error: Option, } impl LatencyStats { /// Compute latency statistics from a slice of response times. /// Returns `None` if the slice is empty. pub fn from_times(times: &[i64]) -> Option { if times.is_empty() { return None; } let mut sorted = times.to_vec(); sorted.sort_unstable(); let n = sorted.len(); let min_ms = sorted[0]; let max_ms = sorted[n - 1]; let sum: i64 = sorted.iter().sum(); let avg_ms = sum as f64 / n as f64; let p95_idx = ((n as f64 * 0.95).ceil() as usize).saturating_sub(1).min(n - 1); let p95_ms = sorted[p95_idx]; Some(Self { min_ms, max_ms, avg_ms, p95_ms, sample_count: n as i64, }) } /// Group timestamped response times into fixed-width buckets and compute /// per-bucket statistics. pub fn bucket_by_time(data: &[(String, i64)], bucket_minutes: u64) -> Vec { if data.is_empty() || bucket_minutes == 0 { return Vec::new(); } let bucket_secs = bucket_minutes * 60; let mut buckets: Vec<(i64, Vec)> = Vec::new(); for (ts, ms) in data { let epoch = chrono::DateTime::parse_from_rfc3339(ts) .map(|dt| dt.timestamp()) .unwrap_or(0); let bucket_start = epoch - (epoch % bucket_secs as i64); if let Some(last) = buckets.last_mut() && last.0 == bucket_start { last.1.push(*ms); continue; } buckets.push((bucket_start, vec![*ms])); } buckets .into_iter() .filter_map(|(start_epoch, times)| { let stats = Self::from_times(×)?; let period_start = chrono::DateTime::from_timestamp(start_epoch, 0) .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); Some(LatencyBucket { period_start, min_ms: stats.min_ms, max_ms: stats.max_ms, avg_ms: stats.avg_ms, p95_ms: stats.p95_ms, sample_count: stats.sample_count, }) }) .collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn health_status_display_roundtrip() { for status in [ HealthStatus::Operational, HealthStatus::Degraded, HealthStatus::Error, HealthStatus::Unreachable, ] { let s = status.to_string(); let parsed: HealthStatus = s.parse().unwrap(); assert_eq!(parsed, status); } } #[test] fn health_status_icons() { assert_eq!(HealthStatus::Operational.icon(), "OK"); assert_eq!(HealthStatus::Degraded.icon(), "WARN"); assert_eq!(HealthStatus::Error.icon(), "ERR"); assert_eq!(HealthStatus::Unreachable.icon(), "DOWN"); } #[test] fn health_status_from_str_rejects_unknown() { assert!("bogus".parse::().is_err()); } #[test] fn health_status_serde_roundtrip() { let status = HealthStatus::Operational; let json = serde_json::to_string(&status).unwrap(); assert_eq!(json, "\"operational\""); let parsed: HealthStatus = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, status); } // --- LatencyStats --- #[test] fn latency_stats_single_element() { let stats = LatencyStats::from_times(&[100]).unwrap(); assert_eq!(stats.min_ms, 100); assert_eq!(stats.max_ms, 100); assert_eq!(stats.avg_ms, 100.0); assert_eq!(stats.p95_ms, 100); assert_eq!(stats.sample_count, 1); } #[test] fn latency_stats_five_elements() { let stats = LatencyStats::from_times(&[50, 100, 150, 200, 250]).unwrap(); assert_eq!(stats.min_ms, 50); assert_eq!(stats.max_ms, 250); assert_eq!(stats.avg_ms, 150.0); assert_eq!(stats.p95_ms, 250); assert_eq!(stats.sample_count, 5); } #[test] fn latency_stats_hundred_elements() { let times: Vec = (1..=100).collect(); let stats = LatencyStats::from_times(×).unwrap(); assert_eq!(stats.min_ms, 1); assert_eq!(stats.max_ms, 100); assert_eq!(stats.p95_ms, 95); assert_eq!(stats.sample_count, 100); } #[test] fn latency_stats_empty() { assert!(LatencyStats::from_times(&[]).is_none()); } #[test] fn latency_stats_p95_boundary() { // 20 elements: p95 index = ceil(20 * 0.95) - 1 = 18 → value 19 let times: Vec = (1..=20).collect(); let stats = LatencyStats::from_times(×).unwrap(); assert_eq!(stats.p95_ms, 19); } #[test] fn latency_bucket_by_time_even_split() { let data: Vec<(String, i64)> = vec![ ("2026-03-10T00:00:00+00:00".to_string(), 100), ("2026-03-10T00:30:00+00:00".to_string(), 120), ("2026-03-10T01:00:00+00:00".to_string(), 140), ("2026-03-10T01:30:00+00:00".to_string(), 160), ]; let buckets = LatencyStats::bucket_by_time(&data, 60); assert_eq!(buckets.len(), 2); assert_eq!(buckets[0].sample_count, 2); assert_eq!(buckets[1].sample_count, 2); } #[test] fn latency_bucket_by_time_single_bucket() { let data: Vec<(String, i64)> = vec![ ("2026-03-10T00:01:00+00:00".to_string(), 100), ("2026-03-10T00:02:00+00:00".to_string(), 200), ]; let buckets = LatencyStats::bucket_by_time(&data, 60); assert_eq!(buckets.len(), 1); assert_eq!(buckets[0].sample_count, 2); assert_eq!(buckets[0].avg_ms, 150.0); } #[test] fn latency_bucket_by_time_empty() { let buckets = LatencyStats::bucket_by_time(&[], 60); assert!(buckets.is_empty()); } #[test] fn dns_check_result_serde_roundtrip() { let result = DnsCheckResult { target: "mnw".to_string(), name: "makenot.work".to_string(), record_type: DnsRecordType::A, expected: vec!["5.78.144.244".to_string()], actual: vec!["5.78.144.244".to_string()], matches: true, checked_at: "2026-03-15T00:00:00Z".to_string(), error: None, }; let json = serde_json::to_string(&result).unwrap(); let parsed: DnsCheckResult = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.target, "mnw"); assert_eq!(parsed.name, "makenot.work"); assert_eq!(parsed.record_type, DnsRecordType::A); assert!(parsed.matches); assert!(parsed.error.is_none()); } // --- AlertCategory --- #[test] fn alert_category_display_roundtrip() { for category in [ AlertCategory::Health, AlertCategory::Recovery, AlertCategory::TlsExpiry, AlertCategory::TlsError, AlertCategory::TlsRecovery, AlertCategory::PeerMissing, AlertCategory::PeerRecovery, AlertCategory::RouteFailure, AlertCategory::RouteRecovery, AlertCategory::DnsMismatch, AlertCategory::DnsRecovery, AlertCategory::WhoisExpiry, AlertCategory::WhoisError, AlertCategory::LatencyDrift, AlertCategory::LatencyRecovery, AlertCategory::TestDurationDrift, AlertCategory::CorsFailure, AlertCategory::CorsRecovery, AlertCategory::MonitoringOffline, AlertCategory::MonitoringRecovery, ] { let s = category.to_string(); let parsed: AlertCategory = s.parse().unwrap(); assert_eq!(parsed, category); } } #[test] fn alert_category_from_str_rejects_unknown() { assert!("bogus".parse::().is_err()); } // --- DnsRecordType --- #[test] fn dns_record_type_display_roundtrip() { for rt in [ DnsRecordType::A, DnsRecordType::Aaaa, DnsRecordType::Cname, DnsRecordType::Mx, DnsRecordType::Txt, ] { let s = rt.to_string(); let parsed: DnsRecordType = s.parse().unwrap(); assert_eq!(parsed, rt); } } #[test] fn dns_record_type_from_str_rejects_unknown() { assert!("SRV".parse::().is_err()); } #[test] fn whois_result_serde_roundtrip() { let result = WhoisResult { target: "mnw".to_string(), domain: "makenot.work".to_string(), registrar: Some("Namecheap".to_string()), expiry_date: Some("2027-01-15T00:00:00Z".to_string()), days_remaining: Some(306), nameservers: vec!["ns1.example.com".to_string()], checked_at: "2026-03-15T00:00:00Z".to_string(), error: None, }; let json = serde_json::to_string(&result).unwrap(); let parsed: WhoisResult = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.domain, "makenot.work"); assert_eq!(parsed.registrar.as_deref(), Some("Namecheap")); assert_eq!(parsed.days_remaining, Some(306)); } #[test] fn latency_stats_serde_roundtrip() { let stats = LatencyStats::from_times(&[50, 100, 150]).unwrap(); let json = serde_json::to_string(&stats).unwrap(); let parsed: LatencyStats = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.min_ms, stats.min_ms); assert_eq!(parsed.max_ms, stats.max_ms); assert_eq!(parsed.p95_ms, stats.p95_ms); assert_eq!(parsed.sample_count, stats.sample_count); } }