1use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum NodeStatus {
21 Completed,
23 Cached,
25 Failed,
27 Running,
29}
30
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct NodeOverlay {
34 #[serde(default)]
36 pub status: Option<NodeStatus>,
37 #[serde(default)]
39 pub duration_ms: Option<u64>,
40 #[serde(default)]
42 pub cache_tier: Option<String>,
43 #[serde(default)]
45 pub flags: Vec<String>,
46 #[serde(default)]
48 pub sublabel: Option<String>,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct GraphOverlay {
54 #[serde(default)]
56 pub nodes: BTreeMap<String, NodeOverlay>,
57}
58
59impl GraphOverlay {
60 pub fn is_empty(&self) -> bool {
63 self.nodes.is_empty()
64 }
65}
66
67impl NodeOverlay {
68 pub fn sublabel_text(&self) -> Option<String> {
72 let mut parts: Vec<String> = Vec::new();
73 if let Some(ms) = self.duration_ms {
74 parts.push(format_duration_ms(ms));
75 }
76 if let Some(tier) = &self.cache_tier {
77 let short = match tier.as_str() {
78 "memory" => "mem",
79 other => other,
80 };
81 parts.push(format!("{short} hit"));
82 } else if self.status == Some(NodeStatus::Failed) {
83 parts.push("failed".into());
84 }
85 for flag in &self.flags {
86 parts.push(format!("⚠ {flag}"));
87 }
88 if let Some(extra) = &self.sublabel {
89 parts.push(extra.clone());
90 }
91 if parts.is_empty() {
92 None
93 } else {
94 Some(parts.join(" · "))
95 }
96 }
97
98 pub fn style_class(&self) -> Option<&'static str> {
102 if !self.flags.is_empty() {
103 return Some("soma_flagged");
104 }
105 match self.status? {
106 NodeStatus::Completed => Some("soma_completed"),
107 NodeStatus::Cached => Some("soma_cached"),
108 NodeStatus::Failed => Some("soma_failed"),
109 NodeStatus::Running => Some("soma_running"),
110 }
111 }
112}
113
114pub(crate) fn mermaid_class_style(class: &str) -> &'static str {
116 match class {
117 "soma_completed" => "fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;",
118 "soma_cached" => "fill:#e3f2fd,stroke:#1565c0,color:#0d47a1;",
119 "soma_failed" => "fill:#ffebee,stroke:#c62828,color:#b71c1c;",
120 "soma_running" => "fill:#fff8e1,stroke:#f9a825,color:#f57f17;",
121 _ => "fill:#fff3e0,stroke:#ef6c00,stroke-width:3px,color:#e65100;",
122 }
123}
124
125pub(crate) fn dot_class_style(class: &str) -> String {
127 let (fill, border, extra) = match class {
128 "soma_completed" => ("#e8f5e9", "#2e7d32", ""),
129 "soma_cached" => ("#e3f2fd", "#1565c0", ""),
130 "soma_failed" => ("#ffebee", "#c62828", ""),
131 "soma_running" => ("#fff8e1", "#f9a825", ""),
132 _ => ("#fff3e0", "#ef6c00", " penwidth=3"),
133 };
134 format!(" style=filled fillcolor=\"{fill}\" color=\"{border}\"{extra}")
135}
136
137pub fn format_duration_ms(ms: u64) -> String {
139 if ms < 1_000 {
140 format!("{ms}ms")
141 } else if ms < 120_000 {
142 format!("{:.1}s", ms as f64 / 1_000.0)
143 } else if ms < 7_200_000 {
144 format!("{:.1}m", ms as f64 / 60_000.0)
145 } else {
146 format!("{:.1}h", ms as f64 / 3_600_000.0)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn sublabel_composes_parts_in_order() {
156 let ov = NodeOverlay {
157 status: Some(NodeStatus::Cached),
158 duration_ms: Some(1_200),
159 cache_tier: Some("memory".into()),
160 flags: vec!["LEAKAGE".into()],
161 sublabel: Some("×3".into()),
162 };
163 assert_eq!(
164 ov.sublabel_text().unwrap(),
165 "1.2s · mem hit · ⚠ LEAKAGE · ×3"
166 );
167 }
168
169 #[test]
170 fn empty_overlay_has_no_sublabel_or_class() {
171 let ov = NodeOverlay::default();
172 assert!(ov.sublabel_text().is_none());
173 assert!(ov.style_class().is_none());
174 }
175
176 #[test]
177 fn failed_status_shows_in_sublabel_and_class() {
178 let ov = NodeOverlay {
179 status: Some(NodeStatus::Failed),
180 ..Default::default()
181 };
182 assert_eq!(ov.sublabel_text().unwrap(), "failed");
183 assert_eq!(ov.style_class(), Some("soma_failed"));
184 }
185
186 #[test]
187 fn flags_take_style_precedence_over_status() {
188 let ov = NodeOverlay {
189 status: Some(NodeStatus::Completed),
190 flags: vec!["DEAD_CHANNELS".into()],
191 ..Default::default()
192 };
193 assert_eq!(ov.style_class(), Some("soma_flagged"));
194 }
195
196 #[test]
197 fn duration_formatting_ranges() {
198 assert_eq!(format_duration_ms(340), "340ms");
199 assert_eq!(format_duration_ms(1_234), "1.2s");
200 assert_eq!(format_duration_ms(150_000), "2.5m");
201 assert_eq!(format_duration_ms(9_000_000), "2.5h");
202 }
203
204 #[test]
205 fn overlay_deserializes_from_partial_json() {
206 let ov: GraphOverlay =
209 serde_json::from_str(r#"{"nodes": {"a": {"status": "completed", "duration_ms": 42}}}"#)
210 .unwrap();
211 assert_eq!(ov.nodes["a"].status, Some(NodeStatus::Completed));
212 assert_eq!(ov.nodes["a"].duration_ms, Some(42));
213 assert!(ov.nodes["a"].flags.is_empty());
214 }
215}