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()),
65        None => CapabilityReport::disabled("mdns"),
66    });
67
68    // Certmesh
69    caps.push(match &cores.certmesh {
70        Some(core) => CapabilityReport::present(core.status()),
71        None => CapabilityReport::disabled("certmesh"),
72    });
73
74    // DNS
75    caps.push(match &cores.dns {
76        Some(rt) if rt.status().await.running => CapabilityReport::present(rt.core().status()),
77        Some(_) => CapabilityReport::stopped("dns"),
78        None => CapabilityReport::disabled("dns"),
79    });
80
81    // Health
82    caps.push(match &cores.health {
83        Some(rt) if rt.status().await.running => CapabilityReport::present(rt.core().status()),
84        Some(_) => CapabilityReport::stopped("health"),
85        None => CapabilityReport::disabled("health"),
86    });
87
88    // Proxy (always healthy when present; summary = listener count)
89    caps.push(match &cores.proxy {
90        Some(rt) => {
91            let listeners = rt.status().await;
92            let summary = if listeners.is_empty() {
93                "no listeners".to_string()
94            } else {
95                format!("{} listeners", listeners.len())
96            };
97            CapabilityReport::present(CapabilityStatus {
98                name: "proxy".to_string(),
99                summary,
100                healthy: true,
101            })
102        }
103        None => CapabilityReport::disabled("proxy"),
104    });
105
106    // UDP (disambiguate the Capability trait method from UdpRuntime's own status())
107    caps.push(match &cores.udp {
108        Some(rt) => CapabilityReport::present(Capability::status(rt.as_ref())),
109        None => CapabilityReport::disabled("udp"),
110    });
111
112    // Runtime
113    caps.push(match &cores.runtime {
114        Some(rt) => CapabilityReport::present(rt.capability_status().await),
115        None => CapabilityReport::disabled("runtime"),
116    });
117
118    caps
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[tokio::test]
126    async fn all_disabled_ladder_is_the_canonical_seven_rungs() {
127        // Golden contract: with no cores, the ladder is exactly these seven rungs, in this
128        // order, each disabled. This is the shape /v1/status, the dashboard, and embedded
129        // all serialize — locking the three projections to one source.
130        let caps = assemble_capabilities(&Cores::default()).await;
131        let rungs: Vec<(&str, &str, bool, bool)> = caps
132            .iter()
133            .map(|c| {
134                (
135                    c.status.name.as_str(),
136                    c.status.summary.as_str(),
137                    c.status.healthy,
138                    c.enabled,
139                )
140            })
141            .collect();
142        assert_eq!(
143            rungs,
144            vec![
145                ("mdns", "disabled", false, false),
146                ("certmesh", "disabled", false, false),
147                ("dns", "disabled", false, false),
148                ("health", "disabled", false, false),
149                ("proxy", "disabled", false, false),
150                ("udp", "disabled", false, false),
151                ("runtime", "disabled", false, false),
152            ]
153        );
154    }
155
156    #[tokio::test]
157    async fn capability_status_projection_matches_v1_status_shape() {
158        // The `/v1/status` projection drops `enabled` and serializes {name, summary, healthy}.
159        let caps = assemble_capabilities(&Cores::default()).await;
160        let statuses: Vec<CapabilityStatus> = caps.into_iter().map(|c| c.status).collect();
161        let json = serde_json::to_value(&statuses).unwrap();
162        let first = &json[0];
163        assert_eq!(first["name"], "mdns");
164        assert_eq!(first["summary"], "disabled");
165        assert_eq!(first["healthy"], false);
166        assert!(first.get("enabled").is_none(), "/v1/status omits `enabled`");
167    }
168}