Skip to main content

max / makenotwork

52.5 KB · 1695 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` (fuzz-2026-07-06
20 /// ANSI/terminal injection). Every such value passes through here at the display
21 /// sink. Keeps printable UTF-8; drops C0/C1 controls (incl. ESC, CR, LF) and DEL.
22 fn scrub(s: &str) -> String {
23 s.chars().filter(|c| !c.is_control()).collect()
24 }
25
26 /// Format a single health snapshot as a human-readable line.
27 pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
28 let mut out = String::new();
29 write!(out, "[{}] {}: {}", s.status.icon(), s.target, s.status).unwrap();
30 write!(out, " ({}ms)", s.response_time_ms).unwrap();
31 if let Some(details) = &s.details {
32 if let Some(v) = &details.version {
33 write!(out, " v{}", scrub(v)).unwrap();
34 }
35 if let Some(u) = &details.uptime {
36 write!(out, " up {}", scrub(u)).unwrap();
37 }
38 }
39 writeln!(out).unwrap();
40 if let Some(err) = &s.error {
41 writeln!(out, " {}", scrub(err)).unwrap();
42 }
43 out
44 }
45
46 /// Format a list of health snapshots for CLI display.
47 pub fn format_health_snapshots(snapshots: &[HealthSnapshot]) -> String {
48 let mut out = String::new();
49 for s in snapshots {
50 out.push_str(&format_health_snapshot(s));
51 }
52 out
53 }
54
55 /// Format a test run result for CLI display.
56 pub fn format_test_result(target_name: &str, run: &TestRun) -> String {
57 let mut out = String::new();
58 let result = if run.passed { "PASSED" } else { "FAILED" };
59 writeln!(out, "{target_name}: {result}").unwrap();
60 if let Some(d) = run.duration_secs {
61 writeln!(out, "Duration: {d}s").unwrap();
62 }
63 if let (Some(p), Some(f)) = (run.summary.total_passed, run.summary.total_failed) {
64 writeln!(out, "Tests: {p} passed, {f} failed").unwrap();
65 }
66 for step in &run.summary.steps {
67 let mark = if step.passed { "PASS" } else { "FAIL" };
68 writeln!(out, " {mark} {}", step.name).unwrap();
69 }
70 if !run.passed {
71 writeln!(out, "\nRaw output:\n{}", run.raw_output).unwrap();
72 }
73 out
74 }
75
76 /// Format a single target's status block for CLI display.
77 #[allow(clippy::too_many_arguments)]
78 pub fn format_status_target(
79 name: &str,
80 label: &str,
81 health: Option<&HealthSnapshot>,
82 latency: Option<&LatencyStats>,
83 tls: Option<&TlsCheckRow>,
84 route_checks: Option<&[RouteCheckRow]>,
85 dns_checks: Option<&[DnsCheckRow]>,
86 whois: Option<&WhoisCheckRow>,
87 test: Option<&TestRun>,
88 staleness: Option<&TestStaleness>,
89 incident: Option<&IncidentRow>,
90 ) -> String {
91 let mut out = String::new();
92 writeln!(out, "=== {name} ({label}) ===").unwrap();
93
94 if let Some(h) = health {
95 write!(out, " Health: [{}] {}", h.status.icon(), h.status).unwrap();
96 write!(out, " ({}ms)", h.response_time_ms).unwrap();
97 if let Some(d) = &h.details
98 && let Some(v) = &d.version
99 {
100 write!(out, " v{}", scrub(v)).unwrap();
101 }
102 writeln!(out).unwrap();
103 } else {
104 writeln!(out, " Health: no data").unwrap();
105 }
106
107 if let Some(l) = latency {
108 writeln!(
109 out,
110 " Latency (24h): avg {:.0}ms, p95 {}ms, range {}-{}ms ({} samples)",
111 l.avg_ms, l.p95_ms, l.min_ms, l.max_ms, l.sample_count
112 )
113 .unwrap();
114 }
115
116 if let Some(t) = tls {
117 if let Some(ref err) = t.error {
118 writeln!(out, " TLS: [ERR] {}: {}", t.host, scrub(err)).unwrap();
119 } else if t.days_remaining <= 0 {
120 writeln!(
121 out,
122 " TLS: [ERR] {}: EXPIRED (expired {})",
123 t.host, t.not_after
124 )
125 .unwrap();
126 } else if t.days_remaining <= 14 {
127 writeln!(
128 out,
129 " TLS: [WARN] {}: {}d remaining (expires {})",
130 t.host, t.days_remaining, t.not_after
131 )
132 .unwrap();
133 } else {
134 writeln!(
135 out,
136 " TLS: [OK] {}: {}d remaining (expires {})",
137 t.host, t.days_remaining, t.not_after
138 )
139 .unwrap();
140 }
141 }
142
143 if let Some(checks) = route_checks
144 && !checks.is_empty()
145 {
146 let total = checks.len();
147 let ok_count = checks.iter().filter(|c| c.ok).count();
148 if ok_count == total {
149 writeln!(out, " Routes: {ok_count}/{total} OK").unwrap();
150 } else {
151 let failed: Vec<&str> = checks
152 .iter()
153 .filter(|c| !c.ok)
154 .map(|c| c.path.as_str())
155 .collect();
156 writeln!(
157 out,
158 " Routes: {ok_count}/{total} (FAIL: {})",
159 failed.join(", ")
160 )
161 .unwrap();
162 }
163 }
164
165 if let Some(checks) = dns_checks
166 && !checks.is_empty()
167 {
168 let total = checks.len();
169 let ok_count = checks.iter().filter(|c| c.matches).count();
170 if ok_count == total {
171 writeln!(out, " DNS: {ok_count}/{total} match").unwrap();
172 } else {
173 let failed: Vec<String> = checks
174 .iter()
175 .filter(|c| !c.matches)
176 .map(|c| format!("{} {}", c.name, c.record_type))
177 .collect();
178 writeln!(
179 out,
180 " DNS: {ok_count}/{total} (MISMATCH: {})",
181 failed.join(", ")
182 )
183 .unwrap();
184 }
185 }
186
187 if let Some(w) = whois {
188 if let Some(ref err) = w.error {
189 writeln!(out, " WHOIS: [ERR] {}: {}", w.domain, scrub(err)).unwrap();
190 } else if let Some(days) = w.days_remaining {
191 if days <= 0 {
192 writeln!(out, " WHOIS: [ERR] {}: EXPIRED", w.domain).unwrap();
193 } else if days <= 30 {
194 writeln!(out, " WHOIS: [WARN] {}: {}d remaining", w.domain, days).unwrap();
195 } else {
196 writeln!(out, " WHOIS: [OK] {}: {}d remaining", w.domain, days).unwrap();
197 }
198 }
199 }
200
201 if let Some(t) = test {
202 let result = if t.passed { "PASSED" } else { "FAILED" };
203 write!(out, " Tests: {result}").unwrap();
204 if let Some(d) = t.duration_secs {
205 write!(out, " ({d}s)").unwrap();
206 }
207 writeln!(out).unwrap();
208 if let (Some(p), Some(f)) = (t.summary.total_passed, t.summary.total_failed) {
209 writeln!(out, " {p} passed, {f} failed").unwrap();
210 }
211 } else {
212 writeln!(out, " Tests: no data").unwrap();
213 }
214
215 if let Some(s) = staleness
216 && s.stale
217 && let Some(reason) = &s.reason
218 {
219 writeln!(out, " Tests: STALE ({})", scrub(reason)).unwrap();
220 }
221
222 if let Some(inc) = incident {
223 writeln!(
224 out,
225 " Incident: [ACTIVE] {} since {}",
226 inc.to_status, inc.started_at
227 )
228 .unwrap();
229 }
230
231 writeln!(out).unwrap();
232 out
233 }
234
235 /// Format health check history for CLI display.
236 pub fn format_health_history(history: &[HealthSnapshot]) -> String {
237 if history.is_empty() {
238 return "No health check history.\n".to_string();
239 }
240 let mut out = String::new();
241 for h in history {
242 writeln!(
243 out,
244 "[{}] {}: {} ({}ms) {}",
245 h.status.icon(),
246 h.target,
247 h.status,
248 h.response_time_ms,
249 h.checked_at
250 )
251 .unwrap();
252 }
253 out
254 }
255
256 /// Format test run history for CLI display.
257 pub fn format_test_history(history: &[TestRun]) -> String {
258 if history.is_empty() {
259 return "No test run history.\n".to_string();
260 }
261 let mut out = String::new();
262 for r in history {
263 let result = if r.passed { "PASS" } else { "FAIL" };
264 write!(out, "[{result}] {}", r.target).unwrap();
265 if let Some(d) = r.duration_secs {
266 write!(out, " ({d}s)").unwrap();
267 }
268 write!(out, " {}", r.started_at).unwrap();
269 if let (Some(p), Some(f)) = (r.summary.total_passed, r.summary.total_failed) {
270 write!(out, " ({p} passed, {f} failed)").unwrap();
271 }
272 writeln!(out).unwrap();
273 }
274 out
275 }
276
277 /// Format regression warnings for CLI display.
278 pub fn format_regressions(regressions: &[String]) -> String {
279 let mut out = String::new();
280 writeln!(out, "\nREGRESSIONS (passed last run, failed now):").unwrap();
281 for name in regressions {
282 writeln!(out, " {name}").unwrap();
283 }
284 out
285 }
286
287 /// Format test duration trend for CLI display.
288 pub fn format_test_duration_trend(durations: &[(String, i64)], drift: Option<&str>) -> String {
289 let mut out = String::new();
290 if !durations.is_empty() {
291 write!(out, " Duration trend (last {}): ", durations.len()).unwrap();
292 let strs: Vec<String> = durations.iter().map(|(_, d)| format!("{d}s")).collect();
293 writeln!(out, "{}", strs.join(", ")).unwrap();
294 }
295 if let Some(msg) = drift {
296 writeln!(out, " DRIFT: {msg}").unwrap();
297 }
298 out
299 }
300
301 /// Format DNS check results and WHOIS results for CLI display.
302 pub fn format_dns_results(dns_results: &[DnsCheckResult], whois_results: &[WhoisResult]) -> String {
303 let mut out = String::new();
304
305 if !dns_results.is_empty() {
306 writeln!(out, "DNS Records:").unwrap();
307 for r in dns_results {
308 if let Some(ref err) = r.error {
309 writeln!(out, " [ERR] {} {}: {}", r.name, r.record_type, scrub(err)).unwrap();
310 } else if r.matches {
311 writeln!(out, " [OK] {} {}: {:?}", r.name, r.record_type, r.actual).unwrap();
312 } else {
313 writeln!(
314 out,
315 " [FAIL] {} {}: expected {:?}, got {:?}",
316 r.name, r.record_type, r.expected, r.actual
317 )
318 .unwrap();
319 }
320 }
321 }
322
323 if !whois_results.is_empty() {
324 if !dns_results.is_empty() {
325 writeln!(out).unwrap();
326 }
327 writeln!(out, "WHOIS:").unwrap();
328 for w in whois_results {
329 if let Some(ref err) = w.error {
330 writeln!(out, " [ERR] {}: {}", w.domain, scrub(err)).unwrap();
331 } else {
332 let days_str = w.days_remaining.map_or_else(
333 || "expiry unknown".to_string(),
334 |d| format!("{d}d remaining"),
335 );
336 let registrar_str = w.registrar.as_deref().unwrap_or("unknown registrar");
337 writeln!(out, " [OK] {}: {days_str} ({registrar_str})", w.domain).unwrap();
338 }
339 }
340 }
341
342 out
343 }
344
345 /// Format prune results for CLI display.
346 pub fn format_prune(result: &PruneResult, days: i64) -> String {
347 format!(
348 "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks, {} systemd checks, {} synckit fleet checks older than {} days.\n",
349 result.health,
350 result.tests,
351 result.test_details,
352 result.heartbeats,
353 result.alerts,
354 result.tls,
355 result.incidents,
356 result.routes,
357 result.dns,
358 result.whois,
359 result.backups,
360 result.systemd,
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 };
928 let out = format_status_target(
929 "mnw",
930 "MakeNotWork",
931 None,
932 None,
933 Some(&tls),
934 None,
935 None,
936 None,
937 None,
938 None,
939 None,
940 );
941 assert!(out.contains("TLS: [OK] makenot.work"));
942 assert!(out.contains("47d remaining"));
943 assert!(out.contains("expires 2026-04-27"));
944 }
945
946 #[test]
947 fn status_target_tls_warning() {
948 let tls = TlsCheckRow {
949 id: 1,
950 target: "mnw".to_string(),
951 host: "makenot.work".to_string(),
952 valid: true,
953 days_remaining: 12,
954 not_before: "2026-01-10T00:00:00Z".to_string(),
955 not_after: "2026-03-23T00:00:00Z".to_string(),
956 subject: "CN=makenot.work".to_string(),
957 issuer: "CN=Let's Encrypt".to_string(),
958 checked_at: "2026-03-11T00:00:00Z".to_string(),
959 error: None,
960 };
961 let out = format_status_target(
962 "mnw",
963 "MakeNotWork",
964 None,
965 None,
966 Some(&tls),
967 None,
968 None,
969 None,
970 None,
971 None,
972 None,
973 );
974 assert!(out.contains("TLS: [WARN] makenot.work"));
975 assert!(out.contains("12d remaining"));
976 }
977
978 #[test]
979 fn status_target_tls_error() {
980 let tls = TlsCheckRow {
981 id: 1,
982 target: "mnw".to_string(),
983 host: "makenot.work".to_string(),
984 valid: false,
985 days_remaining: 0,
986 not_before: String::new(),
987 not_after: String::new(),
988 subject: String::new(),
989 issuer: String::new(),
990 checked_at: "2026-03-11T00:00:00Z".to_string(),
991 error: Some("connection refused".to_string()),
992 };
993 let out = format_status_target(
994 "mnw",
995 "MakeNotWork",
996 None,
997 None,
998 Some(&tls),
999 None,
1000 None,
1001 None,
1002 None,
1003 None,
1004 None,
1005 );
1006 assert!(out.contains("TLS: [ERR] makenot.work"));
1007 assert!(out.contains("connection refused"));
1008 }
1009
1010 // format_status_target with incident
1011
1012 #[test]
1013 fn status_target_with_active_incident() {
1014 let incident = IncidentRow {
1015 id: 1,
1016 target: "mnw".to_string(),
1017 started_at: "2026-03-11T14:30:00Z".to_string(),
1018 ended_at: None,
1019 duration_secs: None,
1020 from_status: "operational".to_string(),
1021 to_status: "degraded".to_string(),
1022 };
1023 let out = format_status_target(
1024 "mnw",
1025 "MakeNotWork",
1026 None,
1027 None,
1028 None,
1029 None,
1030 None,
1031 None,
1032 None,
1033 None,
1034 Some(&incident),
1035 );
1036 assert!(out.contains("Incident: [ACTIVE] degraded since 2026-03-11T14:30:00Z"));
1037 }
1038
1039 #[test]
1040 fn status_target_no_incident() {
1041 let out = format_status_target(
1042 "mnw",
1043 "MakeNotWork",
1044 None,
1045 None,
1046 None,
1047 None,
1048 None,
1049 None,
1050 None,
1051 None,
1052 None,
1053 );
1054 assert!(!out.contains("Incident"));
1055 }
1056
1057 // format_status_target with latency
1058
1059 #[test]
1060 fn status_target_with_latency() {
1061 let latency = LatencyStats {
1062 min_ms: 95,
1063 max_ms: 210,
1064 avg_ms: 120.0,
1065 p95_ms: 180,
1066 sample_count: 288,
1067 };
1068 let out = format_status_target(
1069 "mnw",
1070 "MakeNotWork",
1071 None,
1072 Some(&latency),
1073 None,
1074 None,
1075 None,
1076 None,
1077 None,
1078 None,
1079 None,
1080 );
1081 assert!(out.contains("Latency (24h): avg 120ms, p95 180ms, range 95-210ms (288 samples)"));
1082 }
1083
1084 #[test]
1085 fn status_target_without_latency() {
1086 let out = format_status_target(
1087 "mnw",
1088 "MakeNotWork",
1089 None,
1090 None,
1091 None,
1092 None,
1093 None,
1094 None,
1095 None,
1096 None,
1097 None,
1098 );
1099 assert!(!out.contains("Latency"));
1100 }
1101
1102 // format_health_history
1103
1104 #[test]
1105 fn health_history_empty() {
1106 let out = format_health_history(&[]);
1107 assert_eq!(out, "No health check history.\n");
1108 }
1109
1110 #[test]
1111 fn health_history_with_entries() {
1112 let history = vec![
1113 HealthSnapshot {
1114 id: Some(2),
1115 target: "mnw".to_string(),
1116 status: HealthStatus::Operational,
1117 checked_at: "2026-03-10T01:00:00Z".to_string(),
1118 response_time_ms: 120,
1119 details: None,
1120 error: None,
1121 },
1122 HealthSnapshot {
1123 id: Some(1),
1124 target: "mnw".to_string(),
1125 status: HealthStatus::Degraded,
1126 checked_at: "2026-03-10T00:00:00Z".to_string(),
1127 response_time_ms: 2500,
1128 details: None,
1129 error: None,
1130 },
1131 ];
1132 let out = format_health_history(&history);
1133 assert!(out.contains("[OK] mnw"));
1134 assert!(out.contains("(120ms)"));
1135 assert!(out.contains("2026-03-10T01:00:00Z"));
1136 assert!(out.contains("[WARN] mnw"));
1137 assert!(out.contains("(2500ms)"));
1138 }
1139
1140 // format_test_history
1141
1142 #[test]
1143 fn test_history_empty() {
1144 let out = format_test_history(&[]);
1145 assert_eq!(out, "No test run history.\n");
1146 }
1147
1148 #[test]
1149 fn test_history_with_entries() {
1150 let history = vec![
1151 TestRun {
1152 id: Some(2),
1153 target: "mnw".to_string(),
1154 started_at: "2026-03-10T01:00:00Z".to_string(),
1155 finished_at: None,
1156 duration_secs: Some(120),
1157 exit_code: Some(0),
1158 passed: true,
1159 summary: TestSummary {
1160 steps: vec![],
1161 total_passed: Some(810),
1162 total_failed: Some(0),
1163 details: vec![],
1164 },
1165 raw_output: String::new(),
1166 filter: None,
1167 },
1168 TestRun {
1169 id: Some(1),
1170 target: "mnw".to_string(),
1171 started_at: "2026-03-10T00:00:00Z".to_string(),
1172 finished_at: None,
1173 duration_secs: None,
1174 exit_code: Some(1),
1175 passed: false,
1176 summary: TestSummary {
1177 steps: vec![],
1178 total_passed: None,
1179 total_failed: None,
1180 details: vec![],
1181 },
1182 raw_output: String::new(),
1183 filter: None,
1184 },
1185 ];
1186 let out = format_test_history(&history);
1187 assert!(out.contains("[PASS] mnw (120s) 2026-03-10T01:00:00Z"));
1188 assert!(out.contains("810 passed, 0 failed"));
1189 assert!(out.contains("[FAIL] mnw 2026-03-10T00:00:00Z"));
1190 }
1191
1192 #[test]
1193 fn test_history_no_duration_no_counts() {
1194 let history = vec![TestRun {
1195 id: Some(1),
1196 target: "svc".to_string(),
1197 started_at: "2026-03-10T00:00:00Z".to_string(),
1198 finished_at: None,
1199 duration_secs: None,
1200 exit_code: None,
1201 passed: true,
1202 summary: TestSummary {
1203 steps: vec![],
1204 total_passed: None,
1205 total_failed: None,
1206 details: vec![],
1207 },
1208 raw_output: String::new(),
1209 filter: None,
1210 }];
1211 let out = format_test_history(&history);
1212 // Should not have duration or counts
1213 assert!(!out.contains('('));
1214 assert!(out.contains("[PASS] svc 2026-03-10T00:00:00Z"));
1215 }
1216
1217 // format_prune
1218
1219 #[test]
1220 fn prune_formatting() {
1221 let result = PruneResult {
1222 health: 5,
1223 tests: 3,
1224 test_details: 15,
1225 heartbeats: 10,
1226 alerts: 2,
1227 tls: 1,
1228 incidents: 4,
1229 routes: 0,
1230 dns: 8,
1231 whois: 2,
1232 backups: 1,
1233 systemd: 6,
1234 synckit_fleet: 7,
1235 };
1236 let out = format_prune(&result, 30);
1237 assert_eq!(
1238 out,
1239 "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, 7 synckit fleet checks older than 30 days.\n"
1240 );
1241 }
1242
1243 #[test]
1244 fn prune_zero_records() {
1245 let result = PruneResult {
1246 health: 0,
1247 tests: 0,
1248 test_details: 0,
1249 heartbeats: 0,
1250 alerts: 0,
1251 tls: 0,
1252 incidents: 0,
1253 routes: 0,
1254 dns: 0,
1255 whois: 0,
1256 backups: 0,
1257 systemd: 0,
1258 synckit_fleet: 0,
1259 };
1260 let out = format_prune(&result, 7);
1261 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 synckit fleet checks older than 7 days."));
1262 }
1263
1264 // format_mesh
1265
1266 #[test]
1267 fn mesh_no_instances() {
1268 let data = serde_json::json!({});
1269 let out = format_mesh(&data);
1270 assert_eq!(out, "No mesh data available.\n");
1271 }
1272
1273 #[test]
1274 fn mesh_empty_instances() {
1275 let data = serde_json::json!({ "instances": {} });
1276 let out = format_mesh(&data);
1277 // Empty map, no output lines beyond the empty string
1278 assert!(out.is_empty());
1279 }
1280
1281 #[test]
1282 fn mesh_single_instance_with_targets_and_peers() {
1283 let data = serde_json::json!({
1284 "instances": {
1285 "hetzner": {
1286 "instance": {
1287 "id": "uuid-123",
1288 "version": "0.2.0"
1289 },
1290 "targets": {
1291 "mnw": {
1292 "status": "operational",
1293 "response_time_ms": 95
1294 }
1295 },
1296 "peers": {
1297 "astra": {
1298 "status": "online",
1299 "latency_ms": 42
1300 }
1301 }
1302 }
1303 }
1304 });
1305 let out = format_mesh(&data);
1306 assert!(out.contains("=== hetzner ==="));
1307 assert!(out.contains("ID: uuid-123"));
1308 assert!(out.contains("Version: 0.2.0"));
1309 assert!(out.contains("Target mnw: operational (95ms)"));
1310 assert!(out.contains("Peer astra: online (42ms)"));
1311 }
1312
1313 #[test]
1314 fn mesh_missing_instance_details() {
1315 let data = serde_json::json!({
1316 "instances": {
1317 "node-1": {}
1318 }
1319 });
1320 let out = format_mesh(&data);
1321 assert!(out.contains("=== node-1 ==="));
1322 assert!(out.contains("ID: ?"));
1323 assert!(out.contains("Version: ?"));
1324 }
1325
1326 #[test]
1327 fn mesh_instance_with_error() {
1328 let data = serde_json::json!({
1329 "instances": {
1330 "node-2": {
1331 "error": "connection refused"
1332 }
1333 }
1334 });
1335 let out = format_mesh(&data);
1336 assert!(out.contains("=== node-2 ==="));
1337 assert!(out.contains("(connection refused)"));
1338 }
1339
1340 #[test]
1341 fn mesh_target_without_response_time() {
1342 let data = serde_json::json!({
1343 "instances": {
1344 "node": {
1345 "instance": { "id": "x", "version": "1.0" },
1346 "targets": {
1347 "svc": { "status": "unreachable" }
1348 }
1349 }
1350 }
1351 });
1352 let out = format_mesh(&data);
1353 assert!(out.contains("Target svc: unreachable"));
1354 // No (Xms) suffix
1355 assert!(!out.contains("Target svc: unreachable ("));
1356 }
1357
1358 #[test]
1359 fn mesh_peer_without_latency() {
1360 let data = serde_json::json!({
1361 "instances": {
1362 "node": {
1363 "instance": { "id": "x", "version": "1.0" },
1364 "peers": {
1365 "other": { "status": "missing" }
1366 }
1367 }
1368 }
1369 });
1370 let out = format_mesh(&data);
1371 assert!(out.contains("Peer other: missing"));
1372 // No (Xms) suffix
1373 assert!(!out.contains("Peer other: missing ("));
1374 }
1375
1376 #[test]
1377 fn mesh_multiple_instances() {
1378 let data = serde_json::json!({
1379 "instances": {
1380 "alpha": {
1381 "instance": { "id": "a1", "version": "0.1.0" }
1382 },
1383 "beta": {
1384 "instance": { "id": "b2", "version": "0.2.0" }
1385 }
1386 }
1387 });
1388 let out = format_mesh(&data);
1389 assert!(out.contains("=== alpha ==="));
1390 assert!(out.contains("=== beta ==="));
1391 assert!(out.contains("ID: a1"));
1392 assert!(out.contains("ID: b2"));
1393 }
1394
1395 // format_status_target with staleness
1396
1397 #[test]
1398 fn status_target_stale_by_version() {
1399 let staleness = TestStaleness {
1400 stale: true,
1401 reason: Some("version changed: 0.1.8 -> 0.1.9".to_string()),
1402 current_version: Some("0.1.9".to_string()),
1403 tested_version: Some("0.1.8".to_string()),
1404 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
1405 days_since_test: Some(1),
1406 };
1407 let out = format_status_target(
1408 "mnw",
1409 "MakeNotWork",
1410 None,
1411 None,
1412 None,
1413 None,
1414 None,
1415 None,
1416 None,
1417 Some(&staleness),
1418 None,
1419 );
1420 assert!(out.contains("Tests: STALE"));
1421 assert!(out.contains("version changed: 0.1.8 -> 0.1.9"));
1422 }
1423
1424 #[test]
1425 fn status_target_stale_by_age() {
1426 let staleness = TestStaleness {
1427 stale: true,
1428 reason: Some("tests are 10 days old (threshold: 7d)".to_string()),
1429 current_version: Some("0.1.9".to_string()),
1430 tested_version: Some("0.1.9".to_string()),
1431 last_test_at: Some("2026-03-01T00:00:00Z".to_string()),
1432 days_since_test: Some(10),
1433 };
1434 let out = format_status_target(
1435 "mnw",
1436 "MakeNotWork",
1437 None,
1438 None,
1439 None,
1440 None,
1441 None,
1442 None,
1443 None,
1444 Some(&staleness),
1445 None,
1446 );
1447 assert!(out.contains("Tests: STALE"));
1448 assert!(out.contains("tests are 10 days old"));
1449 }
1450
1451 #[test]
1452 fn status_target_not_stale() {
1453 let staleness = TestStaleness {
1454 stale: false,
1455 reason: None,
1456 current_version: Some("0.1.9".to_string()),
1457 tested_version: Some("0.1.9".to_string()),
1458 last_test_at: Some("2026-03-10T00:00:00Z".to_string()),
1459 days_since_test: Some(1),
1460 };
1461 let out = format_status_target(
1462 "mnw",
1463 "MakeNotWork",
1464 None,
1465 None,
1466 None,
1467 None,
1468 None,
1469 None,
1470 None,
1471 Some(&staleness),
1472 None,
1473 );
1474 assert!(!out.contains("STALE"));
1475 }
1476
1477 #[test]
1478 fn status_target_no_staleness_data() {
1479 let out = format_status_target(
1480 "mnw",
1481 "MakeNotWork",
1482 None,
1483 None,
1484 None,
1485 None,
1486 None,
1487 None,
1488 None,
1489 None,
1490 None,
1491 );
1492 assert!(!out.contains("STALE"));
1493 }
1494
1495 // format_status_target with routes
1496
1497 #[test]
1498 fn status_target_all_routes_ok() {
1499 let checks = vec![
1500 RouteCheckRow {
1501 id: 1,
1502 target: "mnw".to_string(),
1503 path: "/".to_string(),
1504 status_code: 200,
1505 ok: true,
1506 response_time_ms: 50,
1507 checked_at: "2026-03-13T00:00:00Z".to_string(),
1508 error: None,
1509 },
1510 RouteCheckRow {
1511 id: 2,
1512 target: "mnw".to_string(),
1513 path: "/docs".to_string(),
1514 status_code: 200,
1515 ok: true,
1516 response_time_ms: 60,
1517 checked_at: "2026-03-13T00:00:00Z".to_string(),
1518 error: None,
1519 },
1520 ];
1521 let out = format_status_target(
1522 "mnw",
1523 "MakeNotWork",
1524 None,
1525 None,
1526 None,
1527 Some(&checks),
1528 None,
1529 None,
1530 None,
1531 None,
1532 None,
1533 );
1534 assert!(out.contains("Routes: 2/2 OK"));
1535 }
1536
1537 #[test]
1538 fn status_target_some_routes_failing() {
1539 let checks = vec![
1540 RouteCheckRow {
1541 id: 1,
1542 target: "mnw".to_string(),
1543 path: "/".to_string(),
1544 status_code: 200,
1545 ok: true,
1546 response_time_ms: 50,
1547 checked_at: "2026-03-13T00:00:00Z".to_string(),
1548 error: None,
1549 },
1550 RouteCheckRow {
1551 id: 2,
1552 target: "mnw".to_string(),
1553 path: "/docs/faq".to_string(),
1554 status_code: 404,
1555 ok: false,
1556 response_time_ms: 30,
1557 checked_at: "2026-03-13T00:00:00Z".to_string(),
1558 error: Some("HTTP 404".to_string()),
1559 },
1560 RouteCheckRow {
1561 id: 3,
1562 target: "mnw".to_string(),
1563 path: "/pricing".to_string(),
1564 status_code: 500,
1565 ok: false,
1566 response_time_ms: 20,
1567 checked_at: "2026-03-13T00:00:00Z".to_string(),
1568 error: Some("HTTP 500".to_string()),
1569 },
1570 ];
1571 let out = format_status_target(
1572 "mnw",
1573 "MakeNotWork",
1574 None,
1575 None,
1576 None,
1577 Some(&checks),
1578 None,
1579 None,
1580 None,
1581 None,
1582 None,
1583 );
1584 assert!(out.contains("Routes: 1/3 (FAIL: /docs/faq, /pricing)"));
1585 }
1586
1587 // format_versions
1588
1589 fn version_row(target: &str, version: Option<&str>, sha: Option<&str>) -> VersionRow {
1590 VersionRow {
1591 target: target.to_string(),
1592 label: format!("{target} label"),
1593 version: version.map(String::from),
1594 git_sha: sha.map(String::from),
1595 checked_at: Some("2026-07-29T18:04:37.123456+00:00".to_string()),
1596 version_since: Some("2026-07-28T09:00:00+00:00".to_string()),
1597 commits_behind: Some(8),
1598 behind_error: None,
1599 }
1600 }
1601
1602 #[test]
1603 fn versions_table_aligns_columns_and_shortens_the_sha() {
1604 let rows = vec![
1605 version_row("mnw", Some("0.11.0"), Some("6402bf4e9c1d2e3f4a5b")),
1606 version_row("multithreaded", Some("0.4.2"), Some("aaaabbbbcccc")),
1607 ];
1608 let out = format_versions(&rows);
1609 let lines: Vec<&str> = out.lines().collect();
1610
1611 assert!(lines[0].starts_with("TARGET"));
1612 // The header pads to the widest target, so every column starts at the
1613 // same offset on every line.
1614 let version_col = lines[0].find("VERSION").unwrap();
1615 assert_eq!(lines[1].find("0.11.0"), Some(version_col));
1616 assert_eq!(lines[2].find("0.4.2"), Some(version_col));
1617
1618 assert!(out.contains("6402bf4e "), "sha shortened to 8: {out}");
1619 assert!(!out.contains("6402bf4e9c1d"));
1620 assert!(
1621 out.contains("2026-07-29 18:04"),
1622 "timestamp to the minute: {out}"
1623 );
1624 }
1625
1626 #[test]
1627 fn versions_table_prints_a_dash_for_every_missing_value() {
1628 let rows = vec![VersionRow {
1629 target: "mt".to_string(),
1630 label: "Multithreaded".to_string(),
1631 version: None,
1632 git_sha: None,
1633 checked_at: None,
1634 version_since: None,
1635 commits_behind: None,
1636 behind_error: None,
1637 }];
1638 let out = format_versions(&rows);
1639 let row = out.lines().nth(1).unwrap();
1640 assert_eq!(
1641 row.split_whitespace().collect::<Vec<_>>(),
1642 ["mt", "-", "-", "-", "-", "-"]
1643 );
1644 }
1645
1646 #[test]
1647 fn versions_table_says_why_a_count_is_blank() {
1648 let mut row = version_row("mnw", Some("0.11.0"), Some("6402bf4e"));
1649 row.commits_behind = None;
1650 row.behind_error = Some("git rev-list exited 128: bad revision".to_string());
1651 let out = format_versions(&[row]);
1652 assert!(out.contains("behind: git rev-list exited 128"), "{out}");
1653 }
1654
1655 #[test]
1656 fn versions_table_scrubs_a_hostile_version_string() {
1657 // Same terminal-injection surface as every other display sink: the
1658 // version and sha are whatever the monitored target chose to send.
1659 let mut row = version_row("mnw", Some("1.0\u{1b}[2J FAKE"), Some("6402bf4e"));
1660 row.behind_error = Some("boom\u{1b}[2J".to_string());
1661 let out = format_versions(&[row]);
1662 assert!(!out.contains('\u{1b}'), "ESC must not reach the terminal");
1663 }
1664
1665 #[test]
1666 fn versions_empty_config_says_so() {
1667 assert_eq!(format_versions(&[]), "No targets configured.\n");
1668 }
1669
1670 #[test]
1671 fn minute_stamp_passes_through_anything_that_is_not_a_timestamp() {
1672 assert_eq!(minute_stamp("2026-07-29T18:04:37Z"), "2026-07-29 18:04");
1673 assert_eq!(minute_stamp("whenever"), "whenever");
1674 assert_eq!(minute_stamp("2026-07-29 18:04:37"), "2026-07-29 18:04:37");
1675 }
1676
1677 #[test]
1678 fn status_target_no_route_checks() {
1679 let out = format_status_target(
1680 "mnw",
1681 "MakeNotWork",
1682 None,
1683 None,
1684 None,
1685 None,
1686 None,
1687 None,
1688 None,
1689 None,
1690 None,
1691 );
1692 assert!(!out.contains("Routes"));
1693 }
1694 }
1695