Skip to main content

koi_compose/
status.rs

1//! Unified capability-status assembly — the one capability ladder that the daemon's
2//! `/v1/status`, the dashboard snapshot, and the embedded snapshot all share.
3//!
4//! Before P07 this 7-rung ladder (mdns, certmesh, dns, health, proxy, udp, runtime — each
5//! with present / stopped / disabled branches) was hand-written three times and could
6//! silently drift between the HTTP API, the dashboard, and embedded. [`assemble_capabilities`]
7//! is now the one source; each consumer projects the result into its own output shape.
8
9use koi_common::capability::{Capability, CapabilityStatus};
10
11use crate::cores::Cores;
12
13/// One capability's report: its status summary plus whether it is configured on at all.
14///
15/// `/v1/status` emits just the [`CapabilityStatus`]; the dashboard and embedded snapshots
16/// additionally surface `enabled` (false only when the capability is disabled entirely — a
17/// stopped-but-enabled runtime still reports `enabled = true`).
18pub struct CapabilityReport {
19    pub status: CapabilityStatus,
20    pub enabled: bool,
21}
22
23impl CapabilityReport {
24    fn present(status: CapabilityStatus) -> Self {
25        Self {
26            status,
27            enabled: true,
28        }
29    }
30
31    fn disabled(name: &str) -> Self {
32        Self {
33            status: CapabilityStatus {
34                name: name.to_string(),
35                summary: "disabled".to_string(),
36                healthy: false,
37            },
38            enabled: false,
39        }
40    }
41
42    fn stopped(name: &str) -> Self {
43        Self {
44            status: CapabilityStatus {
45                name: name.to_string(),
46                summary: "stopped".to_string(),
47                healthy: false,
48            },
49            enabled: true,
50        }
51    }
52}
53
54/// Assemble the capability ladder in the canonical order:
55/// mdns, certmesh, dns, health, proxy, udp, runtime.
56///
57/// DNS and health distinguish running / stopped / disabled; proxy is always healthy when
58/// present (its summary is the listener count); the rest are present-or-disabled.
59pub async fn assemble_capabilities(cores: &Cores) -> Vec<CapabilityReport> {
60    let mut caps = Vec::with_capacity(7);
61
62    // mDNS
63    caps.push(match &cores.mdns {
64        Some(core) => CapabilityReport::present(core.status().await),
65        None => CapabilityReport::disabled("mdns"),
66    });
67
68    // Certmesh
69    caps.push(match &cores.certmesh {
70        Some(core) => CapabilityReport::present(core.status().await),
71        None => CapabilityReport::disabled("certmesh"),
72    });
73
74    // DNS
75    caps.push(match &cores.dns {
76        Some(rt) if rt.status().await.running => {
77            CapabilityReport::present(rt.core().status().await)
78        }
79        Some(_) => CapabilityReport::stopped("dns"),
80        None => CapabilityReport::disabled("dns"),
81    });
82
83    // Health
84    caps.push(match &cores.health {
85        Some(rt) if rt.status().await.running => {
86            CapabilityReport::present(rt.core().status().await)
87        }
88        Some(_) => CapabilityReport::stopped("health"),
89        None => CapabilityReport::disabled("health"),
90    });
91
92    // Proxy (always healthy when present; summary = listener count)
93    caps.push(match &cores.proxy {
94        Some(rt) => {
95            let listeners = rt.status().await;
96            let summary = if listeners.is_empty() {
97                "no listeners".to_string()
98            } else {
99                format!("{} listeners", listeners.len())
100            };
101            CapabilityReport::present(CapabilityStatus {
102                name: "proxy".to_string(),
103                summary,
104                healthy: true,
105            })
106        }
107        None => CapabilityReport::disabled("proxy"),
108    });
109
110    // UDP (disambiguate the Capability trait method from UdpRuntime's own status())
111    caps.push(match &cores.udp {
112        Some(rt) => CapabilityReport::present(Capability::status(rt.as_ref()).await),
113        None => CapabilityReport::disabled("udp"),
114    });
115
116    // Runtime (RuntimeCore's Capability::status; was the bespoke capability_status())
117    caps.push(match &cores.runtime {
118        Some(rt) => CapabilityReport::present(Capability::status(rt.as_ref()).await),
119        None => CapabilityReport::disabled("runtime"),
120    });
121
122    caps
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[tokio::test]
130    async fn all_disabled_ladder_is_the_canonical_seven_rungs() {
131        // Golden contract: with no cores, the ladder is exactly these seven rungs, in this
132        // order, each disabled. This is the shape /v1/status, the dashboard, and embedded
133        // all serialize — locking the three projections to one source.
134        let caps = assemble_capabilities(&Cores::default()).await;
135        let rungs: Vec<(&str, &str, bool, bool)> = caps
136            .iter()
137            .map(|c| {
138                (
139                    c.status.name.as_str(),
140                    c.status.summary.as_str(),
141                    c.status.healthy,
142                    c.enabled,
143                )
144            })
145            .collect();
146        assert_eq!(
147            rungs,
148            vec![
149                ("mdns", "disabled", false, false),
150                ("certmesh", "disabled", false, false),
151                ("dns", "disabled", false, false),
152                ("health", "disabled", false, false),
153                ("proxy", "disabled", false, false),
154                ("udp", "disabled", false, false),
155                ("runtime", "disabled", false, false),
156            ]
157        );
158    }
159
160    #[tokio::test]
161    async fn capability_status_projection_matches_v1_status_shape() {
162        // The `/v1/status` projection drops `enabled` and serializes {name, summary, healthy}.
163        let caps = assemble_capabilities(&Cores::default()).await;
164        let statuses: Vec<CapabilityStatus> = caps.into_iter().map(|c| c.status).collect();
165        let json = serde_json::to_value(&statuses).unwrap();
166        let first = &json[0];
167        assert_eq!(first["name"], "mdns");
168        assert_eq!(first["summary"], "disabled");
169        assert_eq!(first["healthy"], false);
170        assert!(first.get("enabled").is_none(), "/v1/status omits `enabled`");
171    }
172}