nixfleet_cli/
lib.rs

1//! Shared CLI logic - table rendering, age math, status classification.
2//! Library form so binaries compose against it and unit tests exercise
3//! formatting without spinning up a real CP.
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use chrono::{DateTime, Utc};
10use nixfleet_proto::{HostStatusEntry, HostsResponse, RolloutEvents, RolloutHosts};
11use reqwest::{Certificate, Identity};
12
13pub mod color;
14pub mod commands;
15pub mod config;
16pub mod operator_cert;
17pub use config::{ConfigError, FileConfig, Overrides};
18pub use operator_cert::{MintOperatorCertArgs, MintOutcome, mint_operator_cert};
19
20/// Write `~/.config/nixfleet/config.toml` (or `--path`). Returns the absolute
21/// path so the bin can report it.
22pub fn run_config_init(
23    path: &Path,
24    cp_url: String,
25    ca_cert: PathBuf,
26    client_cert: PathBuf,
27    client_key: PathBuf,
28    overwrite: bool,
29) -> Result<PathBuf> {
30    if path.exists() && !overwrite {
31        anyhow::bail!(
32            "{} already exists; pass --force to overwrite",
33            path.display(),
34        );
35    }
36    let cfg = config::FileConfig {
37        cp_url: Some(cp_url),
38        ca_cert: Some(ca_cert),
39        client_cert: Some(client_cert),
40        client_key: Some(client_key),
41    };
42    cfg.save(path)
43        .with_context(|| format!("write {}", path.display()))?;
44    Ok(path.to_path_buf())
45}
46
47/// Resolved operator-side config. Every field is required by the time we
48/// reach a network call; layered loader (flag > env > file) populates this.
49#[derive(Debug, Clone)]
50pub struct ResolvedClientConfig {
51    pub cp_url: String,
52    pub ca_cert: PathBuf,
53    pub client_cert: PathBuf,
54    pub client_key: PathBuf,
55}
56
57pub fn build_client(cfg: &ResolvedClientConfig) -> Result<reqwest::Client> {
58    let mut builder = reqwest::Client::builder().use_rustls_tls();
59    let pem = std::fs::read(&cfg.ca_cert)
60        .with_context(|| format!("read CA cert {}", cfg.ca_cert.display()))?;
61    let cert = Certificate::from_pem(&pem).context("parse CA cert PEM")?;
62    builder = builder.add_root_certificate(cert);
63
64    let mut id_pem = std::fs::read(&cfg.client_cert)
65        .with_context(|| format!("read client cert {}", cfg.client_cert.display()))?;
66    let key_pem = std::fs::read(&cfg.client_key)
67        .with_context(|| format!("read client key {}", cfg.client_key.display()))?;
68    id_pem.extend_from_slice(&key_pem);
69    let identity = Identity::from_pem(&id_pem).context("parse client identity PEM")?;
70    builder = builder.identity(identity);
71
72    builder.build().context("build HTTP client")
73}
74
75pub async fn run_status(cfg: &ResolvedClientConfig, json: bool, color: bool) -> Result<String> {
76    let cp_url = cfg.cp_url.trim_end_matches('/');
77    let client = build_client(cfg)?;
78
79    let hosts: HostsResponse = client
80        .get(format!("{cp_url}/v1/hosts"))
81        .send()
82        .await
83        .with_context(|| format!("GET {cp_url}/v1/hosts"))?
84        .error_for_status()?
85        .json()
86        .await
87        .context("parse /v1/hosts response")?;
88
89    if json {
90        return serde_json::to_string_pretty(&hosts).context("serialize HostsResponse to JSON");
91    }
92
93    let mut channels_seen: Vec<String> = hosts.hosts.iter().map(|h| h.channel.clone()).collect();
94    channels_seen.sort();
95    channels_seen.dedup();
96    let mut channel_freshness: BTreeMap<String, u32> = BTreeMap::new();
97    for channel in &channels_seen {
98        let resp: serde_json::Value = client
99            .get(format!("{cp_url}/v1/channels/{channel}"))
100            .send()
101            .await
102            .with_context(|| format!("GET {cp_url}/v1/channels/{channel}"))?
103            .error_for_status()?
104            .json()
105            .await
106            .context("parse /v1/channels response")?;
107        if let Some(window) = resp
108            .get("freshness_window_minutes")
109            .and_then(serde_json::Value::as_u64)
110        {
111            channel_freshness.insert(channel.clone(), window as u32);
112        }
113    }
114
115    let inputs = StatusInputs {
116        now: Utc::now(),
117        hosts: hosts.hosts,
118        channel_freshness,
119    };
120    Ok(render_status_table_with_color(&inputs, color))
121}
122
123/// `GET /v1/rollouts/{id}/hosts` — per-host summary. CLI subcommand:
124/// `nixfleet rollout hosts <id>`.
125pub async fn run_hosts(cfg: &ResolvedClientConfig, rollout_id: &str, json: bool) -> Result<String> {
126    let cp_url = cfg.cp_url.trim_end_matches('/');
127    let client = build_client(cfg)?;
128    let url = format!("{cp_url}/v1/rollouts/{}/hosts", rollout_id);
129    let resp = client
130        .get(&url)
131        .send()
132        .await
133        .with_context(|| format!("GET {url}"))?;
134    if resp.status() == reqwest::StatusCode::NOT_FOUND {
135        anyhow::bail!(
136            "rollout {rollout_id} has no host_rollout_records (never dispatched, or rollout id unknown)",
137        );
138    }
139    let hosts: RolloutHosts = resp
140        .error_for_status()?
141        .json()
142        .await
143        .context("parse /v1/rollouts/{id}/hosts response")?;
144    if json {
145        return serde_json::to_string_pretty(&hosts).context("serialize RolloutHosts to JSON");
146    }
147    Ok(render_hosts_table(&hosts))
148}
149
150/// `GET /v1/rollouts/{id}/events` — chronological event_log stream.
151/// CLI subcommand: `nixfleet rollout events <id>`. Default output is
152/// JSON because payload shapes vary by `kind` — a single rendered
153/// table would mislead. `json = false` falls back to a compact
154/// summary (seq / ts / kind / host).
155pub async fn run_events(
156    cfg: &ResolvedClientConfig,
157    rollout_id: &str,
158    limit: Option<i64>,
159    json: bool,
160) -> Result<String> {
161    let cp_url = cfg.cp_url.trim_end_matches('/');
162    let client = build_client(cfg)?;
163    let mut url = format!("{cp_url}/v1/rollouts/{}/events", rollout_id);
164    if let Some(n) = limit {
165        url.push_str(&format!("?limit={n}"));
166    }
167    let resp = client
168        .get(&url)
169        .send()
170        .await
171        .with_context(|| format!("GET {url}"))?;
172    if resp.status() == reqwest::StatusCode::NOT_FOUND {
173        anyhow::bail!("rollout {rollout_id} unknown");
174    }
175    let events: RolloutEvents = resp
176        .error_for_status()?
177        .json()
178        .await
179        .context("parse /v1/rollouts/{id}/events response")?;
180    if json {
181        return serde_json::to_string_pretty(&events).context("serialize RolloutEvents to JSON");
182    }
183    Ok(render_events_summary(&events))
184}
185
186/// Compact summary table for `--no-json` mode. One line per event:
187/// `seq  ts  kind  host`. The full payload is only available via
188/// the default JSON output.
189pub fn render_events_summary(events: &RolloutEvents) -> String {
190    let mut s = String::new();
191    s.push_str(&format!(
192        "rollout {} — {} event(s)\n",
193        events.rollout_id,
194        events.events.len()
195    ));
196    s.push_str(&format!(
197        "{:>8} {:24} {:18} {}\n",
198        "SEQ", "TS", "KIND", "HOST"
199    ));
200    for e in &events.events {
201        s.push_str(&format!(
202            "{:>8} {:24} {:18} {}\n",
203            e.seq,
204            e.ts,
205            e.kind,
206            e.host.as_deref().unwrap_or("-"),
207        ));
208    }
209    s
210}
211
212pub struct StatusInputs {
213    pub now: DateTime<Utc>,
214    pub hosts: Vec<HostStatusEntry>,
215    /// channel name -> freshness_window in minutes (from `/v1/channels/{name}`).
216    pub channel_freshness: BTreeMap<String, u32>,
217}
218
219pub fn render_status_table(input: &StatusInputs) -> String {
220    let mut rows: Vec<[String; 6]> = Vec::with_capacity(input.hosts.len() + 1);
221    rows.push([
222        "HOST".into(),
223        "CHANNEL".into(),
224        "CURRENT".into(),
225        "DECLARED".into(),
226        "STATUS".into(),
227        "COMPLIANCE".into(),
228    ]);
229    for host in &input.hosts {
230        rows.push([
231            host.hostname.clone(),
232            host.channel.clone(),
233            current_column(host),
234            display_hash(host.declared_closure_hash.as_deref(), "<unset>"),
235            status_label(
236                host,
237                input.now,
238                input.channel_freshness.get(&host.channel).copied(),
239            ),
240            compliance_label(host),
241        ]);
242    }
243
244    let mut widths = [0usize; 6];
245    for row in &rows {
246        for (i, col) in row.iter().enumerate() {
247            widths[i] = widths[i].max(col.chars().count());
248        }
249    }
250
251    let mut out = String::new();
252    for row in &rows {
253        for (i, col) in row.iter().enumerate() {
254            if i > 0 {
255                out.push_str("  ");
256            }
257            out.push_str(col);
258            if i + 1 < row.len() {
259                let pad = widths[i].saturating_sub(col.chars().count());
260                for _ in 0..pad {
261                    out.push(' ');
262                }
263            }
264        }
265        out.push('\n');
266    }
267    out
268}
269
270pub fn render_status_table_with_color(input: &StatusInputs, color: bool) -> String {
271    use crate::color::Stylizer;
272    let st = Stylizer { enabled: color };
273    let mut rows: Vec<[(String, String); 6]> = Vec::with_capacity(input.hosts.len() + 1);
274    rows.push([
275        ("HOST".into(), "HOST".into()),
276        ("CHANNEL".into(), "CHANNEL".into()),
277        ("CURRENT".into(), "CURRENT".into()),
278        ("DECLARED".into(), "DECLARED".into()),
279        ("STATUS".into(), "STATUS".into()),
280        ("COMPLIANCE".into(), "COMPLIANCE".into()),
281    ]);
282    for host in &input.hosts {
283        let raw_status = status_label(
284            host,
285            input.now,
286            input.channel_freshness.get(&host.channel).copied(),
287        );
288        let painted = paint_status(&st, &raw_status);
289        let current = current_column(host);
290        let declared = display_hash(host.declared_closure_hash.as_deref(), "<unset>");
291        let compliance = compliance_label(host);
292        rows.push([
293            (host.hostname.clone(), host.hostname.clone()),
294            (host.channel.clone(), host.channel.clone()),
295            (current.clone(), current),
296            (declared.clone(), declared),
297            (painted, raw_status),
298            (compliance.clone(), compliance),
299        ]);
300    }
301    layout_styled(&rows)
302}
303
304/// Map a STATUS label to its colored variant. FOOTGUN: `\u{2026}` also marks
305/// hash-column truncation in `display_hash` - only call this on STATUS
306/// labels emitted by `status_label`, never on hash columns.
307fn paint_status(st: &crate::color::Stylizer, label: &str) -> String {
308    use crate::color::Style;
309    if label.contains('\u{2713}') {
310        st.paint(Style::Green, label)
311    } else if label.contains('\u{26A0}')
312        || label.contains('\u{27F3}')
313        || label.contains('\u{2192}')
314        || label.contains('\u{2026}')
315    {
316        st.paint(Style::Yellow, label)
317    } else if label.contains('\u{2717}') {
318        st.paint(Style::Red, label)
319    } else {
320        label.to_string()
321    }
322}
323
324fn layout_styled(rows: &[[(String, String); 6]]) -> String {
325    let mut widths = [0usize; 6];
326    for row in rows {
327        for (i, (_render, width_src)) in row.iter().enumerate() {
328            widths[i] = widths[i].max(width_src.chars().count());
329        }
330    }
331    let mut out = String::new();
332    for row in rows {
333        for (i, (render, width_src)) in row.iter().enumerate() {
334            if i > 0 {
335                out.push_str("  ");
336            }
337            out.push_str(render);
338            if i + 1 < row.len() {
339                let pad = widths[i].saturating_sub(width_src.chars().count());
340                for _ in 0..pad {
341                    out.push(' ');
342                }
343            }
344        }
345        out.push('\n');
346    }
347    out
348}
349
350fn display_hash(h: Option<&str>, fallback: &str) -> String {
351    match h {
352        None => fallback.to_string(),
353        Some(s) if s.chars().count() <= 14 => s.to_string(),
354        Some(s) => {
355            let prefix: String = s.chars().take(13).collect();
356            format!("{prefix}\u{2026}")
357        }
358    }
359}
360
361/// CURRENT-column rendering with a context-aware fallback chain. The
362/// agent's post-activation closure observation (`current_closure_hash`)
363/// is the canonical value, but it's `None` between rollout-open and
364/// `ActivationCompleted` (the switch-in-progress window). During that
365/// window the host is demonstrably executing
366/// `pending_closure_hash` — the closure the agent reported running
367/// when it acked the dispatch — and the operator wants to see it.
368///
369/// Fallback order:
370/// 1. `current_closure_hash` — post-activation observed closure.
371/// 2. `pending_closure_hash` rendered with a trailing arrow —
372///    mid-rollout: agent has acked, switch in progress, host still
373///    on this closure.
374/// 3. `<unseen>` — no closure observation. The STATUS column
375///    already conveys in-flight state via its `→ in progress` /
376///    `→ activating` labels; CURRENT staying `<unseen>` is honest
377///    about the lack of closure data.
378fn current_column(host: &HostStatusEntry) -> String {
379    if let Some(c) = host.current_closure_hash.as_deref() {
380        return display_hash(Some(c), "");
381    }
382    if let Some(p) = host.pending_closure_hash.as_deref() {
383        return format!("{} \u{2192}", display_hash(Some(p), ""));
384    }
385    "<unseen>".to_string()
386}
387
388fn status_label(
389    host: &HostStatusEntry,
390    now: DateTime<Utc>,
391    freshness_minutes: Option<u32>,
392) -> String {
393    let base = base_status_label(host, now, freshness_minutes);
394    // Pin is operator metadata, not a status of its own - appended as a suffix
395    // so the health signal stays primary. Short-prefix to keep the column tidy.
396    match host.pin.as_ref() {
397        Some(pin) => {
398            let short: String = pin.commit.chars().take(7).collect();
399            format!("{base} \u{1F512}{short}")
400        }
401        None => base,
402    }
403}
404
405fn base_status_label(
406    host: &HostStatusEntry,
407    now: DateTime<Utc>,
408    freshness_minutes: Option<u32>,
409) -> String {
410    use nixfleet_proto::HostRolloutState;
411
412    // 6-state machine per RFC-0005 §3. The pre-v0.2 conditional ladder
413    // (Failed+current!=declared → "→ reverting", Healthy/Soaked → label
414    // soaking, etc.) collapsed into one match arm per variant because the
415    // new state machine forbids the shapes that ladder masked.
416    let quarantined = host.quarantined_closure.is_some();
417
418    // Stale check trumps in-flight labels: a host stuck in `Activating`
419    // for 3 days isn't activating, it's offline. Failures + Reverted
420    // remain operator-visible (they're not "in flight") so stale only
421    // applies to in-flight states.
422    let stale_label = host
423        .last_checkin_at
424        .zip(freshness_minutes)
425        .and_then(|(last, window)| {
426            let age = now.signed_duration_since(last);
427            let stale_threshold = chrono::Duration::minutes(i64::from(window) * 2);
428            (age > stale_threshold).then(|| format!("\u{26A0} stale ({})", format_age(age)))
429        });
430
431    if let Some(state) = host.rollout_state {
432        if state.is_in_flight()
433            && let Some(label) = stale_label
434        {
435            return label;
436        }
437        return match state {
438            HostRolloutState::Pending => "\u{2192} in progress".to_string(),
439            HostRolloutState::Activating => "\u{2192} activating".to_string(),
440            HostRolloutState::Deferred => {
441                // Activation staged at bootloader; live switch deferred
442                // because a critical component cannot be live-swapped.
443                // Operator action: reboot the host. LIFT #1's heartbeat
444                // synthesis then transitions Deferred → Soaking.
445                "\u{25B2} pending reboot".to_string()
446            }
447            HostRolloutState::Soaking => {
448                // Probe failures during soak are operator-visible even
449                // though the state itself is non-terminal.
450                if host.outstanding_health_failures > 0 {
451                    "\u{26A0} probes failing".to_string()
452                } else {
453                    "\u{2192} soaking".to_string()
454                }
455            }
456            HostRolloutState::Converged => "\u{2713} converged".to_string(),
457            HostRolloutState::Failed => {
458                if quarantined {
459                    "\u{2717} failed - channel halted, push fix".to_string()
460                } else {
461                    "\u{2717} failed".to_string()
462                }
463            }
464            HostRolloutState::Reverted => {
465                if quarantined {
466                    "\u{2717} reverted - channel halted, push fix".to_string()
467                } else {
468                    "\u{2717} reverted".to_string()
469                }
470            }
471        };
472    }
473
474    // No rollout_state recorded: fall through to the "did the closure
475    // match anyway?" + freshness ladder. Quarantine still surfaces; the
476    // pending-reboot hint stays as an operator carve-out.
477    if quarantined {
478        return "\u{2717} quarantined - channel halted, push fix".to_string();
479    }
480    if host.pending_reboot {
481        return "\u{27F3} pending reboot".to_string();
482    }
483    if host.converged {
484        return "\u{2713} converged".to_string();
485    }
486
487    if host.last_checkin_at.is_none() {
488        return "\u{2717} never".to_string();
489    }
490    if let Some(label) = stale_label {
491        return label;
492    }
493    "\u{2192} in progress".to_string()
494}
495
496fn format_age(d: chrono::Duration) -> String {
497    let total_seconds = d.num_seconds().max(0);
498    if total_seconds >= 86400 {
499        format!("{}d", total_seconds / 86400)
500    } else if total_seconds >= 3600 {
501        format!("{}h", total_seconds / 3600)
502    } else {
503        format!("{}m", total_seconds / 60)
504    }
505}
506
507fn compliance_label(host: &HostStatusEntry) -> String {
508    // Compliance + runtime-gate + health failures all surface as one
509    // "outstanding" number; drill-down lives in the dashboard / JSON.
510    let total = host.outstanding_compliance_failures
511        + host.outstanding_runtime_gate_errors
512        + host.outstanding_health_failures;
513    format!("{total} outstanding")
514}
515
516/// Render `nixfleet rollout hosts`: wave-major listing of per-host
517/// dispatch state. Open dispatches show `<open>` in TERMINAL.
518pub fn render_hosts_table(rollout: &RolloutHosts) -> String {
519    let mut rows: Vec<[String; 5]> = Vec::with_capacity(rollout.hosts.len() + 1);
520    rows.push([
521        "WAVE".into(),
522        "HOST".into(),
523        "DISPATCHED".into(),
524        "TERMINAL".into(),
525        "AT".into(),
526    ]);
527    for h in &rollout.hosts {
528        rows.push([
529            h.wave.to_string(),
530            h.host.clone(),
531            short_ts(&h.dispatched_at),
532            h.terminal_state.clone().unwrap_or_else(|| "<open>".into()),
533            h.terminal_at.as_deref().map(short_ts).unwrap_or_default(),
534        ]);
535    }
536
537    let mut widths = [0usize; 5];
538    for row in &rows {
539        for (i, col) in row.iter().enumerate() {
540            widths[i] = widths[i].max(col.chars().count());
541        }
542    }
543
544    let mut out = format!("rollout {}\n", rollout.rollout_id);
545    for row in &rows {
546        for (i, col) in row.iter().enumerate() {
547            if i > 0 {
548                out.push_str("  ");
549            }
550            out.push_str(col);
551            if i + 1 < row.len() {
552                let pad = widths[i].saturating_sub(col.chars().count());
553                for _ in 0..pad {
554                    out.push(' ');
555                }
556            }
557        }
558        out.push('\n');
559    }
560    out
561}
562
563/// "2026-05-05T12:34:56.789Z" -> "2026-05-05 12:34:56" (denser column).
564/// Falls back to the original on parse fail so malformed rows surface.
565fn short_ts(rfc3339: &str) -> String {
566    DateTime::parse_from_rfc3339(rfc3339)
567        .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
568        .unwrap_or_else(|_| rfc3339.to_string())
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use chrono::TimeZone;
575
576    fn fixture_host(
577        hostname: &str,
578        channel: &str,
579        converged: bool,
580        last_checkin_min_ago: Option<i64>,
581        outstanding: usize,
582    ) -> HostStatusEntry {
583        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
584        HostStatusEntry {
585            hostname: hostname.into(),
586            channel: channel.into(),
587            declared_closure_hash: Some("aaaaaaaaaaaaaaaaaaaa".into()),
588            current_closure_hash: last_checkin_min_ago.map(|_| "bbbbbbbbbbbbbbbbbbbb".to_string()),
589            pending_closure_hash: None,
590            last_checkin_at: last_checkin_min_ago.map(|m| now - chrono::Duration::minutes(m)),
591            last_rollout_id: None,
592            converged,
593            outstanding_compliance_failures: outstanding,
594            outstanding_runtime_gate_errors: 0,
595            verified_event_count: 0,
596            last_uptime_secs: None,
597            rollout_state: None,
598            pending_reboot: false,
599            quarantined_closure: None,
600            pin: None,
601            outstanding_health_failures: 0,
602        }
603    }
604
605    #[test]
606    fn renders_three_status_classes() {
607        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
608        let inputs = StatusInputs {
609            now,
610            hosts: vec![
611                fixture_host("host-05", "stable", true, Some(0), 0),
612                fixture_host("host-01", "stable", false, None, 0),
613                fixture_host("host-02", "stable", false, Some(60 * 24 * 3), 2),
614            ],
615            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
616        };
617        let out = render_status_table(&inputs);
618        assert!(out.contains("\u{2713} converged"), "no converged: {out}");
619        assert!(out.contains("\u{2717} never"), "no never: {out}");
620        assert!(out.contains("\u{26A0} stale (3d)"), "no stale: {out}");
621        assert!(out.contains("HOST"));
622        assert!(out.contains("0 outstanding"));
623        assert!(out.contains("2 outstanding"));
624    }
625
626    #[test]
627    fn long_hashes_truncate_with_ellipsis() {
628        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
629        let mut h = fixture_host("a", "stable", true, Some(0), 0);
630        h.declared_closure_hash = Some("0123456789abcdef0123456789abcdef".into());
631        let inputs = StatusInputs {
632            now,
633            hosts: vec![h],
634            channel_freshness: BTreeMap::new(),
635        };
636        let out = render_status_table(&inputs);
637        assert!(
638            out.contains("0123456789abc\u{2026}"),
639            "no truncation: {out}"
640        );
641    }
642
643    #[test]
644    fn missing_freshness_window_skips_staleness_check() {
645        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
646        let inputs = StatusInputs {
647            now,
648            hosts: vec![fixture_host("a", "stable", false, Some(60 * 24 * 7), 0)],
649            channel_freshness: BTreeMap::new(),
650        };
651        let out = render_status_table(&inputs);
652        assert!(
653            out.contains("\u{2192} in progress"),
654            "fell through to in-progress without a window: {out}"
655        );
656        assert!(
657            !out.contains("stale"),
658            "shouldn't be stale without window: {out}"
659        );
660    }
661
662    /// Priority contract: quarantined (CI-side fix) ranks above
663    /// pending-reboot (operator reboot).
664    #[test]
665    fn quarantined_renders_above_pending_reboot_priority() {
666        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
667        let mut h = fixture_host("a", "stable", false, Some(1), 0);
668        h.quarantined_closure = Some("broken-closure-h1".into());
669        h.pending_reboot = true;
670        let inputs = StatusInputs {
671            now,
672            hosts: vec![h],
673            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
674        };
675        let out = render_status_table(&inputs);
676        assert!(
677            out.contains("\u{2717} quarantined"),
678            "expected quarantined label: {out}",
679        );
680        assert!(
681            !out.contains("pending reboot"),
682            "quarantined must out-rank pending-reboot: {out}",
683        );
684    }
685
686    #[test]
687    fn health_failures_roll_into_outstanding_count() {
688        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
689        let mut h = fixture_host("a", "stable", true, Some(0), 1);
690        h.outstanding_runtime_gate_errors = 1;
691        h.outstanding_health_failures = 2;
692        let inputs = StatusInputs {
693            now,
694            hosts: vec![h],
695            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
696        };
697        let out = render_status_table(&inputs);
698        assert!(
699            out.contains("4 outstanding"),
700            "expected combined count: {out}"
701        );
702    }
703
704    #[test]
705    fn pin_appends_to_converged_label() {
706        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
707        let mut h = fixture_host("a", "stable", true, Some(0), 0);
708        h.pin = Some(nixfleet_proto::Pin {
709            commit: "abc12345-deadbeef".into(),
710            reason: "investigating CVE".into(),
711            expires_at: None,
712        });
713        let inputs = StatusInputs {
714            now,
715            hosts: vec![h],
716            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
717        };
718        let out = render_status_table(&inputs);
719        assert!(
720            out.contains("\u{2713} converged"),
721            "must keep converged: {out}"
722        );
723        assert!(
724            out.contains("\u{1F512}abc1234"),
725            "must show 7-char pin prefix: {out}"
726        );
727        assert!(
728            !out.contains("abc12345"),
729            "8th char must be truncated: {out}"
730        );
731    }
732
733    /// Pin info stays visible on failure paths so operators see "supposed
734    /// to be on commit X, and it's failed".
735    #[test]
736    fn pin_appends_to_failed_label_too() {
737        use nixfleet_proto::HostRolloutState;
738        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
739        let mut h = fixture_host("a", "stable", false, Some(1), 0);
740        h.rollout_state = Some(HostRolloutState::Failed);
741        // Pin the host to the bad SHA so it shows "✗ failed" (on declared)
742        // rather than "→ reverting" (off declared).
743        h.current_closure_hash = h.declared_closure_hash.clone();
744        h.pin = Some(nixfleet_proto::Pin {
745            commit: "frozen1".into(),
746            reason: "Q2 audit".into(),
747            expires_at: None,
748        });
749        let inputs = StatusInputs {
750            now,
751            hosts: vec![h],
752            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
753        };
754        let out = render_status_table(&inputs);
755        assert!(out.contains("\u{2717} failed"));
756        assert!(out.contains("\u{1F512}frozen1"));
757    }
758
759    #[test]
760    fn pending_reboot_renders_distinctly_when_not_converged() {
761        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
762        let mut h = fixture_host("a", "stable", false, Some(1), 0);
763        h.pending_reboot = true;
764        let inputs = StatusInputs {
765            now,
766            hosts: vec![h],
767            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
768        };
769        let out = render_status_table(&inputs);
770        assert!(
771            out.contains("\u{27F3} pending reboot"),
772            "expected pending-reboot label: {out}",
773        );
774        assert!(
775            !out.contains("converged"),
776            "should not show converged: {out}"
777        );
778        assert!(
779            !out.contains("in progress"),
780            "pending-reboot is louder than in-progress: {out}"
781        );
782    }
783
784    #[test]
785    fn rollout_state_failed_takes_priority_over_converged() {
786        use nixfleet_proto::HostRolloutState;
787        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
788        let mut h = fixture_host("a", "stable", true, Some(1), 0);
789        h.rollout_state = Some(HostRolloutState::Failed);
790        // Force current == declared so the "on declared" arm renders ("failed").
791        // The "off declared" arm ("reverting") is exercised separately.
792        h.current_closure_hash = h.declared_closure_hash.clone();
793        let inputs = StatusInputs {
794            now,
795            hosts: vec![h],
796            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
797        };
798        let out = render_status_table(&inputs);
799        assert!(
800            out.contains("\u{2717} failed"),
801            "expected failed label: {out}"
802        );
803        assert!(
804            !out.contains("converged"),
805            "should not show converged: {out}"
806        );
807    }
808
809    /// Issue (state-machine clarity): Failed with the host already off the
810    /// declared (bad) SHA means the agent has rolled back -- CP just hasn't
811    /// transitioned to Reverted yet. Render as "→ reverting" so the operator
812    /// knows recovery is in flight rather than seeing a stale "✗ failed".
813    #[test]
814    fn rollout_state_failed_renders_as_failed_under_new_state_machine() {
815        // RFC-0005 §3 forbids the v0.1 "Failed + current != declared →
816        // → reverting" shape. The agent owns its own rollback; CP sees
817        // either Failed or Reverted as terminal-but-stuck. The label
818        // collapsed accordingly in Phase 7h.
819        use nixfleet_proto::HostRolloutState;
820        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
821        let mut h = fixture_host("a", "stable", false, Some(1), 0);
822        h.rollout_state = Some(HostRolloutState::Failed);
823        let inputs = StatusInputs {
824            now,
825            hosts: vec![h],
826            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
827        };
828        let out = render_status_table(&inputs);
829        assert!(
830            out.contains("\u{2717} failed"),
831            "Failed renders as '✗ failed' regardless of agent's current closure: {out}",
832        );
833        assert!(
834            !out.contains("\u{2192} reverting"),
835            "v0.1 '→ reverting' transition label is gone: {out}",
836        );
837    }
838
839    #[test]
840    fn soaked_with_failing_probes_does_not_render_as_converged() {
841        use nixfleet_proto::HostRolloutState;
842        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
843        let mut h = fixture_host("a", "stable", true, Some(1), 0);
844        h.rollout_state = Some(HostRolloutState::Soaking);
845        h.outstanding_health_failures = 1;
846        let inputs = StatusInputs {
847            now,
848            hosts: vec![h],
849            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
850        };
851        let out = render_status_table(&inputs);
852        assert!(
853            out.contains("\u{26A0} probes failing"),
854            "expected probes-failing label: {out}",
855        );
856        assert!(
857            !out.contains("\u{2713} converged"),
858            "should not show converged when probes are failing: {out}",
859        );
860    }
861
862    #[test]
863    fn healthy_with_failing_probes_does_not_render_as_converged() {
864        use nixfleet_proto::HostRolloutState;
865        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
866        // Pre-soak window: closure activated, host still in Healthy, probes
867        // already failing. Same misleading-display bug as the Soaked case.
868        let mut h = fixture_host("a", "stable", true, Some(1), 0);
869        h.rollout_state = Some(HostRolloutState::Soaking);
870        h.outstanding_health_failures = 1;
871        let inputs = StatusInputs {
872            now,
873            hosts: vec![h],
874            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
875        };
876        let out = render_status_table(&inputs);
877        assert!(
878            out.contains("\u{26A0} probes failing"),
879            "expected probes-failing label: {out}",
880        );
881        assert!(
882            !out.contains("\u{2713} converged"),
883            "should not show converged when probes are failing: {out}",
884        );
885    }
886
887    #[test]
888    fn soaking_with_no_failing_probes_renders_soaking_not_converged() {
889        // A host in Soaking has activated cleanly but the soak window
890        // hasn't elapsed yet — distinct from Converged.
891        use nixfleet_proto::HostRolloutState;
892        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
893        let mut h = fixture_host("a", "stable", true, Some(1), 0);
894        h.rollout_state = Some(HostRolloutState::Soaking);
895        let inputs = StatusInputs {
896            now,
897            hosts: vec![h],
898            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
899        };
900        let out = render_status_table(&inputs);
901        assert!(
902            out.contains("\u{2192} soaking"),
903            "soaking+passing must render as '→ soaking': {out}",
904        );
905        assert!(
906            !out.contains("\u{2713} converged"),
907            "must not collapse soaking into converged: {out}",
908        );
909    }
910
911    /// Companion: Healthy + passing probes is the brief window between
912    /// confirm and Soaked. Show "→ healthy" so the operator sees the host
913    /// is still progressing through the rollout, not done.
914    #[test]
915    fn healthy_with_passing_probes_renders_soaking_not_converged() {
916        // Healthy is the soak window between confirm and Soaked. Label it as
917        // "→ soaking" so the operator sees rollout progress accurately --
918        // "healthy" reads as a terminal state and lies about the transient
919        // nature of this phase.
920        use nixfleet_proto::HostRolloutState;
921        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
922        let mut h = fixture_host("a", "stable", true, Some(1), 0);
923        h.rollout_state = Some(HostRolloutState::Soaking);
924        let inputs = StatusInputs {
925            now,
926            hosts: vec![h],
927            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
928        };
929        let out = render_status_table(&inputs);
930        assert!(
931            !out.contains("\u{2713} converged"),
932            "must not collapse healthy into converged: {out}",
933        );
934        assert!(
935            out.contains("\u{2192} soaking"),
936            "Healthy must render as '→ soaking': {out}",
937        );
938        assert!(
939            !out.contains("\u{2192} healthy"),
940            "must not surface the raw '→ healthy' label: {out}",
941        );
942    }
943
944    /// Issue #5: reverted + quarantined surface together so the operator
945    /// sees the channel-halt actionability ("push a new closure") rather
946    /// than just the failure label.
947    #[test]
948    fn reverted_with_quarantine_appends_channel_halt_hint() {
949        use nixfleet_proto::HostRolloutState;
950        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
951        let mut h = fixture_host("a", "stable", false, Some(1), 0);
952        h.rollout_state = Some(HostRolloutState::Reverted);
953        h.quarantined_closure = Some("bad-sha".to_string());
954        let inputs = StatusInputs {
955            now,
956            hosts: vec![h],
957            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
958        };
959        let out = render_status_table(&inputs);
960        assert!(
961            out.contains("reverted") && out.contains("channel halted"),
962            "must surface channel-halt hint alongside reverted: {out}",
963        );
964    }
965
966    /// Reverted without quarantine keeps the original label.
967    #[test]
968    fn reverted_without_quarantine_stays_plain() {
969        use nixfleet_proto::HostRolloutState;
970        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
971        let mut h = fixture_host("a", "stable", false, Some(1), 0);
972        h.rollout_state = Some(HostRolloutState::Reverted);
973        let inputs = StatusInputs {
974            now,
975            hosts: vec![h],
976            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
977        };
978        let out = render_status_table(&inputs);
979        assert!(
980            out.contains("reverted") && !out.contains("channel halted"),
981            "no quarantine -> no halt hint: {out}",
982        );
983    }
984
985    /// Quarantined-only (no rollout-state failure yet) carries the hint too.
986    #[test]
987    fn quarantined_label_includes_channel_halt_hint() {
988        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
989        let mut h = fixture_host("a", "stable", false, Some(1), 0);
990        h.quarantined_closure = Some("bad-sha".to_string());
991        let inputs = StatusInputs {
992            now,
993            hosts: vec![h],
994            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
995        };
996        let out = render_status_table(&inputs);
997        assert!(
998            out.contains("quarantined") && out.contains("channel halted"),
999            "quarantined-only label must include halt hint: {out}",
1000        );
1001    }
1002
1003    /// Sanity: Converged state still renders as converged (this is the
1004    /// terminal state where the green check is genuinely earned).
1005    #[test]
1006    fn converged_state_renders_converged() {
1007        use nixfleet_proto::HostRolloutState;
1008        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1009        let mut h = fixture_host("a", "stable", true, Some(1), 0);
1010        h.rollout_state = Some(HostRolloutState::Converged);
1011        let inputs = StatusInputs {
1012            now,
1013            hosts: vec![h],
1014            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1015        };
1016        let out = render_status_table(&inputs);
1017        assert!(
1018            out.contains("\u{2713} converged"),
1019            "Converged state must render as converged: {out}",
1020        );
1021    }
1022
1023    #[test]
1024    fn rollout_state_in_flight_renders_active_state() {
1025        use nixfleet_proto::HostRolloutState;
1026        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1027        let mut h = fixture_host("a", "stable", false, Some(1), 0);
1028        h.rollout_state = Some(HostRolloutState::Activating);
1029        let inputs = StatusInputs {
1030            now,
1031            hosts: vec![h],
1032            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1033        };
1034        let out = render_status_table(&inputs);
1035        assert!(
1036            out.contains("\u{2192} activating"),
1037            "expected activating: {out}"
1038        );
1039    }
1040
1041    #[test]
1042    fn rollout_state_soaking_renders_in_flight_not_converged() {
1043        // RFC-0005 §3 collapsed Soaked into Soaking and made Converged
1044        // the sole terminal-for-ordering state. Soaking must render as
1045        // in-flight (→ soaking), not as ✓.
1046        use nixfleet_proto::HostRolloutState;
1047        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1048        let mut h = fixture_host("a", "stable", false, Some(1), 0);
1049        h.rollout_state = Some(HostRolloutState::Soaking);
1050        let inputs = StatusInputs {
1051            now,
1052            hosts: vec![h],
1053            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1054        };
1055        let out = render_status_table(&inputs);
1056        assert!(
1057            out.contains("\u{2192} soaking"),
1058            "Soaking must render in-flight: {out}",
1059        );
1060        assert!(
1061            !out.contains("\u{2713}"),
1062            "Soaking is not terminal-for-ordering in v0.2: {out}",
1063        );
1064    }
1065
1066    #[test]
1067    fn rollout_state_pending_renders_in_progress() {
1068        // v0.1 Queued + Dispatched + ConfirmWindow all collapsed into
1069        // Pending. The label collapsed with them.
1070        use nixfleet_proto::HostRolloutState;
1071        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1072        let mut h = fixture_host("a", "stable", false, Some(1), 0);
1073        h.rollout_state = Some(HostRolloutState::Pending);
1074        let inputs = StatusInputs {
1075            now,
1076            hosts: vec![h],
1077            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1078        };
1079        let out = render_status_table(&inputs);
1080        assert!(
1081            out.contains("\u{2192} in progress"),
1082            "Pending must render '→ in progress': {out}",
1083        );
1084    }
1085
1086    fn host_entry(
1087        host: &str,
1088        wave: u32,
1089        terminal: Option<&str>,
1090    ) -> nixfleet_proto::RolloutHostEntry {
1091        nixfleet_proto::RolloutHostEntry {
1092            host: host.into(),
1093            channel: "stable".into(),
1094            wave,
1095            target_closure_hash: "system-r1".into(),
1096            target_channel_ref: "stable@trace1".into(),
1097            dispatched_at: "2026-05-05T12:00:00Z".into(),
1098            terminal_state: terminal.map(String::from),
1099            terminal_at: terminal.map(|_| "2026-05-05T12:30:00Z".into()),
1100        }
1101    }
1102
1103    #[test]
1104    fn render_hosts_table_shows_open_dispatches_distinctly() {
1105        let rollout = RolloutHosts {
1106            rollout_id: nixfleet_proto::RolloutId::new("stable", "trace1"),
1107            hosts: vec![
1108                host_entry("host-05", 0, Some("converged")),
1109                host_entry("host-01", 1, None),
1110            ],
1111        };
1112        let out = render_hosts_table(&rollout);
1113        assert!(
1114            out.contains("rollout stable@trace1"),
1115            "missing header: {out}"
1116        );
1117        assert!(out.contains("WAVE"), "missing column header: {out}");
1118        assert!(out.contains("converged"), "missing terminal state: {out}");
1119        assert!(out.contains("<open>"), "missing open marker: {out}");
1120        assert!(
1121            out.contains("2026-05-05 12:00:00"),
1122            "timestamp not shortened: {out}"
1123        );
1124    }
1125
1126    #[test]
1127    fn stale_checkin_overrides_in_flight_state() {
1128        use nixfleet_proto::HostRolloutState;
1129        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1130        let mut h = fixture_host("a", "stable", false, Some(60 * 24 * 3), 0);
1131        h.rollout_state = Some(HostRolloutState::Activating);
1132        let inputs = StatusInputs {
1133            now,
1134            hosts: vec![h],
1135            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1136        };
1137        let out = render_status_table(&inputs);
1138        assert!(
1139            out.contains("\u{26A0} stale"),
1140            "stale should win over in-flight Activating: {out}"
1141        );
1142    }
1143
1144    /// Compile-time guard for the `run_status(cfg, json, color)` signature.
1145    #[test]
1146    fn run_status_json_branch_compiles() {
1147        fn _typecheck(cfg: &crate::ResolvedClientConfig) {
1148            let _fut = crate::run_status(cfg, true, false);
1149        }
1150    }
1151
1152    #[test]
1153    fn color_render_preserves_column_widths() {
1154        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1155        let inputs = StatusInputs {
1156            now,
1157            hosts: vec![
1158                fixture_host("a", "stable", true, Some(0), 0),
1159                fixture_host("verylonghostname", "stable", false, None, 0),
1160            ],
1161            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1162        };
1163        let plain = render_status_table(&inputs);
1164        let painted = render_status_table_with_color(&inputs, true);
1165        assert_eq!(plain.lines().count(), painted.lines().count());
1166        assert!(painted.contains("\x1b["), "expected ANSI in painted output");
1167        assert!(!plain.contains("\x1b["), "plain must not have ANSI escapes");
1168        // Strip ANSI then compare line-by-line modulo trailing whitespace
1169        // (column padding can collapse differently across renderers).
1170        let strip_ansi = |s: &str| -> String {
1171            let mut out = String::new();
1172            let mut chars = s.chars().peekable();
1173            while let Some(c) = chars.next() {
1174                if c == '\x1b' && chars.peek() == Some(&'[') {
1175                    chars.next();
1176                    while let Some(&c2) = chars.peek() {
1177                        chars.next();
1178                        if c2 == 'm' {
1179                            break;
1180                        }
1181                    }
1182                } else {
1183                    out.push(c);
1184                }
1185            }
1186            out
1187        };
1188        let painted_plain: Vec<&str> = painted.lines().collect();
1189        let stripped: Vec<String> = painted_plain.iter().map(|l| strip_ansi(l)).collect();
1190        let plain_lines: Vec<&str> = plain.lines().collect();
1191        for (a, b) in stripped.iter().zip(plain_lines.iter()) {
1192            assert_eq!(
1193                a.trim_end(),
1194                b.trim_end(),
1195                "row mismatch:\nstripped: {a}\nplain:    {b}"
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn paint_status_glyph_color_mapping_locks_in() {
1202        use nixfleet_proto::HostRolloutState;
1203        let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1204
1205        let inputs = StatusInputs {
1206            now,
1207            hosts: vec![fixture_host("a", "stable", true, Some(0), 0)],
1208            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1209        };
1210        let painted = render_status_table_with_color(&inputs, true);
1211        assert!(
1212            painted.contains("\x1b[32m") && painted.contains("\u{2713} converged"),
1213            "converged should be green: {painted}",
1214        );
1215
1216        let mut h = fixture_host("a", "stable", false, Some(1), 0);
1217        h.rollout_state = Some(HostRolloutState::Failed);
1218        // Force current == declared so the label is "✗ failed" (red), not
1219        // "→ reverting" (off-declared transient).
1220        h.current_closure_hash = h.declared_closure_hash.clone();
1221        let inputs = StatusInputs {
1222            now,
1223            hosts: vec![h],
1224            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1225        };
1226        let painted = render_status_table_with_color(&inputs, true);
1227        assert!(
1228            painted.contains("\x1b[31m") && painted.contains("\u{2717} failed"),
1229            "failed should be red: {painted}",
1230        );
1231
1232        let inputs = StatusInputs {
1233            now,
1234            hosts: vec![fixture_host("a", "stable", false, Some(60 * 24 * 3), 0)],
1235            channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1236        };
1237        let painted = render_status_table_with_color(&inputs, true);
1238        assert!(
1239            painted.contains("\x1b[33m") && painted.contains("\u{26A0} stale"),
1240            "stale should be yellow: {painted}",
1241        );
1242    }
1243
1244    /// CURRENT-column fallback: mid-rollout (switch-in-progress window)
1245    /// shows the pre-dispatch closure with an arrow, not `<unseen>`.
1246    /// The agent's `ActivationCompleted` hasn't landed yet, but the
1247    /// host is demonstrably running `pending_closure_hash` and the
1248    /// operator gets to see it.
1249    #[test]
1250    fn current_column_shows_pending_closure_during_rollout() {
1251        use nixfleet_proto::HostRolloutState;
1252        let mut h = fixture_host("a", "stable", false, None, 0);
1253        h.current_closure_hash = None;
1254        h.pending_closure_hash = Some("aaaa1111bbbb2222".into());
1255        h.rollout_state = Some(HostRolloutState::Activating);
1256        let rendered = current_column(&h);
1257        assert!(
1258            rendered.contains("\u{2192}"),
1259            "switch-in-progress must mark direction with arrow: {rendered}",
1260        );
1261        assert!(
1262            rendered.contains("aaaa1111bbbb2"),
1263            "switch-in-progress must show pending hash: {rendered}",
1264        );
1265        assert!(
1266            !rendered.contains("<unseen>"),
1267            "switch-in-progress must NOT render <unseen>: {rendered}",
1268        );
1269    }
1270
1271    /// CURRENT-column fallback: with no closure observation in either
1272    /// field, `<unseen>` is the honest answer regardless of rollout
1273    /// state. The STATUS column already carries the in-flight signal
1274    /// via its `→ in progress` / `→ activating` labels — duplicating
1275    /// it in CURRENT (bare arrow) just made every transitional row
1276    /// visually noisy with a double-arrow `→  → in progress` pattern.
1277    #[test]
1278    fn current_column_renders_unseen_when_no_closure_data() {
1279        use nixfleet_proto::HostRolloutState;
1280        let mut h = fixture_host("a", "stable", false, None, 0);
1281        h.current_closure_hash = None;
1282        h.pending_closure_hash = None;
1283        // Both with and without rollout_state → still `<unseen>`.
1284        h.rollout_state = Some(HostRolloutState::Pending);
1285        assert_eq!(current_column(&h), "<unseen>");
1286        h.rollout_state = None;
1287        assert_eq!(current_column(&h), "<unseen>");
1288    }
1289
1290    /// CURRENT-column fallback: post-activation observation wins over
1291    /// pending-closure data, even if both are populated.
1292    #[test]
1293    fn current_column_prefers_current_over_pending() {
1294        let mut h = fixture_host("a", "stable", true, Some(0), 0);
1295        h.current_closure_hash = Some("ccccdddd33334444".into());
1296        h.pending_closure_hash = Some("aaaabbbb11112222".into());
1297        let rendered = current_column(&h);
1298        assert!(rendered.contains("ccccdddd33334"));
1299        assert!(!rendered.contains("aaaabbbb"));
1300        assert!(!rendered.contains("\u{2192}"));
1301    }
1302}