Skip to main content

max / makenotwork

53.1 KB · 1709 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::{
10 DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, VersionRow, WhoisResult,
11 };
12
13 /// Strip terminal control characters from an untrusted remote string before it
14 /// is written to the operator's terminal.
15 ///
16 /// A monitored host's health `version`/`uptime`/`error`, a whois/DNS error, a
17 /// mesh peer's fields, or *any* TCP service on an ssh_banner target can embed raw
18 /// ANSI escape sequences (ESC/CSI) that clear or rewrite the screen, e.g. a fake
19 /// all-clear, the moment they're printed on `pom status`/`mesh`. Every such
20 /// value passes through here at the display sink. Keeps printable UTF-8; drops C0/C1 controls (incl. ESC, CR, LF) and DEL.
21 fn scrub(s: &str) -> String {
22 s.chars().filter(|c| !c.is_control()).collect()
23 }
24
25 /// Format a single health snapshot as a human-readable line.
26 pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
27 let mut out = String::new();
28 write!(out, "[{}] {}: {}", s.status.icon(), s.target, s.status).unwrap();
29 write!(out, " ({}ms)", s.response_time_ms).unwrap();
30 if let Some(details) = &s.details {
31 if let Some(v) = &details.version {
32 write!(out, " v{}", scrub(v)).unwrap();
33 }
34 if let Some(u) = &details.uptime {
35 write!(out, " up {}", scrub(u)).unwrap();
36 }
37 }
38 writeln!(out).unwrap();
39 if let Some(err) = &s.error {
40 writeln!(out, " {}", scrub(err)).unwrap();
41 }
42 out
43 }
44
45 /// Format a list of health snapshots for CLI display.
46 pub fn format_health_snapshots(snapshots: &[HealthSnapshot]) -> String {
47 let mut out = String::new();
48 for s in snapshots {
49 out.push_str(&format_health_snapshot(s));
50 }
51 out
52 }
53
54 /// Format a test run result for CLI display.
55 pub fn format_test_result(target_name: &str, run: &TestRun) -> String {
56 let mut out = String::new();
57 let result = if run.passed { "PASSED" } else { "FAILED" };
58 writeln!(out, "{target_name}: {result}").unwrap();
59 if let Some(d) = run.duration_secs {
60 writeln!(out, "Duration: {d}s").unwrap();
61 }
62 if let (Some(p), Some(f)) = (run.summary.total_passed, run.summary.total_failed) {
63 writeln!(out, "Tests: {p} passed, {f} failed").unwrap();
64 }
65 for step in &run.summary.steps {
66 let mark = if step.passed { "PASS" } else { "FAIL" };
67 writeln!(out, " {mark} {}", step.name).unwrap();
68 }
69 if !run.passed {
70 writeln!(out, "\nRaw output:\n{}", run.raw_output).unwrap();
71 }
72 out
73 }
74
75 /// Format a single target's status block for CLI display.
76 #[allow(clippy::too_many_arguments)]
77 pub fn format_status_target(
78 name: &str,
79 label: &str,
80 health: Option<&HealthSnapshot>,
81 latency: Option<&LatencyStats>,
82 tls: Option<&TlsCheckRow>,
83 route_checks: Option<&[RouteCheckRow]>,
84 dns_checks: Option<&[DnsCheckRow]>,
85 whois: Option<&WhoisCheckRow>,
86 test: Option<&TestRun>,
87 staleness: Option<&TestStaleness>,
88 incident: Option<&IncidentRow>,
89 ) -> String {
90 let mut out = String::new();
91 writeln!(out, "=== {name} ({label}) ===").unwrap();
92
93 if let Some(h) = health {
94 write!(out, " Health: [{}] {}", h.status.icon(), h.status).unwrap();
95 write!(out, " ({}ms)", h.response_time_ms).unwrap();
96 if let Some(d) = &h.details
97 && let Some(v) = &d.version
98 {
99 write!(out, " v{}", scrub(v)).unwrap();
100 }
101 writeln!(out).unwrap();
102 } else {
103 writeln!(out, " Health: no data").unwrap();
104 }
105
106 if let Some(l) = latency {
107 writeln!(
108 out,
109 " Latency (24h): avg {:.0}ms, p95 {}ms, range {}-{}ms ({} samples)",
110 l.avg_ms, l.p95_ms, l.min_ms, l.max_ms, l.sample_count
111 )
112 .unwrap();
113 }
114
115 if let Some(t) = tls {
116 if let Some(ref err) = t.error {
117 writeln!(out, " TLS: [ERR] {}: {}", t.host, scrub(err)).unwrap();
118 } else if t.days_remaining <= 0 {
119 writeln!(
120 out,
121 " TLS: [ERR] {}: EXPIRED (expired {})",
122 t.host, t.not_after
123 )
124 .unwrap();
125 } else if t.days_remaining <= 14 {
126 writeln!(
127 out,
128 " TLS: [WARN] {}: {}d remaining (expires {})",
129 t.host, t.days_remaining, t.not_after
130 )
131 .unwrap();
132 } else {
133 writeln!(
134 out,
135 " TLS: [OK] {}: {}d remaining (expires {})",
136 t.host, t.days_remaining, t.not_after
137 )
138 .unwrap();
139 }
140 }
141
142 if let Some(checks) = route_checks
143 && !checks.is_empty()
144 {
145 let total = checks.len();
146 let ok_count = checks.iter().filter(|c| c.ok).count();
147 if ok_count == total {
148 writeln!(out, " Routes: {ok_count}/{total} OK").unwrap();
149 } else {
150 let failed: Vec<&str> = checks
151 .iter()
152 .filter(|c| !c.ok)
153 .map(|c| c.path.as_str())
154 .collect();
155 writeln!(
156 out,
157 " Routes: {ok_count}/{total} (FAIL: {})",
158 failed.join(", ")
159 )
160 .unwrap();
161 }
162 }
163
164 if let Some(checks) = dns_checks
165 && !checks.is_empty()
166 {
167 let total = checks.len();
168 let ok_count = checks.iter().filter(|c| c.matches).count();
169 if ok_count == total {
170 writeln!(out, " DNS: {ok_count}/{total} match").unwrap();
171 } else {
172 let failed: Vec<String> = checks
173 .iter()
174 .filter(|c| !c.matches)
175 .map(|c| format!("{} {}", c.name, c.record_type))
176 .collect();
177 writeln!(
178 out,
179 " DNS: {ok_count}/{total} (MISMATCH: {})",
180 failed.join(", ")
181 )
182 .unwrap();
183 }
184 }
185
186 if let Some(w) = whois {
187 if let Some(ref err) = w.error {
188 writeln!(out, " WHOIS: [ERR] {}: {}", w.domain, scrub(err)).unwrap();
189 } else if let Some(days) = w.days_remaining {
190 if days <= 0 {
191 writeln!(out, " WHOIS: [ERR] {}: EXPIRED", w.domain).unwrap();
192 } else if days <= 30 {
193 writeln!(out, " WHOIS: [WARN] {}: {}d remaining", w.domain, days).unwrap();
194 } else {
195 writeln!(out, " WHOIS: [OK] {}: {}d remaining", w.domain, days).unwrap();
196 }
197 }
198 }
199
200 if let Some(t) = test {
201 let result = if t.passed { "PASSED" } else { "FAILED" };
202 write!(out, " Tests: {result}").unwrap();
203 if let Some(d) = t.duration_secs {
204 write!(out, " ({d}s)").unwrap();
205 }
206 writeln!(out).unwrap();
207 if let (Some(p), Some(f)) = (t.summary.total_passed, t.summary.total_failed) {
208 writeln!(out, " {p} passed, {f} failed").unwrap();
209 }
210 } else {
211 writeln!(out, " Tests: no data").unwrap();
212 }
213
214 if let Some(s) = staleness
215 && s.stale
216 && let Some(reason) = &s.reason
217 {
218 writeln!(out, " Tests: STALE ({})", scrub(reason)).unwrap();
219 }
220
221 if let Some(inc) = incident {
222 writeln!(
223 out,
224 " Incident: [ACTIVE] {} since {}",
225 inc.to_status, inc.started_at
226 )
227 .unwrap();
228 }
229
230 writeln!(out).unwrap();
231 out
232 }
233
234 /// Format health check history for CLI display.
235 pub fn format_health_history(history: &[HealthSnapshot]) -> String {
236 if history.is_empty() {
237 return "No health check history.\n".to_string();
238 }
239 let mut out = String::new();
240 for h in history {
241 writeln!(
242 out,
243 "[{}] {}: {} ({}ms) {}",
244 h.status.icon(),
245 h.target,
246 h.status,
247 h.response_time_ms,
248 h.checked_at
249 )
250 .unwrap();
251 }
252 out
253 }
254
255 /// Format test run history for CLI display.
256 pub fn format_test_history(history: &[TestRun]) -> String {
257 if history.is_empty() {
258 return "No test run history.\n".to_string();
259 }
260 let mut out = String::new();
261 for r in history {
262 let result = if r.passed { "PASS" } else { "FAIL" };
263 write!(out, "[{result}] {}", r.target).unwrap();
264 if let Some(d) = r.duration_secs {
265 write!(out, " ({d}s)").unwrap();
266 }
267 write!(out, " {}", r.started_at).unwrap();
268 if let (Some(p), Some(f)) = (r.summary.total_passed, r.summary.total_failed) {
269 write!(out, " ({p} passed, {f} failed)").unwrap();
270 }
271 writeln!(out).unwrap();
272 }
273 out
274 }
275
276 /// Format regression warnings for CLI display.
277 pub fn format_regressions(regressions: &[String]) -> String {
278 let mut out = String::new();
279 writeln!(out, "\nREGRESSIONS (passed last run, failed now):").unwrap();
280 for name in regressions {
281 writeln!(out, " {name}").unwrap();
282 }
283 out
284 }
285
286 /// Format test duration trend for CLI display.
287 pub fn format_test_duration_trend(durations: &[(String, i64)], drift: Option<&str>) -> String {
288 let mut out = String::new();
289 if !durations.is_empty() {
290 write!(out, " Duration trend (last {}): ", durations.len()).unwrap();
291 let strs: Vec<String> = durations.iter().map(|(_, d)| format!("{d}s")).collect();
292 writeln!(out, "{}", strs.join(", ")).unwrap();
293 }
294 if let Some(msg) = drift {
295 writeln!(out, " DRIFT: {msg}").unwrap();
296 }
297 out
298 }
299
300 /// Format DNS check results and WHOIS results for CLI display.
301 pub fn format_dns_results(dns_results: &[DnsCheckResult], whois_results: &[WhoisResult]) -> String {
302 let mut out = String::new();
303
304 if !dns_results.is_empty() {
305 writeln!(out, "DNS Records:").unwrap();
306 for r in dns_results {
307 if let Some(ref err) = r.error {
308 writeln!(out, " [ERR] {} {}: {}", r.name, r.record_type, scrub(err)).unwrap();
309 } else if r.matches {
310 writeln!(out, " [OK] {} {}: {:?}", r.name, r.record_type, r.actual).unwrap();
311 } else {
312 writeln!(
313 out,
314 " [FAIL] {} {}: expected {:?}, got {:?}",
315 r.name, r.record_type, r.expected, r.actual
316 )
317 .unwrap();
318 }
319 }
320 }
321
322 if !whois_results.is_empty() {
323 if !dns_results.is_empty() {
324 writeln!(out).unwrap();
325 }
326 writeln!(out, "WHOIS:").unwrap();
327 for w in whois_results {
328 if let Some(ref err) = w.error {
329 writeln!(out, " [ERR] {}: {}", w.domain, scrub(err)).unwrap();
330 } else {
331 let days_str = w.days_remaining.map_or_else(
332 || "expiry unknown".to_string(),
333 |d| format!("{d}d remaining"),
334 );
335 let registrar_str = w.registrar.as_deref().unwrap_or("unknown registrar");
336 writeln!(out, " [OK] {}: {days_str} ({registrar_str})", w.domain).unwrap();
337 }
338 }
339 }
340
341 out
342 }
343
344 /// Format prune results for CLI display.
345 pub fn format_prune(result: &PruneResult, days: i64) -> String {
346 format!(
347 "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks, {} systemd checks, {} CA bundle checks, {} synckit fleet checks older than {} days.\n",
348 result.health,
349 result.tests,
350 result.test_details,
351 result.heartbeats,
352 result.alerts,
353 result.tls,
354 result.incidents,
355 result.routes,
356 result.dns,
357 result.whois,
358 result.backups,
359 result.systemd,
360 result.ca_bundle,
361 result.synckit_fleet,
362 days
363 )
364 }
365
366 /// Format the `pom versions` roll-up as one aligned table.
367 ///
368 /// Every cell is a value a monitored target chose, so all of them are scrubbed.
369 /// A missing value prints as `-`: the table answers "what is live everywhere",
370 /// and a row that has to be left out of the answer is itself the finding.
371 pub fn format_versions(rows: &[VersionRow]) -> String {
372 if rows.is_empty() {
373 return "No targets configured.\n".to_string();
374 }
375
376 let cells: Vec<[String; 6]> = rows
377 .iter()
378 .map(|r| {
379 [
380 scrub(&r.target),
381 r.version.as_deref().map_or_else(dash, scrub),
382 r.git_sha.as_deref().map_or_else(dash, short_sha),
383 r.version_since.as_deref().map_or_else(dash, minute_stamp),
384 r.checked_at.as_deref().map_or_else(dash, minute_stamp),
385 r.commits_behind.map_or_else(dash, |n| n.to_string()),
386 ]
387 })
388 .collect();
389
390 // SINCE is when this version was first seen, not when it was deployed:
391 // nothing tells PoM about a deploy, and the column header should not
392 // promise more than a poller can know.
393 const HEADERS: [&str; 6] = ["TARGET", "VERSION", "SHA", "SINCE", "CHECKED", "BEHIND"];
394 let widths: Vec<usize> = (0..HEADERS.len())
395 .map(|i| {
396 cells
397 .iter()
398 .map(|row| row[i].chars().count())
399 .chain(std::iter::once(HEADERS[i].len()))
400 .max()
401 .unwrap_or(0)
402 })
403 .collect();
404
405 let mut out = String::new();
406 write_row(&mut out, &HEADERS.map(String::from), &widths);
407 for (row, source) in cells.iter().zip(rows) {
408 write_row(&mut out, row, &widths);
409 // The blank in the BEHIND column has a reason whenever a count was
410 // attempted and did not come back. Printing it under the row keeps the
411 // table one line per target while still saying what happened.
412 if let Some(err) = &source.behind_error {
413 writeln!(out, " behind: {}", scrub(err)).unwrap();
414 }
415 }
416 out
417 }
418
419 /// Write one table row, every column but the last padded to its width.
420 fn write_row(out: &mut String, cells: &[String; 6], widths: &[usize]) {
421 for (i, cell) in cells.iter().enumerate() {
422 if i + 1 == cells.len() {
423 writeln!(out, "{cell}").unwrap();
424 } else {
425 let pad = widths[i].saturating_sub(cell.chars().count());
426 write!(out, "{cell}{:pad$} ", "", pad = pad).unwrap();
427 }
428 }
429 }
430
431 fn dash() -> String {
432 "-".to_string()
433 }
434
435 /// First 8 characters of a commit id, enough to identify it by eye.
436 fn short_sha(sha: &str) -> String {
437 scrub(sha).chars().take(8).collect()
438 }
439
440 /// RFC 3339 down to the minute: seconds and offset are noise in a table whose
441 /// rows are minutes to hours apart. Anything that does not look like a
442 /// timestamp is passed through rather than truncated into nonsense.
443 fn minute_stamp(ts: &str) -> String {
444 let scrubbed = scrub(ts);
445 match (scrubbed.len() >= 16, scrubbed.get(..16)) {
446 (true, Some(head)) if scrubbed.as_bytes()[10] == b'T' => head.replacen('T', " ", 1),
447 _ => scrubbed,
448 }
449 }
450
451 /// Format mesh data (from JSON) for human-readable CLI display.
452 pub fn format_mesh(data: &serde_json::Value) -> String {
453 let Some(instances) = data.get("instances").and_then(|v| v.as_object()) else {
454 return "No mesh data available.\n".to_string();
455 };
456
457 let mut out = String::new();
458 for (name, instance_data) in instances {
459 let instance = instance_data.get("instance");
460 let id = instance
461 .and_then(|i| i.get("id"))
462 .and_then(|v| v.as_str())
463 .unwrap_or("?");
464 let version = instance
465 .and_then(|i| i.get("version"))
466 .and_then(|v| v.as_str())
467 .unwrap_or("?");
468
469 writeln!(out, "=== {} ===", scrub(name)).unwrap();
470 writeln!(out, " ID: {}", scrub(id)).unwrap();
471 writeln!(out, " Version: {}", scrub(version)).unwrap();
472
473 // Targets
474 if let Some(targets) = instance_data.get("targets").and_then(|v| v.as_object()) {
475 for (target_name, target_data) in targets {
476 let status = target_data
477 .get("status")
478 .and_then(|v| v.as_str())
479 .unwrap_or("?");
480 let ms = target_data
481 .get("response_time_ms")
482 .and_then(serde_json::Value::as_i64);
483 let ms_str = ms.map(|m| format!(" ({m}ms)")).unwrap_or_default();
484 writeln!(
485 out,
486 " Target {}: {}{ms_str}",
487 scrub(target_name),
488 scrub(status)
489 )
490 .unwrap();
491 }
492 }
493
494 // Peers
495 if let Some(peers) = instance_data.get("peers").and_then(|v| v.as_object()) {
496 for (peer_name, peer_data) in peers {
497 let status = peer_data
498 .get("status")
499 .and_then(|v| v.as_str())
500 .unwrap_or("?");
501 let latency = peer_data
502 .get("latency_ms")
503 .and_then(serde_json::Value::as_u64)
504 .map(|ms| format!(" ({ms}ms)"))
505 .unwrap_or_default();
506 writeln!(
507 out,
508 " Peer {}: {}{latency}",
509 scrub(peer_name),
510 scrub(status)
511 )
512 .unwrap();
513 }
514 }
515
516 // Error fallback
517 if let Some(err) = instance_data.get("error").and_then(|v| v.as_str()) {
518 writeln!(out, " ({})", scrub(err)).unwrap();
519 }
520
521 writeln!(out).unwrap();
522 }
523 out
524 }
525
526 #[cfg(test)]
527 mod tests {
528 use super::*;
529 use crate::types::*;
530
531 #[test]
532 fn scrub_strips_ansi_and_control_chars() {
533 // A monitored host trying to clear/rewrite the operator's terminal.
534 let hostile = "1.0\u{1b}[2J\u{1b}[1;1H FAKE ALL-CLEAR\r\n";
535 let cleaned = scrub(hostile);
536 assert!(!cleaned.contains('\u{1b}'), "ESC must be stripped");
537 assert!(!cleaned.contains('\r') && !cleaned.contains('\n'));
538 assert_eq!(cleaned, "1.0[2J[1;1H FAKE ALL-CLEAR");
539 // Printable non-ASCII Unicode is preserved.
540 assert_eq!(scrub("v1.2 \u{2014} ok"), "v1.2 \u{2014} ok");
541 }
542
543 #[test]
544 fn health_snapshot_scrubs_hostile_error() {
545 let s = HealthSnapshot {
546 id: None,
547 target: "mnw".to_string(),
548 status: HealthStatus::Error,
549 checked_at: "2026-07-07T00:00:00+00:00".to_string(),
550 response_time_ms: 5,
551 details: None,
552 error: Some("boom\u{1b}[2Jcleared".to_string()),
553 };
554 let out = format_health_snapshot(&s);
555 assert!(
556 !out.contains('\u{1b}'),
557 "ESC from a remote error must not reach the terminal"
558 );
559 }
560
561 // format_health_snapshot
562
563 #[test]
564 fn health_snapshot_operational_with_details() {
565 let s = 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("1.2.0".to_string()),
573 git_sha: None,
574 uptime: Some("5d 3h".to_string()),
575 checks: None,
576 monitoring: None,
577 }),
578 error: None,
579 };
580 let out = format_health_snapshot(&s);
581 assert!(out.contains("[OK]"));
582 assert!(out.contains("mnw"));
583 assert!(out.contains("operational"));
584 assert!(out.contains("(95ms)"));
585 assert!(out.contains("v1.2.0"));
586 assert!(out.contains("up 5d 3h"));
587 }
588
589 #[test]
590 fn health_snapshot_unreachable_with_error() {
591 let s = HealthSnapshot {
592 id: None,
593 target: "api".to_string(),
594 status: HealthStatus::Unreachable,
595 checked_at: "2026-03-10T00:00:00Z".to_string(),
596 response_time_ms: 0,
597 details: None,
598 error: Some("connection refused".to_string()),
599 };
600 let out = format_health_snapshot(&s);
601 assert!(out.contains("[DOWN]"));
602 assert!(out.contains("unreachable"));
603 assert!(out.contains("connection refused"));
604 }
605
606 #[test]
607 fn health_snapshot_degraded_no_details() {
608 let s = HealthSnapshot {
609 id: None,
610 target: "svc".to_string(),
611 status: HealthStatus::Degraded,
612 checked_at: "2026-03-10T00:00:00Z".to_string(),
613 response_time_ms: 2500,
614 details: None,
615 error: None,
616 };
617 let out = format_health_snapshot(&s);
618 assert!(out.contains("[WARN]"));
619 assert!(out.contains("degraded"));
620 assert!(out.contains("(2500ms)"));
621 assert!(!out.contains("up "));
622 assert!(!out.contains(" v"));
623 }
624
625 #[test]
626 fn health_snapshot_error_status() {
627 let s = HealthSnapshot {
628 id: None,
629 target: "db".to_string(),
630 status: HealthStatus::Error,
631 checked_at: "2026-03-10T00:00:00Z".to_string(),
632 response_time_ms: 500,
633 details: None,
634 error: Some("500 internal server error".to_string()),
635 };
636 let out = format_health_snapshot(&s);
637 assert!(out.contains("[ERR]"));
638 assert!(out.contains("error"));
639 assert!(out.contains("500 internal server error"));
640 }
641
642 #[test]
643 fn health_snapshots_multiple() {
644 let snapshots = vec![
645 HealthSnapshot {
646 id: None,
647 target: "a".to_string(),
648 status: HealthStatus::Operational,
649 checked_at: "2026-03-10T00:00:00Z".to_string(),
650 response_time_ms: 50,
651 details: None,
652 error: None,
653 },
654 HealthSnapshot {
655 id: None,
656 target: "b".to_string(),
657 status: HealthStatus::Degraded,
658 checked_at: "2026-03-10T00:00:00Z".to_string(),
659 response_time_ms: 3000,
660 details: None,
661 error: None,
662 },
663 ];
664 let out = format_health_snapshots(&snapshots);
665 assert!(out.contains("[OK]"));
666 assert!(out.contains("[WARN]"));
667 assert!(out.contains('a'));
668 assert!(out.contains('b'));
669 }
670
671 // format_test_result
672
673 #[test]
674 fn test_result_passed() {
675 let run = TestRun {
676 id: None,
677 target: "mnw".to_string(),
678 started_at: "2026-03-10T00:00:00Z".to_string(),
679 finished_at: Some("2026-03-10T00:02:00Z".to_string()),
680 duration_secs: Some(120),
681 exit_code: Some(0),
682 passed: true,
683 summary: TestSummary {
684 steps: vec![
685 StepResult {
686 name: "cargo check".to_string(),
687 passed: true,
688 },
689 StepResult {
690 name: "cargo test".to_string(),
691 passed: true,
692 },
693 ],
694 total_passed: Some(759),
695 total_failed: Some(0),
696 details: vec![],
697 },
698 raw_output: String::new(),
699 filter: None,
700 };
701 let out = format_test_result("mnw", &run);
702 assert!(out.contains("mnw: PASSED"));
703 assert!(out.contains("Duration: 120s"));
704 assert!(out.contains("Tests: 759 passed, 0 failed"));
705 assert!(out.contains("PASS cargo check"));
706 assert!(out.contains("PASS cargo test"));
707 assert!(!out.contains("Raw output"));
708 }
709
710 #[test]
711 fn test_result_failed_shows_raw_output() {
712 let run = TestRun {
713 id: None,
714 target: "mnw".to_string(),
715 started_at: "2026-03-10T00:00:00Z".to_string(),
716 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
717 duration_secs: Some(60),
718 exit_code: Some(1),
719 passed: false,
720 summary: TestSummary {
721 steps: vec![
722 StepResult {
723 name: "cargo check".to_string(),
724 passed: true,
725 },
726 StepResult {
727 name: "cargo test".to_string(),
728 passed: false,
729 },
730 ],
731 total_passed: Some(750),
732 total_failed: Some(9),
733 details: vec![],
734 },
735 raw_output: "thread 'test_foo' panicked at 'assertion failed'".to_string(),
736 filter: None,
737 };
738 let out = format_test_result("mnw", &run);
739 assert!(out.contains("mnw: FAILED"));
740 assert!(out.contains("PASS cargo check"));
741 assert!(out.contains("FAIL cargo test"));
742 assert!(out.contains("750 passed, 9 failed"));
743 assert!(out.contains("Raw output:"));
744 assert!(out.contains("assertion failed"));
745 }
746
747 #[test]
748 fn test_result_no_duration_or_counts() {
749 let run = TestRun {
750 id: None,
751 target: "svc".to_string(),
752 started_at: "2026-03-10T00:00:00Z".to_string(),
753 finished_at: None,
754 duration_secs: None,
755 exit_code: None,
756 passed: true,
757 summary: TestSummary {
758 steps: vec![],
759 total_passed: None,
760 total_failed: None,
761 details: vec![],
762 },
763 raw_output: String::new(),
764 filter: None,
765 };
766 let out = format_test_result("svc", &run);
767 assert!(out.contains("svc: PASSED"));
768 assert!(!out.contains("Duration:"));
769 assert!(!out.contains("Tests:"));
770 }
771
772 // format_status_target
773
774 #[test]
775 fn status_target_with_health_and_tests() {
776 let health = HealthSnapshot {
777 id: None,
778 target: "mnw".to_string(),
779 status: HealthStatus::Operational,
780 checked_at: "2026-03-10T00:00:00Z".to_string(),
781 response_time_ms: 95,
782 details: Some(HealthDetails {
783 version: Some("2.1.0".to_string()),
784 git_sha: None,
785 uptime: None,
786 checks: None,
787 monitoring: None,
788 }),
789 error: None,
790 };
791 let test = TestRun {
792 id: None,
793 target: "mnw".to_string(),
794 started_at: "2026-03-10T00:00:00Z".to_string(),
795 finished_at: Some("2026-03-10T00:01:00Z".to_string()),
796 duration_secs: Some(60),
797 exit_code: Some(0),
798 passed: true,
799 summary: TestSummary {
800 steps: vec![],
801 total_passed: Some(100),
802 total_failed: Some(0),
803 details: vec![],
804 },
805 raw_output: String::new(),
806 filter: None,
807 };
808 let out = format_status_target(
809 "mnw",
810 "MakeNotWork",
811 Some(&health),
812 None,
813 None,
814 None,
815 None,
816 None,
817 Some(&test),
818 None,
819 None,
820 );
821 assert!(out.contains("=== mnw (MakeNotWork) ==="));
822 assert!(out.contains("Health: [OK] operational (95ms) v2.1.0"));
823 assert!(out.contains("Tests: PASSED (60s)"));
824 assert!(out.contains("100 passed, 0 failed"));
825 }
826
827 #[test]
828 fn status_target_no_data() {
829 let out = format_status_target(
830 "mnw",
831 "MakeNotWork",
832 None,
833 None,
834 None,
835 None,
836 None,
837 None,
838 None,
839 None,
840 None,
841 );
842 assert!(out.contains("=== mnw (MakeNotWork) ==="));
843 assert!(out.contains("Health: no data"));
844 assert!(out.contains("Tests: no data"));
845 }
846
847 #[test]
848 fn status_target_health_only() {
849 let health = HealthSnapshot {
850 id: None,
851 target: "mnw".to_string(),
852 status: HealthStatus::Degraded,
853 checked_at: "2026-03-10T00:00:00Z".to_string(),
854 response_time_ms: 2000,
855 details: None,
856 error: None,
857 };
858 let out = format_status_target(
859 "mnw",
860 "MakeNotWork",
861 Some(&health),
862 None,
863 None,
864 None,
865 None,
866 None,
867 None,
868 None,
869 None,
870 );
871 assert!(out.contains("Health: [WARN] degraded (2000ms)"));
872 assert!(out.contains("Tests: no data"));
873 }
874
875 #[test]
876 fn status_target_failed_tests() {
877 let test = TestRun {
878 id: None,
879 target: "mnw".to_string(),
880 started_at: "2026-03-10T00:00:00Z".to_string(),
881 finished_at: None,
882 duration_secs: None,
883 exit_code: Some(1),
884 passed: false,
885 summary: TestSummary {
886 steps: vec![],
887 total_passed: Some(80),
888 total_failed: Some(5),
889 details: vec![],
890 },
891 raw_output: String::new(),
892 filter: None,
893 };
894 let out = format_status_target(
895 "mnw",
896 "MakeNotWork",
897 None,
898 None,
899 None,
900 None,
901 None,
902 None,
903 Some(&test),
904 None,
905 None,
906 );
907 assert!(out.contains("Tests: FAILED"));
908 assert!(out.contains("80 passed, 5 failed"));
909 }
910
911 // format_status_target with TLS
912
913 #[test]
914 fn status_target_tls_ok() {
915 let tls = TlsCheckRow {
916 id: 1,
917 target: "mnw".to_string(),
918 host: "makenot.work".to_string(),
919 valid: true,
920 days_remaining: 47,
921 not_before: "2026-01-10T00:00:00Z".to_string(),
922 not_after: "2026-04-27T00:00:00Z".to_string(),
923 subject: "CN=makenot.work".to_string(),
924 issuer: "CN=Let's Encrypt".to_string(),
925 checked_at: "2026-03-11T00:00:00Z".to_string(),
926 error: None,
927 webpki_trusted: Some(true),
928 platform_trusted: Some(true),
929 webpki_error: None,
930 platform_error: None,
931 };
932 let out = format_status_target(
933 "mnw",
934 "MakeNotWork",
935 None,
936 None,
937 Some(&tls),
938 None,
939 None,
940 None,
941 None,
942 None,
943 None,
944 );
945 assert!(out.contains("TLS: [OK] makenot.work"));
946 assert!(out.contains("47d remaining"));
947 assert!(out.contains("expires 2026-04-27"));
948 }
949
950 #[test]
951 fn status_target_tls_warning() {
952 let tls = TlsCheckRow {
953 id: 1,
954 target: "mnw".to_string(),
955 host: "makenot.work".to_string(),
956 valid: true,
957 days_remaining: 12,
958 not_before: "2026-01-10T00:00:00Z".to_string(),
959 not_after: "2026-03-23T00:00:00Z".to_string(),
960 subject: "CN=makenot.work".to_string(),
961 issuer: "CN=Let's Encrypt".to_string(),
962 checked_at: "2026-03-11T00:00:00Z".to_string(),
963 error: None,
964 webpki_trusted: Some(true),
965 platform_trusted: Some(true),
966 webpki_error: None,
967 platform_error: None,
968 };
969 let out = format_status_target(
970 "mnw",
971 "MakeNotWork",
972 None,
973 None,
974 Some(&tls),
975 None,
976 None,
977 None,
978 None,
979 None,
980 None,
981 );
982 assert!(out.contains("TLS: [WARN] makenot.work"));
983 assert!(out.contains("12d remaining"));
984 }
985
986 #[test]
987 fn status_target_tls_error() {
988 let tls = TlsCheckRow {
989 id: 1,
990 target: "mnw".to_string(),
991 host: "makenot.work".to_string(),
992 valid: false,
993 days_remaining: 0,
994 not_before: String::new(),
995 not_after: String::new(),
996 subject: String::new(),
997 issuer: String::new(),
998 checked_at: "2026-03-11T00:00:00Z".to_string(),
999 error: Some("connection refused".to_string()),
1000 webpki_trusted: Some(false),
1001 platform_trusted: Some(false),
1002 webpki_error: Some("TCP connect failed".to_string()),
1003 platform_error: Some("TCP connect failed".to_string()),
1004 };
1005 let out = format_status_target(
1006 "mnw",
1007 "MakeNotWork",
1008 None,
1009 None,
1010 Some(&tls),
1011 None,
1012 None,
1013 None,
1014 None,
1015 None,
1016 None,
1017 );
1018 assert!(out.contains("TLS: [ERR] makenot.work"));
1019 assert!(out.contains("connection refused"));
1020 }
1021
1022 // format_status_target with incident
1023
1024 #[test]
1025 fn status_target_with_active_incident() {
1026 let incident = IncidentRow {
1027 id: 1,
1028 target: "mnw".to_string(),
1029 started_at: "2026-03-11T14:30:00Z".to_string(),
1030 ended_at: None,
1031 duration_secs: None,
1032 from_status: "operational".to_string(),
1033 to_status: "degraded".to_string(),
1034 };
1035 let out = format_status_target(
1036 "mnw",
1037 "MakeNotWork",
1038 None,
1039 None,
1040 None,
1041 None,
1042 None,
1043 None,
1044 None,
1045 None,
1046 Some(&incident),
1047 );
1048 assert!(out.contains("Incident: [ACTIVE] degraded since 2026-03-11T14:30:00Z"));
1049 }
1050
1051 #[test]
1052 fn status_target_no_incident() {
1053 let out = format_status_target(
1054 "mnw",
1055 "MakeNotWork",
1056 None,
1057 None,
1058 None,
1059 None,
1060 None,
1061 None,
1062 None,
1063 None,
1064 None,
1065 );
1066 assert!(!out.contains("Incident"));
1067 }
1068
1069 // format_status_target with latency
1070
1071 #[test]
1072 fn status_target_with_latency() {
1073 let latency = LatencyStats {
1074 min_ms: 95,
1075 max_ms: 210,
1076 avg_ms: 120.0,
1077 p95_ms: 180,
1078 sample_count: 288,
1079 };
1080 let out = format_status_target(
1081 "mnw",
1082 "MakeNotWork",
1083 None,
1084 Some(&latency),
1085 None,
1086 None,
1087 None,
1088 None,
1089 None,
1090 None,
1091 None,
1092 );
1093 assert!(out.contains("Latency (24h): avg 120ms, p95 180ms, range 95-210ms (288 samples)"));
1094 }
1095
1096 #[test]
1097 fn status_target_without_latency() {
1098 let out = format_status_target(
1099 "mnw",
1100 "MakeNotWork",
1101 None,
1102 None,
1103 None,
1104 None,
1105 None,
1106 None,
1107 None,
1108 None,
1109 None,
1110 );
1111 assert!(!out.contains("Latency"));
1112 }
1113
1114 // format_health_history
1115
1116 #[test]
1117 fn health_history_empty() {
1118 let out = format_health_history(&[]);
1119 assert_eq!(out, "No health check history.\n");
1120 }
1121
1122 #[test]
1123 fn health_history_with_entries() {
1124 let history = vec![
1125 HealthSnapshot {
1126 id: Some(2),
1127 target: "mnw".to_string(),
1128 status: HealthStatus::Operational,
1129 checked_at: "2026-03-10T01:00:00Z".to_string(),
1130 response_time_ms: 120,
1131 details: None,
1132 error: None,
1133 },
1134 HealthSnapshot {
1135 id: Some(1),
1136 target: "mnw".to_string(),
1137 status: HealthStatus::Degraded,
1138 checked_at: "2026-03-10T00:00:00Z".to_string(),
1139 response_time_ms: 2500,
1140 details: None,
1141 error: None,
1142 },
1143 ];
1144 let out = format_health_history(&history);
1145 assert!(out.contains("[OK] mnw"));
1146 assert!(out.contains("(120ms)"));
1147 assert!(out.contains("2026-03-10T01:00:00Z"));
1148 assert!(out.contains("[WARN] mnw"));
1149 assert!(out.contains("(2500ms)"));
1150 }
1151
1152 // format_test_history
1153
1154 #[test]
1155 fn test_history_empty() {
1156 let out = format_test_history(&[]);
1157 assert_eq!(out, "No test run history.\n");
1158 }
1159
1160 #[test]
1161 fn test_history_with_entries() {
1162 let history = vec![
1163 TestRun {
1164 id: Some(2),
1165 target: "mnw".to_string(),
1166 started_at: "2026-03-10T01:00:00Z".to_string(),
1167 finished_at: None,
1168 duration_secs: Some(120),
1169 exit_code: Some(0),
1170 passed: true,
1171 summary: TestSummary {
1172 steps: vec![],
1173 total_passed: Some(810),
1174 total_failed: Some(0),
1175 details: vec![],
1176 },
1177 raw_output: String::new(),
1178 filter: None,
1179 },
1180 TestRun {
1181 id: Some(1),
1182 target: "mnw".to_string(),
1183 started_at: "2026-03-10T00:00:00Z".to_string(),
1184 finished_at: None,
1185 duration_secs: None,
1186 exit_code: Some(1),
1187 passed: false,
1188 summary: TestSummary {
1189 steps: vec![],
1190 total_passed: None,
1191 total_failed: None,
1192 details: vec![],
1193 },
1194 raw_output: String::new(),
1195 filter: None,
1196 },
1197 ];
1198 let out = format_test_history(&history);
1199 assert!(out.contains("[PASS] mnw (120s) 2026-03-10T01:00:00Z"));
1200 assert!(out.contains("810 passed, 0 failed"));
1201 assert!(out.contains("[FAIL] mnw 2026-03-10T00:00:00Z"));
1202 }
1203
1204 #[test]
1205 fn test_history_no_duration_no_counts() {
1206 let history = vec![TestRun {
1207 id: Some(1),
1208 target: "svc".to_string(),
1209 started_at: "2026-03-10T00:00:00Z".to_string(),
1210 finished_at: None,
1211 duration_secs: None,
1212 exit_code: None,
1213 passed: true,
1214 summary: TestSummary {
1215 steps: vec![],
1216 total_passed: None,
1217 total_failed: None,
1218 details: vec![],
1219 },
1220 raw_output: String::new(),
1221 filter: None,
1222 }];
1223 let out = format_test_history(&history);
1224 // Should not have duration or counts
1225 assert!(!out.contains('('));
1226 assert!(out.contains("[PASS] svc 2026-03-10T00:00:00Z"));
1227 }
1228
1229 // format_prune
1230
1231 #[test]
1232 fn prune_formatting() {
1233 let result = PruneResult {
1234 health: 5,
1235 tests: 3,
1236 test_details: 15,
1237 heartbeats: 10,
1238 alerts: 2,
1239 tls: 1,
1240 incidents: 4,
1241 routes: 0,
1242 dns: 8,
1243 whois: 2,
1244 backups: 1,
1245 systemd: 6,
1246 ca_bundle: 2,
1247 synckit_fleet: 7,
1248 };
1249 let out = format_prune(&result, 30);
1250 assert_eq!(
1251 out,
1252 "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, 6 systemd checks, 2 CA bundle checks, 7 synckit fleet checks older than 30 days.\n"
1253 );
1254 }
1255
1256 #[test]
1257 fn prune_zero_records() {
1258 let result = PruneResult {
1259 health: 0,
1260 tests: 0,
1261 test_details: 0,
1262 heartbeats: 0,
1263 alerts: 0,
1264 tls: 0,
1265 incidents: 0,
1266 routes: 0,
1267 dns: 0,
1268 whois: 0,
1269 backups: 0,
1270 systemd: 0,
1271 ca_bundle: 0,
1272 synckit_fleet: 0,
1273 };
1274 let out = format_prune(&result, 7);
1275 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, 0 systemd checks, 0 CA bundle checks, 0 synckit fleet checks older than 7 days."));
1276 }
1277
1278 // format_mesh
1279
1280 #[test]
1281 fn mesh_no_instances() {
1282 let data = serde_json::json!({});
1283 let out = format_mesh(&data);
1284 assert_eq!(out, "No mesh data available.\n");
1285 }
1286
1287 #[test]
1288 fn mesh_empty_instances() {
1289 let data = serde_json::json!({ "instances": {} });
1290 let out = format_mesh(&data);
1291 // Empty map, no output lines beyond the empty string
1292 assert!(out.is_empty());
1293 }
1294
1295 #[test]
1296 fn mesh_single_instance_with_targets_and_peers() {
1297 let data = serde_json::json!({
1298 "instances": {
1299 "hetzner": {
1300 "instance": {
1301 "id": "uuid-123",
1302 "version": "0.2.0"
1303 },
1304 "targets": {
1305 "mnw": {
1306 "status": "operational",
1307 "response_time_ms": 95
1308 }
1309 },
1310 "peers": {
1311 "astra": {
1312 "status": "online",
1313 "latency_ms": 42
1314 }
1315 }
1316 }
1317 }
1318 });
1319 let out = format_mesh(&data);
1320 assert!(out.contains("=== hetzner ==="));
1321 assert!(out.contains("ID: uuid-123"));
1322 assert!(out.contains("Version: 0.2.0"));
1323 assert!(out.contains("Target mnw: operational (95ms)"));
1324 assert!(out.contains("Peer astra: online (42ms)"));
1325 }
1326
1327 #[test]
1328 fn mesh_missing_instance_details() {
1329 let data = serde_json::json!({
1330 "instances": {
1331 "node-1": {}
1332 }
1333 });
1334 let out = format_mesh(&data);
1335 assert!(out.contains("=== node-1 ==="));
1336 assert!(out.contains("ID: ?"));
1337 assert!(out.contains("Version: ?"));
1338 }
1339
1340 #[test]
1341 fn mesh_instance_with_error() {
1342 let data = serde_json::json!({
1343 "instances": {
1344 "node-2": {
1345 "error": "connection refused"
1346 }
1347 }
1348 });
1349 let out = format_mesh(&data);
1350 assert!(out.contains("=== node-2 ==="));
1351 assert!(out.contains("(connection refused)"));
1352 }
1353
1354 #[test]
1355 fn mesh_target_without_response_time() {
1356 let data = serde_json::json!({
1357 "instances": {
1358 "node": {
1359 "instance": { "id": "x", "version": "1.0" },
1360 "targets": {
1361 "svc": { "status": "unreachable" }
1362 }
1363 }
1364 }
1365 });
1366 let out = format_mesh(&data);
1367 assert!(out.contains("Target svc: unreachable"));
1368 // No (Xms) suffix
1369 assert!(!out.contains("Target svc: unreachable ("));
1370 }
1371
1372 #[test]
1373 fn mesh_peer_without_latency() {
1374 let data = serde_json::json!({
1375 "instances": {
1376 "node": {
1377 "instance": { "id": "x", "version": "1.0" },
1378 "peers": {
1379 "other": { "status": "missing" }
1380 }
1381 }
1382 }
1383 });
1384 let out = format_mesh(&data);
1385 assert!(out.contains("Peer other: missing"));
1386 // No (Xms) suffix
1387 assert!(!out.contains("Peer other: missing ("));
1388 }
1389
1390 #[test]
1391 fn mesh_multiple_instances() {
1392 let data = serde_json::json!({
1393 "instances": {
1394 "alpha": {
1395 "instance": { "id": "a1", "version": "0.1.0" }
1396 },
1397 "beta": {
1398 "instance": { "id": "b2", "version": "0.2.0" }
1399 }
1400 }
1401 });
1402 let out = format_mesh(&data);
1403 assert!(out.contains("=== alpha ==="));
1404 assert!(out.contains("=== beta ==="));
1405 assert!(out.contains("ID: a1"));
1406 assert!(out.contains("ID: b2"));
1407 }
1408
1409 // format_status_target with staleness
1410
1411 #[test]
1412 fn status_target_stale_by_version() {
1413 let staleness = TestStaleness {
1414 stale: true,
1415 reason: Some("version changed: 0.1.8 -> 0.1.9".to_string()),
1416 current_version: Some("0.1.9".to_string()),
1417 tested_version: Some("0.1.8".to_string()),
1418 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
1419 days_since_test: Some(1),
1420 };
1421 let out = format_status_target(
1422 "mnw",
1423 "MakeNotWork",
1424 None,
1425 None,
1426 None,
1427 None,
1428 None,
1429 None,
1430 None,
1431 Some(&staleness),
1432 None,
1433 );
1434 assert!(out.contains("Tests: STALE"));
1435 assert!(out.contains("version changed: 0.1.8 -> 0.1.9"));
1436 }
1437
1438 #[test]
1439 fn status_target_stale_by_age() {
1440 let staleness = TestStaleness {
1441 stale: true,
1442 reason: Some("tests are 10 days old (threshold: 7d)".to_string()),
1443 current_version: Some("0.1.9".to_string()),
1444 tested_version: Some("0.1.9".to_string()),
1445 last_test_at: Some("2026-03-01T00:00:00Z".to_string()),
1446 days_since_test: Some(10),
1447 };
1448 let out = format_status_target(
1449 "mnw",
1450 "MakeNotWork",
1451 None,
1452 None,
1453 None,
1454 None,
1455 None,
1456 None,
1457 None,
1458 Some(&staleness),
1459 None,
1460 );
1461 assert!(out.contains("Tests: STALE"));
1462 assert!(out.contains("tests are 10 days old"));
1463 }
1464
1465 #[test]
1466 fn status_target_not_stale() {
1467 let staleness = TestStaleness {
1468 stale: false,
1469 reason: None,
1470 current_version: Some("0.1.9".to_string()),
1471 tested_version: Some("0.1.9".to_string()),
1472 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
1473 days_since_test: Some(1),
1474 };
1475 let out = format_status_target(
1476 "mnw",
1477 "MakeNotWork",
1478 None,
1479 None,
1480 None,
1481 None,
1482 None,
1483 None,
1484 None,
1485 Some(&staleness),
1486 None,
1487 );
1488 assert!(!out.contains("STALE"));
1489 }
1490
1491 #[test]
1492 fn status_target_no_staleness_data() {
1493 let out = format_status_target(
1494 "mnw",
1495 "MakeNotWork",
1496 None,
1497 None,
1498 None,
1499 None,
1500 None,
1501 None,
1502 None,
1503 None,
1504 None,
1505 );
1506 assert!(!out.contains("STALE"));
1507 }
1508
1509 // format_status_target with routes
1510
1511 #[test]
1512 fn status_target_all_routes_ok() {
1513 let checks = vec![
1514 RouteCheckRow {
1515 id: 1,
1516 target: "mnw".to_string(),
1517 path: "/".to_string(),
1518 status_code: 200,
1519 ok: true,
1520 response_time_ms: 50,
1521 checked_at: "2026-03-13T00:00:00Z".to_string(),
1522 error: None,
1523 },
1524 RouteCheckRow {
1525 id: 2,
1526 target: "mnw".to_string(),
1527 path: "/docs".to_string(),
1528 status_code: 200,
1529 ok: true,
1530 response_time_ms: 60,
1531 checked_at: "2026-03-13T00:00:00Z".to_string(),
1532 error: None,
1533 },
1534 ];
1535 let out = format_status_target(
1536 "mnw",
1537 "MakeNotWork",
1538 None,
1539 None,
1540 None,
1541 Some(&checks),
1542 None,
1543 None,
1544 None,
1545 None,
1546 None,
1547 );
1548 assert!(out.contains("Routes: 2/2 OK"));
1549 }
1550
1551 #[test]
1552 fn status_target_some_routes_failing() {
1553 let checks = vec![
1554 RouteCheckRow {
1555 id: 1,
1556 target: "mnw".to_string(),
1557 path: "/".to_string(),
1558 status_code: 200,
1559 ok: true,
1560 response_time_ms: 50,
1561 checked_at: "2026-03-13T00:00:00Z".to_string(),
1562 error: None,
1563 },
1564 RouteCheckRow {
1565 id: 2,
1566 target: "mnw".to_string(),
1567 path: "/docs/faq".to_string(),
1568 status_code: 404,
1569 ok: false,
1570 response_time_ms: 30,
1571 checked_at: "2026-03-13T00:00:00Z".to_string(),
1572 error: Some("HTTP 404".to_string()),
1573 },
1574 RouteCheckRow {
1575 id: 3,
1576 target: "mnw".to_string(),
1577 path: "/pricing".to_string(),
1578 status_code: 500,
1579 ok: false,
1580 response_time_ms: 20,
1581 checked_at: "2026-03-13T00:00:00Z".to_string(),
1582 error: Some("HTTP 500".to_string()),
1583 },
1584 ];
1585 let out = format_status_target(
1586 "mnw",
1587 "MakeNotWork",
1588 None,
1589 None,
1590 None,
1591 Some(&checks),
1592 None,
1593 None,
1594 None,
1595 None,
1596 None,
1597 );
1598 assert!(out.contains("Routes: 1/3 (FAIL: /docs/faq, /pricing)"));
1599 }
1600
1601 // format_versions
1602
1603 fn version_row(target: &str, version: Option<&str>, sha: Option<&str>) -> VersionRow {
1604 VersionRow {
1605 target: target.to_string(),
1606 label: format!("{target} label"),
1607 version: version.map(String::from),
1608 git_sha: sha.map(String::from),
1609 checked_at: Some("2026-07-29T18:04:37.123456+00:00".to_string()),
1610 version_since: Some("2026-07-28T09:00:00+00:00".to_string()),
1611 commits_behind: Some(8),
1612 behind_error: None,
1613 }
1614 }
1615
1616 #[test]
1617 fn versions_table_aligns_columns_and_shortens_the_sha() {
1618 let rows = vec![
1619 version_row("mnw", Some("0.11.0"), Some("6402bf4e9c1d2e3f4a5b")),
1620 version_row("multithreaded", Some("0.4.2"), Some("aaaabbbbcccc")),
1621 ];
1622 let out = format_versions(&rows);
1623 let lines: Vec<&str> = out.lines().collect();
1624
1625 assert!(lines[0].starts_with("TARGET"));
1626 // The header pads to the widest target, so every column starts at the
1627 // same offset on every line.
1628 let version_col = lines[0].find("VERSION").unwrap();
1629 assert_eq!(lines[1].find("0.11.0"), Some(version_col));
1630 assert_eq!(lines[2].find("0.4.2"), Some(version_col));
1631
1632 assert!(out.contains("6402bf4e "), "sha shortened to 8: {out}");
1633 assert!(!out.contains("6402bf4e9c1d"));
1634 assert!(
1635 out.contains("2026-07-29 18:04"),
1636 "timestamp to the minute: {out}"
1637 );
1638 }
1639
1640 #[test]
1641 fn versions_table_prints_a_dash_for_every_missing_value() {
1642 let rows = vec![VersionRow {
1643 target: "mt".to_string(),
1644 label: "Multithreaded".to_string(),
1645 version: None,
1646 git_sha: None,
1647 checked_at: None,
1648 version_since: None,
1649 commits_behind: None,
1650 behind_error: None,
1651 }];
1652 let out = format_versions(&rows);
1653 let row = out.lines().nth(1).unwrap();
1654 assert_eq!(
1655 row.split_whitespace().collect::<Vec<_>>(),
1656 ["mt", "-", "-", "-", "-", "-"]
1657 );
1658 }
1659
1660 #[test]
1661 fn versions_table_says_why_a_count_is_blank() {
1662 let mut row = version_row("mnw", Some("0.11.0"), Some("6402bf4e"));
1663 row.commits_behind = None;
1664 row.behind_error = Some("git rev-list exited 128: bad revision".to_string());
1665 let out = format_versions(&[row]);
1666 assert!(out.contains("behind: git rev-list exited 128"), "{out}");
1667 }
1668
1669 #[test]
1670 fn versions_table_scrubs_a_hostile_version_string() {
1671 // Same terminal-injection surface as every other display sink: the
1672 // version and sha are whatever the monitored target chose to send.
1673 let mut row = version_row("mnw", Some("1.0\u{1b}[2J FAKE"), Some("6402bf4e"));
1674 row.behind_error = Some("boom\u{1b}[2J".to_string());
1675 let out = format_versions(&[row]);
1676 assert!(!out.contains('\u{1b}'), "ESC must not reach the terminal");
1677 }
1678
1679 #[test]
1680 fn versions_empty_config_says_so() {
1681 assert_eq!(format_versions(&[]), "No targets configured.\n");
1682 }
1683
1684 #[test]
1685 fn minute_stamp_passes_through_anything_that_is_not_a_timestamp() {
1686 assert_eq!(minute_stamp("2026-07-29T18:04:37Z"), "2026-07-29 18:04");
1687 assert_eq!(minute_stamp("whenever"), "whenever");
1688 assert_eq!(minute_stamp("2026-07-29 18:04:37"), "2026-07-29 18:04:37");
1689 }
1690
1691 #[test]
1692 fn status_target_no_route_checks() {
1693 let out = format_status_target(
1694 "mnw",
1695 "MakeNotWork",
1696 None,
1697 None,
1698 None,
1699 None,
1700 None,
1701 None,
1702 None,
1703 None,
1704 None,
1705 );
1706 assert!(!out.contains("Routes"));
1707 }
1708 }
1709