Skip to main content

max / makenotwork

39.2 KB · 1110 lines History Blame Raw
1 //! Pure formatting functions for CLI display output.
2 //!
3 //! Each function takes data types and writes formatted output to a `String`,
4 //! keeping display logic separate from async I/O for testability.
5
6 use std::fmt::Write;
7
8 use crate::db::{DnsCheckRow, IncidentRow, PruneResult, RouteCheckRow, TlsCheckRow, WhoisCheckRow};
9 use crate::types::{DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, WhoisResult};
10
11 /// Format a single health snapshot as a human-readable line.
12 pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
13 let mut out = String::new();
14 write!(out, "[{}] {} \u{2014} {}", s.status.icon(), s.target, s.status).unwrap();
15 write!(out, " ({}ms)", s.response_time_ms).unwrap();
16 if let Some(details) = &s.details {
17 if let Some(v) = &details.version {
18 write!(out, " v{v}").unwrap();
19 }
20 if let Some(u) = &details.uptime {
21 write!(out, " up {u}").unwrap();
22 }
23 }
24 writeln!(out).unwrap();
25 if let Some(err) = &s.error {
26 writeln!(out, " {err}").unwrap();
27 }
28 out
29 }
30
31 /// Format a list of health snapshots for CLI display.
32 pub fn format_health_snapshots(snapshots: &[HealthSnapshot]) -> String {
33 let mut out = String::new();
34 for s in snapshots {
35 out.push_str(&format_health_snapshot(s));
36 }
37 out
38 }
39
40 /// Format a test run result for CLI display.
41 pub fn format_test_result(target_name: &str, run: &TestRun) -> String {
42 let mut out = String::new();
43 let result = if run.passed { "PASSED" } else { "FAILED" };
44 writeln!(out, "{target_name}: {result}").unwrap();
45 if let Some(d) = run.duration_secs {
46 writeln!(out, "Duration: {d}s").unwrap();
47 }
48 if let (Some(p), Some(f)) = (run.summary.total_passed, run.summary.total_failed) {
49 writeln!(out, "Tests: {p} passed, {f} failed").unwrap();
50 }
51 for step in &run.summary.steps {
52 let mark = if step.passed { "PASS" } else { "FAIL" };
53 writeln!(out, " {mark} {}", step.name).unwrap();
54 }
55 if !run.passed {
56 writeln!(out, "\nRaw output:\n{}", run.raw_output).unwrap();
57 }
58 out
59 }
60
61 /// Format a single target's status block for CLI display.
62 #[allow(clippy::too_many_arguments)]
63 pub fn format_status_target(
64 name: &str,
65 label: &str,
66 health: Option<&HealthSnapshot>,
67 latency: Option<&LatencyStats>,
68 tls: Option<&TlsCheckRow>,
69 route_checks: Option<&[RouteCheckRow]>,
70 dns_checks: Option<&[DnsCheckRow]>,
71 whois: Option<&WhoisCheckRow>,
72 test: Option<&TestRun>,
73 staleness: Option<&TestStaleness>,
74 incident: Option<&IncidentRow>,
75 ) -> String {
76 let mut out = String::new();
77 writeln!(out, "=== {name} ({label}) ===").unwrap();
78
79 if let Some(h) = health {
80 write!(out, " Health: [{}] {}", h.status.icon(), h.status).unwrap();
81 write!(out, " ({}ms)", h.response_time_ms).unwrap();
82 if let Some(d) = &h.details
83 && let Some(v) = &d.version
84 {
85 write!(out, " v{v}").unwrap();
86 }
87 writeln!(out).unwrap();
88 } else {
89 writeln!(out, " Health: no data").unwrap();
90 }
91
92 if let Some(l) = latency {
93 writeln!(
94 out,
95 " Latency (24h): avg {:.0}ms, p95 {}ms, range {}-{}ms ({} samples)",
96 l.avg_ms, l.p95_ms, l.min_ms, l.max_ms, l.sample_count
97 )
98 .unwrap();
99 }
100
101 if let Some(t) = tls {
102 if let Some(ref err) = t.error {
103 writeln!(out, " TLS: [ERR] {} \u{2014} {err}", t.host).unwrap();
104 } else if t.days_remaining <= 0 {
105 writeln!(out, " TLS: [ERR] {} \u{2014} EXPIRED (expired {})", t.host, t.not_after).unwrap();
106 } else if t.days_remaining <= 14 {
107 writeln!(out, " TLS: [WARN] {} \u{2014} {}d remaining (expires {})", t.host, t.days_remaining, t.not_after).unwrap();
108 } else {
109 writeln!(out, " TLS: [OK] {} \u{2014} {}d remaining (expires {})", t.host, t.days_remaining, t.not_after).unwrap();
110 }
111 }
112
113 if let Some(checks) = route_checks
114 && !checks.is_empty()
115 {
116 let total = checks.len();
117 let ok_count = checks.iter().filter(|c| c.ok).count();
118 if ok_count == total {
119 writeln!(out, " Routes: {ok_count}/{total} OK").unwrap();
120 } else {
121 let failed: Vec<&str> = checks.iter().filter(|c| !c.ok).map(|c| c.path.as_str()).collect();
122 writeln!(out, " Routes: {ok_count}/{total} (FAIL: {})", failed.join(", ")).unwrap();
123 }
124 }
125
126 if let Some(checks) = dns_checks
127 && !checks.is_empty()
128 {
129 let total = checks.len();
130 let ok_count = checks.iter().filter(|c| c.matches).count();
131 if ok_count == total {
132 writeln!(out, " DNS: {ok_count}/{total} match").unwrap();
133 } else {
134 let failed: Vec<String> = checks.iter().filter(|c| !c.matches).map(|c| format!("{} {}", c.name, c.record_type)).collect();
135 writeln!(out, " DNS: {ok_count}/{total} (MISMATCH: {})", failed.join(", ")).unwrap();
136 }
137 }
138
139 if let Some(w) = whois {
140 if let Some(ref err) = w.error {
141 writeln!(out, " WHOIS: [ERR] {} \u{2014} {err}", w.domain).unwrap();
142 } else if let Some(days) = w.days_remaining {
143 if days <= 0 {
144 writeln!(out, " WHOIS: [ERR] {} \u{2014} EXPIRED", w.domain).unwrap();
145 } else if days <= 30 {
146 writeln!(out, " WHOIS: [WARN] {} \u{2014} {}d remaining", w.domain, days).unwrap();
147 } else {
148 writeln!(out, " WHOIS: [OK] {} \u{2014} {}d remaining", w.domain, days).unwrap();
149 }
150 }
151 }
152
153 if let Some(t) = test {
154 let result = if t.passed { "PASSED" } else { "FAILED" };
155 write!(out, " Tests: {result}").unwrap();
156 if let Some(d) = t.duration_secs {
157 write!(out, " ({d}s)").unwrap();
158 }
159 writeln!(out).unwrap();
160 if let (Some(p), Some(f)) = (t.summary.total_passed, t.summary.total_failed) {
161 writeln!(out, " {p} passed, {f} failed").unwrap();
162 }
163 } else {
164 writeln!(out, " Tests: no data").unwrap();
165 }
166
167 if let Some(s) = staleness
168 && s.stale
169 && let Some(reason) = &s.reason
170 {
171 writeln!(out, " Tests: STALE \u{2014} {reason}").unwrap();
172 }
173
174 if let Some(inc) = incident {
175 writeln!(out, " Incident: [ACTIVE] {} since {}", inc.to_status, inc.started_at).unwrap();
176 }
177
178 writeln!(out).unwrap();
179 out
180 }
181
182 /// Format health check history for CLI display.
183 pub fn format_health_history(history: &[HealthSnapshot]) -> String {
184 if history.is_empty() {
185 return "No health check history.\n".to_string();
186 }
187 let mut out = String::new();
188 for h in history {
189 writeln!(
190 out,
191 "[{}] {} \u{2014} {} ({}ms) {}",
192 h.status.icon(),
193 h.target,
194 h.status,
195 h.response_time_ms,
196 h.checked_at
197 )
198 .unwrap();
199 }
200 out
201 }
202
203 /// Format test run history for CLI display.
204 pub fn format_test_history(history: &[TestRun]) -> String {
205 if history.is_empty() {
206 return "No test run history.\n".to_string();
207 }
208 let mut out = String::new();
209 for r in history {
210 let result = if r.passed { "PASS" } else { "FAIL" };
211 write!(out, "[{result}] {}", r.target).unwrap();
212 if let Some(d) = r.duration_secs {
213 write!(out, " ({d}s)").unwrap();
214 }
215 write!(out, " {}", r.started_at).unwrap();
216 if let (Some(p), Some(f)) = (r.summary.total_passed, r.summary.total_failed) {
217 write!(out, " \u{2014} {p} passed, {f} failed").unwrap();
218 }
219 writeln!(out).unwrap();
220 }
221 out
222 }
223
224 /// Format regression warnings for CLI display.
225 pub fn format_regressions(regressions: &[String]) -> String {
226 let mut out = String::new();
227 writeln!(out, "\nREGRESSIONS (passed last run, failed now):").unwrap();
228 for name in regressions {
229 writeln!(out, " {name}").unwrap();
230 }
231 out
232 }
233
234 /// Format test duration trend for CLI display.
235 pub fn format_test_duration_trend(durations: &[(String, i64)], drift: Option<&str>) -> String {
236 let mut out = String::new();
237 if !durations.is_empty() {
238 write!(out, " Duration trend (last {}): ", durations.len()).unwrap();
239 let strs: Vec<String> = durations.iter().map(|(_, d)| format!("{d}s")).collect();
240 writeln!(out, "{}", strs.join(", ")).unwrap();
241 }
242 if let Some(msg) = drift {
243 writeln!(out, " DRIFT: {msg}").unwrap();
244 }
245 out
246 }
247
248 /// Format DNS check results and WHOIS results for CLI display.
249 pub fn format_dns_results(dns_results: &[DnsCheckResult], whois_results: &[WhoisResult]) -> String {
250 let mut out = String::new();
251
252 if !dns_results.is_empty() {
253 writeln!(out, "DNS Records:").unwrap();
254 for r in dns_results {
255 if let Some(ref err) = r.error {
256 writeln!(out, " [ERR] {} {} \u{2014} {err}", r.name, r.record_type).unwrap();
257 } else if r.matches {
258 writeln!(out, " [OK] {} {} \u{2014} {:?}", r.name, r.record_type, r.actual).unwrap();
259 } else {
260 writeln!(out, " [FAIL] {} {} \u{2014} expected {:?}, got {:?}", r.name, r.record_type, r.expected, r.actual).unwrap();
261 }
262 }
263 }
264
265 if !whois_results.is_empty() {
266 if !dns_results.is_empty() {
267 writeln!(out).unwrap();
268 }
269 writeln!(out, "WHOIS:").unwrap();
270 for w in whois_results {
271 if let Some(ref err) = w.error {
272 writeln!(out, " [ERR] {} \u{2014} {err}", w.domain).unwrap();
273 } else {
274 let days_str = w.days_remaining
275 .map(|d| format!("{d}d remaining"))
276 .unwrap_or_else(|| "expiry unknown".to_string());
277 let registrar_str = w.registrar.as_deref().unwrap_or("unknown registrar");
278 writeln!(out, " [OK] {} \u{2014} {days_str} ({registrar_str})", w.domain).unwrap();
279 }
280 }
281 }
282
283 out
284 }
285
286 /// Format prune results for CLI display.
287 pub fn format_prune(result: &PruneResult, days: i64) -> String {
288 format!(
289 "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks older than {} days.\n",
290 result.health, result.tests, result.test_details, result.heartbeats, result.alerts, result.tls,
291 result.incidents, result.routes, result.dns, result.whois, result.backups, days
292 )
293 }
294
295 /// Format mesh data (from JSON) for human-readable CLI display.
296 pub fn format_mesh(data: &serde_json::Value) -> String {
297 let Some(instances) = data.get("instances").and_then(|v| v.as_object()) else {
298 return "No mesh data available.\n".to_string();
299 };
300
301 let mut out = String::new();
302 for (name, instance_data) in instances {
303 let instance = instance_data.get("instance");
304 let id = instance
305 .and_then(|i| i.get("id"))
306 .and_then(|v| v.as_str())
307 .unwrap_or("?");
308 let version = instance
309 .and_then(|i| i.get("version"))
310 .and_then(|v| v.as_str())
311 .unwrap_or("?");
312
313 writeln!(out, "=== {name} ===").unwrap();
314 writeln!(out, " ID: {id}").unwrap();
315 writeln!(out, " Version: {version}").unwrap();
316
317 // Targets
318 if let Some(targets) = instance_data.get("targets").and_then(|v| v.as_object()) {
319 for (target_name, target_data) in targets {
320 let status = target_data
321 .get("status")
322 .and_then(|v| v.as_str())
323 .unwrap_or("?");
324 let ms = target_data
325 .get("response_time_ms")
326 .and_then(|v| v.as_i64());
327 let ms_str = ms.map(|m| format!(" ({m}ms)")).unwrap_or_default();
328 writeln!(out, " Target {target_name}: {status}{ms_str}").unwrap();
329 }
330 }
331
332 // Peers
333 if let Some(peers) = instance_data.get("peers").and_then(|v| v.as_object()) {
334 for (peer_name, peer_data) in peers {
335 let status = peer_data
336 .get("status")
337 .and_then(|v| v.as_str())
338 .unwrap_or("?");
339 let latency = peer_data
340 .get("latency_ms")
341 .and_then(|v| v.as_u64())
342 .map(|ms| format!(" ({ms}ms)"))
343 .unwrap_or_default();
344 writeln!(out, " Peer {peer_name}: {status}{latency}").unwrap();
345 }
346 }
347
348 // Error fallback
349 if let Some(err) = instance_data.get("error").and_then(|v| v.as_str()) {
350 writeln!(out, " ({err})").unwrap();
351 }
352
353 writeln!(out).unwrap();
354 }
355 out
356 }
357
358 #[cfg(test)]
359 mod tests {
360 use super::*;
361 use crate::types::*;
362
363 // --- format_health_snapshot ---
364
365 #[test]
366 fn health_snapshot_operational_with_details() {
367 let s = HealthSnapshot {
368 id: None,
369 target: "mnw".to_string(),
370 status: HealthStatus::Operational,
371 checked_at: "2026-03-10T00:00:00Z".to_string(),
372 response_time_ms: 95,
373 details: Some(HealthDetails {
374 version: Some("1.2.0".to_string()),
375 uptime: Some("5d 3h".to_string()),
376 checks: None,
377 monitoring: None,
378 }),
379 error: None,
380 };
381 let out = format_health_snapshot(&s);
382 assert!(out.contains("[OK]"));
383 assert!(out.contains("mnw"));
384 assert!(out.contains("operational"));
385 assert!(out.contains("(95ms)"));
386 assert!(out.contains("v1.2.0"));
387 assert!(out.contains("up 5d 3h"));
388 }
389
390 #[test]
391 fn health_snapshot_unreachable_with_error() {
392 let s = HealthSnapshot {
393 id: None,
394 target: "api".to_string(),
395 status: HealthStatus::Unreachable,
396 checked_at: "2026-03-10T00:00:00Z".to_string(),
397 response_time_ms: 0,
398 details: None,
399 error: Some("connection refused".to_string()),
400 };
401 let out = format_health_snapshot(&s);
402 assert!(out.contains("[DOWN]"));
403 assert!(out.contains("unreachable"));
404 assert!(out.contains("connection refused"));
405 }
406
407 #[test]
408 fn health_snapshot_degraded_no_details() {
409 let s = HealthSnapshot {
410 id: None,
411 target: "svc".to_string(),
412 status: HealthStatus::Degraded,
413 checked_at: "2026-03-10T00:00:00Z".to_string(),
414 response_time_ms: 2500,
415 details: None,
416 error: None,
417 };
418 let out = format_health_snapshot(&s);
419 assert!(out.contains("[WARN]"));
420 assert!(out.contains("degraded"));
421 assert!(out.contains("(2500ms)"));
422 assert!(!out.contains("up "));
423 assert!(!out.contains(" v"));
424 }
425
426 #[test]
427 fn health_snapshot_error_status() {
428 let s = HealthSnapshot {
429 id: None,
430 target: "db".to_string(),
431 status: HealthStatus::Error,
432 checked_at: "2026-03-10T00:00:00Z".to_string(),
433 response_time_ms: 500,
434 details: None,
435 error: Some("500 internal server error".to_string()),
436 };
437 let out = format_health_snapshot(&s);
438 assert!(out.contains("[ERR]"));
439 assert!(out.contains("error"));
440 assert!(out.contains("500 internal server error"));
441 }
442
443 #[test]
444 fn health_snapshots_multiple() {
445 let snapshots = vec![
446 HealthSnapshot {
447 id: None,
448 target: "a".to_string(),
449 status: HealthStatus::Operational,
450 checked_at: "2026-03-10T00:00:00Z".to_string(),
451 response_time_ms: 50,
452 details: None,
453 error: None,
454 },
455 HealthSnapshot {
456 id: None,
457 target: "b".to_string(),
458 status: HealthStatus::Degraded,
459 checked_at: "2026-03-10T00:00:00Z".to_string(),
460 response_time_ms: 3000,
461 details: None,
462 error: None,
463 },
464 ];
465 let out = format_health_snapshots(&snapshots);
466 assert!(out.contains("[OK]"));
467 assert!(out.contains("[WARN]"));
468 assert!(out.contains("a"));
469 assert!(out.contains("b"));
470 }
471
472 // --- format_test_result ---
473
474 #[test]
475 fn test_result_passed() {
476 let run = TestRun {
477 id: None,
478 target: "mnw".to_string(),
479 started_at: "2026-03-10T00:00:00Z".to_string(),
480 finished_at: Some("2026-03-10T00:02:00Z".to_string()),
481 duration_secs: Some(120),
482 exit_code: Some(0),
483 passed: true,
484 summary: TestSummary {
485 steps: vec![
486 StepResult { name: "cargo check".to_string(), passed: true },
487 StepResult { name: "cargo test".to_string(), passed: true },
488 ],
489 total_passed: Some(759),
490 total_failed: Some(0),
491 details: vec![],
492 },
493 raw_output: String::new(),
494 filter: None,
495 };
496 let out = format_test_result("mnw", &run);
497 assert!(out.contains("mnw: PASSED"));
498 assert!(out.contains("Duration: 120s"));
499 assert!(out.contains("Tests: 759 passed, 0 failed"));
500 assert!(out.contains("PASS cargo check"));
501 assert!(out.contains("PASS cargo test"));
502 assert!(!out.contains("Raw output"));
503 }
504
505 #[test]
506 fn test_result_failed_shows_raw_output() {
507 let run = TestRun {
508 id: None,
509 target: "mnw".to_string(),
510 started_at: "2026-03-10T00:00:00Z".to_string(),
511 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
512 duration_secs: Some(60),
513 exit_code: Some(1),
514 passed: false,
515 summary: TestSummary {
516 steps: vec![
517 StepResult { name: "cargo check".to_string(), passed: true },
518 StepResult { name: "cargo test".to_string(), passed: false },
519 ],
520 total_passed: Some(750),
521 total_failed: Some(9),
522 details: vec![],
523 },
524 raw_output: "thread 'test_foo' panicked at 'assertion failed'".to_string(),
525 filter: None,
526 };
527 let out = format_test_result("mnw", &run);
528 assert!(out.contains("mnw: FAILED"));
529 assert!(out.contains("PASS cargo check"));
530 assert!(out.contains("FAIL cargo test"));
531 assert!(out.contains("750 passed, 9 failed"));
532 assert!(out.contains("Raw output:"));
533 assert!(out.contains("assertion failed"));
534 }
535
536 #[test]
537 fn test_result_no_duration_or_counts() {
538 let run = TestRun {
539 id: None,
540 target: "svc".to_string(),
541 started_at: "2026-03-10T00:00:00Z".to_string(),
542 finished_at: None,
543 duration_secs: None,
544 exit_code: None,
545 passed: true,
546 summary: TestSummary {
547 steps: vec![],
548 total_passed: None,
549 total_failed: None,
550 details: vec![],
551 },
552 raw_output: String::new(),
553 filter: None,
554 };
555 let out = format_test_result("svc", &run);
556 assert!(out.contains("svc: PASSED"));
557 assert!(!out.contains("Duration:"));
558 assert!(!out.contains("Tests:"));
559 }
560
561 // --- format_status_target ---
562
563 #[test]
564 fn status_target_with_health_and_tests() {
565 let health = HealthSnapshot {
566 id: None,
567 target: "mnw".to_string(),
568 status: HealthStatus::Operational,
569 checked_at: "2026-03-10T00:00:00Z".to_string(),
570 response_time_ms: 95,
571 details: Some(HealthDetails {
572 version: Some("2.1.0".to_string()),
573 uptime: None,
574 checks: None,
575 monitoring: None,
576 }),
577 error: None,
578 };
579 let test = TestRun {
580 id: None,
581 target: "mnw".to_string(),
582 started_at: "2026-03-10T00:00:00Z".to_string(),
583 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
584 duration_secs: Some(60),
585 exit_code: Some(0),
586 passed: true,
587 summary: TestSummary {
588 steps: vec![],
589 total_passed: Some(100),
590 total_failed: Some(0),
591 details: vec![],
592 },
593 raw_output: String::new(),
594 filter: None,
595 };
596 let out = format_status_target("mnw", "MakeNotWork", Some(&health), None, None, None, None, None, Some(&test), None, None);
597 assert!(out.contains("=== mnw (MakeNotWork) ==="));
598 assert!(out.contains("Health: [OK] operational (95ms) v2.1.0"));
599 assert!(out.contains("Tests: PASSED (60s)"));
600 assert!(out.contains("100 passed, 0 failed"));
601 }
602
603 #[test]
604 fn status_target_no_data() {
605 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
606 assert!(out.contains("=== mnw (MakeNotWork) ==="));
607 assert!(out.contains("Health: no data"));
608 assert!(out.contains("Tests: no data"));
609 }
610
611 #[test]
612 fn status_target_health_only() {
613 let health = HealthSnapshot {
614 id: None,
615 target: "mnw".to_string(),
616 status: HealthStatus::Degraded,
617 checked_at: "2026-03-10T00:00:00Z".to_string(),
618 response_time_ms: 2000,
619 details: None,
620 error: None,
621 };
622 let out = format_status_target("mnw", "MakeNotWork", Some(&health), None, None, None, None, None, None, None, None);
623 assert!(out.contains("Health: [WARN] degraded (2000ms)"));
624 assert!(out.contains("Tests: no data"));
625 }
626
627 #[test]
628 fn status_target_failed_tests() {
629 let test = TestRun {
630 id: None,
631 target: "mnw".to_string(),
632 started_at: "2026-03-10T00:00:00Z".to_string(),
633 finished_at: None,
634 duration_secs: None,
635 exit_code: Some(1),
636 passed: false,
637 summary: TestSummary {
638 steps: vec![],
639 total_passed: Some(80),
640 total_failed: Some(5),
641 details: vec![],
642 },
643 raw_output: String::new(),
644 filter: None,
645 };
646 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, Some(&test), None, None);
647 assert!(out.contains("Tests: FAILED"));
648 assert!(out.contains("80 passed, 5 failed"));
649 }
650
651 // --- format_status_target with TLS ---
652
653 #[test]
654 fn status_target_tls_ok() {
655 let tls = TlsCheckRow {
656 id: 1,
657 target: "mnw".to_string(),
658 host: "makenot.work".to_string(),
659 valid: true,
660 days_remaining: 47,
661 not_before: "2026-01-10T00:00:00Z".to_string(),
662 not_after: "2026-04-27T00:00:00Z".to_string(),
663 subject: "CN=makenot.work".to_string(),
664 issuer: "CN=Let's Encrypt".to_string(),
665 checked_at: "2026-03-11T00:00:00Z".to_string(),
666 error: None,
667 };
668 let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None, None, None);
669 assert!(out.contains("TLS: [OK] makenot.work"));
670 assert!(out.contains("47d remaining"));
671 assert!(out.contains("expires 2026-04-27"));
672 }
673
674 #[test]
675 fn status_target_tls_warning() {
676 let tls = TlsCheckRow {
677 id: 1,
678 target: "mnw".to_string(),
679 host: "makenot.work".to_string(),
680 valid: true,
681 days_remaining: 12,
682 not_before: "2026-01-10T00:00:00Z".to_string(),
683 not_after: "2026-03-23T00:00:00Z".to_string(),
684 subject: "CN=makenot.work".to_string(),
685 issuer: "CN=Let's Encrypt".to_string(),
686 checked_at: "2026-03-11T00:00:00Z".to_string(),
687 error: None,
688 };
689 let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None, None, None);
690 assert!(out.contains("TLS: [WARN] makenot.work"));
691 assert!(out.contains("12d remaining"));
692 }
693
694 #[test]
695 fn status_target_tls_error() {
696 let tls = TlsCheckRow {
697 id: 1,
698 target: "mnw".to_string(),
699 host: "makenot.work".to_string(),
700 valid: false,
701 days_remaining: 0,
702 not_before: String::new(),
703 not_after: String::new(),
704 subject: String::new(),
705 issuer: String::new(),
706 checked_at: "2026-03-11T00:00:00Z".to_string(),
707 error: Some("connection refused".to_string()),
708 };
709 let out = format_status_target("mnw", "MakeNotWork", None, None, Some(&tls), None, None, None, None, None, None);
710 assert!(out.contains("TLS: [ERR] makenot.work"));
711 assert!(out.contains("connection refused"));
712 }
713
714 // --- format_status_target with incident ---
715
716 #[test]
717 fn status_target_with_active_incident() {
718 let incident = IncidentRow {
719 id: 1,
720 target: "mnw".to_string(),
721 started_at: "2026-03-11T14:30:00Z".to_string(),
722 ended_at: None,
723 duration_secs: None,
724 from_status: "operational".to_string(),
725 to_status: "degraded".to_string(),
726 };
727 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, Some(&incident));
728 assert!(out.contains("Incident: [ACTIVE] degraded since 2026-03-11T14:30:00Z"));
729 }
730
731 #[test]
732 fn status_target_no_incident() {
733 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
734 assert!(!out.contains("Incident"));
735 }
736
737 // --- format_status_target with latency ---
738
739 #[test]
740 fn status_target_with_latency() {
741 let latency = LatencyStats {
742 min_ms: 95,
743 max_ms: 210,
744 avg_ms: 120.0,
745 p95_ms: 180,
746 sample_count: 288,
747 };
748 let out = format_status_target("mnw", "MakeNotWork", None, Some(&latency), None, None, None, None, None, None, None);
749 assert!(out.contains("Latency (24h): avg 120ms, p95 180ms, range 95-210ms (288 samples)"));
750 }
751
752 #[test]
753 fn status_target_without_latency() {
754 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
755 assert!(!out.contains("Latency"));
756 }
757
758 // --- format_health_history ---
759
760 #[test]
761 fn health_history_empty() {
762 let out = format_health_history(&[]);
763 assert_eq!(out, "No health check history.\n");
764 }
765
766 #[test]
767 fn health_history_with_entries() {
768 let history = vec![
769 HealthSnapshot {
770 id: Some(2),
771 target: "mnw".to_string(),
772 status: HealthStatus::Operational,
773 checked_at: "2026-03-10T01:00:00Z".to_string(),
774 response_time_ms: 120,
775 details: None,
776 error: None,
777 },
778 HealthSnapshot {
779 id: Some(1),
780 target: "mnw".to_string(),
781 status: HealthStatus::Degraded,
782 checked_at: "2026-03-10T00:00:00Z".to_string(),
783 response_time_ms: 2500,
784 details: None,
785 error: None,
786 },
787 ];
788 let out = format_health_history(&history);
789 assert!(out.contains("[OK] mnw"));
790 assert!(out.contains("(120ms)"));
791 assert!(out.contains("2026-03-10T01:00:00Z"));
792 assert!(out.contains("[WARN] mnw"));
793 assert!(out.contains("(2500ms)"));
794 }
795
796 // --- format_test_history ---
797
798 #[test]
799 fn test_history_empty() {
800 let out = format_test_history(&[]);
801 assert_eq!(out, "No test run history.\n");
802 }
803
804 #[test]
805 fn test_history_with_entries() {
806 let history = vec![
807 TestRun {
808 id: Some(2),
809 target: "mnw".to_string(),
810 started_at: "2026-03-10T01:00:00Z".to_string(),
811 finished_at: None,
812 duration_secs: Some(120),
813 exit_code: Some(0),
814 passed: true,
815 summary: TestSummary {
816 steps: vec![],
817 total_passed: Some(810),
818 total_failed: Some(0),
819 details: vec![],
820 },
821 raw_output: String::new(),
822 filter: None,
823 },
824 TestRun {
825 id: Some(1),
826 target: "mnw".to_string(),
827 started_at: "2026-03-10T00:00:00Z".to_string(),
828 finished_at: None,
829 duration_secs: None,
830 exit_code: Some(1),
831 passed: false,
832 summary: TestSummary {
833 steps: vec![],
834 total_passed: None,
835 total_failed: None,
836 details: vec![],
837 },
838 raw_output: String::new(),
839 filter: None,
840 },
841 ];
842 let out = format_test_history(&history);
843 assert!(out.contains("[PASS] mnw (120s) 2026-03-10T01:00:00Z"));
844 assert!(out.contains("810 passed, 0 failed"));
845 assert!(out.contains("[FAIL] mnw 2026-03-10T00:00:00Z"));
846 }
847
848 #[test]
849 fn test_history_no_duration_no_counts() {
850 let history = vec![TestRun {
851 id: Some(1),
852 target: "svc".to_string(),
853 started_at: "2026-03-10T00:00:00Z".to_string(),
854 finished_at: None,
855 duration_secs: None,
856 exit_code: None,
857 passed: true,
858 summary: TestSummary {
859 steps: vec![],
860 total_passed: None,
861 total_failed: None,
862 details: vec![],
863 },
864 raw_output: String::new(),
865 filter: None,
866 }];
867 let out = format_test_history(&history);
868 // Should not have duration or counts
869 assert!(!out.contains("("));
870 assert!(out.contains("[PASS] svc 2026-03-10T00:00:00Z"));
871 }
872
873 // --- format_prune ---
874
875 #[test]
876 fn prune_formatting() {
877 let result = PruneResult {
878 health: 5, tests: 3, test_details: 15, heartbeats: 10, alerts: 2, tls: 1,
879 incidents: 4, routes: 0, dns: 8, whois: 2, backups: 1,
880 };
881 let out = format_prune(&result, 30);
882 assert_eq!(
883 out,
884 "Pruned 5 health checks, 3 test runs, 15 test details, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks, 8 DNS checks, 2 WHOIS checks, 1 backup checks older than 30 days.\n"
885 );
886 }
887
888 #[test]
889 fn prune_zero_records() {
890 let result = PruneResult {
891 health: 0, tests: 0, test_details: 0, heartbeats: 0, alerts: 0, tls: 0,
892 incidents: 0, routes: 0, dns: 0, whois: 0, backups: 0,
893 };
894 let out = format_prune(&result, 7);
895 assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 test details, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks, 0 DNS checks, 0 WHOIS checks, 0 backup checks older than 7 days."));
896 }
897
898 // --- format_mesh ---
899
900 #[test]
901 fn mesh_no_instances() {
902 let data = serde_json::json!({});
903 let out = format_mesh(&data);
904 assert_eq!(out, "No mesh data available.\n");
905 }
906
907 #[test]
908 fn mesh_empty_instances() {
909 let data = serde_json::json!({ "instances": {} });
910 let out = format_mesh(&data);
911 // Empty map — no output lines beyond the empty string
912 assert!(out.is_empty());
913 }
914
915 #[test]
916 fn mesh_single_instance_with_targets_and_peers() {
917 let data = serde_json::json!({
918 "instances": {
919 "hetzner": {
920 "instance": {
921 "id": "uuid-123",
922 "version": "0.2.0"
923 },
924 "targets": {
925 "mnw": {
926 "status": "operational",
927 "response_time_ms": 95
928 }
929 },
930 "peers": {
931 "astra": {
932 "status": "online",
933 "latency_ms": 42
934 }
935 }
936 }
937 }
938 });
939 let out = format_mesh(&data);
940 assert!(out.contains("=== hetzner ==="));
941 assert!(out.contains("ID: uuid-123"));
942 assert!(out.contains("Version: 0.2.0"));
943 assert!(out.contains("Target mnw: operational (95ms)"));
944 assert!(out.contains("Peer astra: online (42ms)"));
945 }
946
947 #[test]
948 fn mesh_missing_instance_details() {
949 let data = serde_json::json!({
950 "instances": {
951 "node-1": {}
952 }
953 });
954 let out = format_mesh(&data);
955 assert!(out.contains("=== node-1 ==="));
956 assert!(out.contains("ID: ?"));
957 assert!(out.contains("Version: ?"));
958 }
959
960 #[test]
961 fn mesh_instance_with_error() {
962 let data = serde_json::json!({
963 "instances": {
964 "node-2": {
965 "error": "connection refused"
966 }
967 }
968 });
969 let out = format_mesh(&data);
970 assert!(out.contains("=== node-2 ==="));
971 assert!(out.contains("(connection refused)"));
972 }
973
974 #[test]
975 fn mesh_target_without_response_time() {
976 let data = serde_json::json!({
977 "instances": {
978 "node": {
979 "instance": { "id": "x", "version": "1.0" },
980 "targets": {
981 "svc": { "status": "unreachable" }
982 }
983 }
984 }
985 });
986 let out = format_mesh(&data);
987 assert!(out.contains("Target svc: unreachable"));
988 // No (Xms) suffix
989 assert!(!out.contains("Target svc: unreachable ("));
990 }
991
992 #[test]
993 fn mesh_peer_without_latency() {
994 let data = serde_json::json!({
995 "instances": {
996 "node": {
997 "instance": { "id": "x", "version": "1.0" },
998 "peers": {
999 "other": { "status": "missing" }
1000 }
1001 }
1002 }
1003 });
1004 let out = format_mesh(&data);
1005 assert!(out.contains("Peer other: missing"));
1006 // No (Xms) suffix
1007 assert!(!out.contains("Peer other: missing ("));
1008 }
1009
1010 #[test]
1011 fn mesh_multiple_instances() {
1012 let data = serde_json::json!({
1013 "instances": {
1014 "alpha": {
1015 "instance": { "id": "a1", "version": "0.1.0" }
1016 },
1017 "beta": {
1018 "instance": { "id": "b2", "version": "0.2.0" }
1019 }
1020 }
1021 });
1022 let out = format_mesh(&data);
1023 assert!(out.contains("=== alpha ==="));
1024 assert!(out.contains("=== beta ==="));
1025 assert!(out.contains("ID: a1"));
1026 assert!(out.contains("ID: b2"));
1027 }
1028
1029 // --- format_status_target with staleness ---
1030
1031 #[test]
1032 fn status_target_stale_by_version() {
1033 let staleness = TestStaleness {
1034 stale: true,
1035 reason: Some("version changed: 0.1.8 -> 0.1.9".to_string()),
1036 current_version: Some("0.1.9".to_string()),
1037 tested_version: Some("0.1.8".to_string()),
1038 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
1039 days_since_test: Some(1),
1040 };
1041 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, Some(&staleness), None);
1042 assert!(out.contains("Tests: STALE"));
1043 assert!(out.contains("version changed: 0.1.8 -> 0.1.9"));
1044 }
1045
1046 #[test]
1047 fn status_target_stale_by_age() {
1048 let staleness = TestStaleness {
1049 stale: true,
1050 reason: Some("tests are 10 days old (threshold: 7d)".to_string()),
1051 current_version: Some("0.1.9".to_string()),
1052 tested_version: Some("0.1.9".to_string()),
1053 last_test_at: Some("2026-03-01T00:00:00Z".to_string()),
1054 days_since_test: Some(10),
1055 };
1056 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, Some(&staleness), None);
1057 assert!(out.contains("Tests: STALE"));
1058 assert!(out.contains("tests are 10 days old"));
1059 }
1060
1061 #[test]
1062 fn status_target_not_stale() {
1063 let staleness = TestStaleness {
1064 stale: false,
1065 reason: None,
1066 current_version: Some("0.1.9".to_string()),
1067 tested_version: Some("0.1.9".to_string()),
1068 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
1069 days_since_test: Some(1),
1070 };
1071 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, Some(&staleness), None);
1072 assert!(!out.contains("STALE"));
1073 }
1074
1075 #[test]
1076 fn status_target_no_staleness_data() {
1077 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
1078 assert!(!out.contains("STALE"));
1079 }
1080
1081 // --- format_status_target with routes ---
1082
1083 #[test]
1084 fn status_target_all_routes_ok() {
1085 let checks = vec![
1086 RouteCheckRow { id: 1, target: "mnw".to_string(), path: "/".to_string(), status_code: 200, ok: true, response_time_ms: 50, checked_at: "2026-03-13T00:00:00Z".to_string(), error: None },
1087 RouteCheckRow { id: 2, target: "mnw".to_string(), path: "/docs".to_string(), status_code: 200, ok: true, response_time_ms: 60, checked_at: "2026-03-13T00:00:00Z".to_string(), error: None },
1088 ];
1089 let out = format_status_target("mnw", "MakeNotWork", None, None, None, Some(&checks), None, None, None, None, None);
1090 assert!(out.contains("Routes: 2/2 OK"));
1091 }
1092
1093 #[test]
1094 fn status_target_some_routes_failing() {
1095 let checks = vec![
1096 RouteCheckRow { id: 1, target: "mnw".to_string(), path: "/".to_string(), status_code: 200, ok: true, response_time_ms: 50, checked_at: "2026-03-13T00:00:00Z".to_string(), error: None },
1097 RouteCheckRow { id: 2, target: "mnw".to_string(), path: "/docs/faq".to_string(), status_code: 404, ok: false, response_time_ms: 30, checked_at: "2026-03-13T00:00:00Z".to_string(), error: Some("HTTP 404".to_string()) },
1098 RouteCheckRow { id: 3, target: "mnw".to_string(), path: "/pricing".to_string(), status_code: 500, ok: false, response_time_ms: 20, checked_at: "2026-03-13T00:00:00Z".to_string(), error: Some("HTTP 500".to_string()) },
1099 ];
1100 let out = format_status_target("mnw", "MakeNotWork", None, None, None, Some(&checks), None, None, None, None, None);
1101 assert!(out.contains("Routes: 1/3 (FAIL: /docs/faq, /pricing)"));
1102 }
1103
1104 #[test]
1105 fn status_target_no_route_checks() {
1106 let out = format_status_target("mnw", "MakeNotWork", None, None, None, None, None, None, None, None, None);
1107 assert!(!out.contains("Routes"));
1108 }
1109 }
1110