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    /// Project this report into the dashboard/embedded capability card shape:
25    /// `{name, enabled, healthy, summary}`. The single source both the daemon's
26    /// dashboard snapshot and the embedded snapshot serialize, so the four-field card
27    /// cannot drift between the two presentations.
28    pub fn into_card(self) -> serde_json::Value {
29        serde_json::json!({
30            "name": self.status.name,
31            "enabled": self.enabled,
32            "healthy": self.status.healthy,
33            "summary": self.status.summary,
34        })
35    }
36
37    fn present(status: CapabilityStatus) -> Self {
38        Self {
39            status,
40            enabled: true,
41        }
42    }
43
44    fn disabled(name: &str) -> Self {
45        Self {
46            status: CapabilityStatus {
47                name: name.to_string(),
48                summary: "disabled".to_string(),
49                healthy: false,
50            },
51            enabled: false,
52        }
53    }
54
55    fn stopped(name: &str) -> Self {
56        Self {
57            status: CapabilityStatus {
58                name: name.to_string(),
59                summary: "stopped".to_string(),
60                healthy: false,
61            },
62            enabled: true,
63        }
64    }
65}
66
67/// Assemble the capability ladder in the canonical order:
68/// mdns, certmesh, dns, health, proxy, udp, runtime.
69///
70/// DNS and health distinguish running / stopped / disabled; proxy is always healthy when
71/// present (its summary is the listener count); the rest are present-or-disabled.
72pub async fn assemble_capabilities(cores: &Cores) -> Vec<CapabilityReport> {
73    let mut caps = Vec::with_capacity(7);
74
75    // mDNS
76    caps.push(match &cores.mdns {
77        Some(core) => CapabilityReport::present(core.status().await),
78        None => CapabilityReport::disabled("mdns"),
79    });
80
81    // Certmesh
82    caps.push(match &cores.certmesh {
83        Some(core) => CapabilityReport::present(core.status().await),
84        None => CapabilityReport::disabled("certmesh"),
85    });
86
87    // DNS
88    caps.push(match &cores.dns {
89        Some(rt) if rt.status().await.running => {
90            CapabilityReport::present(rt.core().status().await)
91        }
92        Some(_) => CapabilityReport::stopped("dns"),
93        None => CapabilityReport::disabled("dns"),
94    });
95
96    // Health
97    caps.push(match &cores.health {
98        Some(rt) if rt.status().await.running => {
99            CapabilityReport::present(rt.core().status().await)
100        }
101        Some(_) => CapabilityReport::stopped("health"),
102        None => CapabilityReport::disabled("health"),
103    });
104
105    // Proxy (always healthy when present; summary = listener count)
106    caps.push(match &cores.proxy {
107        Some(rt) => {
108            let listeners = rt.status().await;
109            let summary = if listeners.is_empty() {
110                "no listeners".to_string()
111            } else {
112                format!("{} listeners", listeners.len())
113            };
114            CapabilityReport::present(CapabilityStatus {
115                name: "proxy".to_string(),
116                summary,
117                healthy: true,
118            })
119        }
120        None => CapabilityReport::disabled("proxy"),
121    });
122
123    // UDP (disambiguate the Capability trait method from UdpRuntime's own status())
124    caps.push(match &cores.udp {
125        Some(rt) => CapabilityReport::present(Capability::status(rt.as_ref()).await),
126        None => CapabilityReport::disabled("udp"),
127    });
128
129    // Runtime (RuntimeCore's Capability::status; was the bespoke capability_status())
130    caps.push(match &cores.runtime {
131        Some(rt) => CapabilityReport::present(Capability::status(rt.as_ref()).await),
132        None => CapabilityReport::disabled("runtime"),
133    });
134
135    caps
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[tokio::test]
143    async fn all_disabled_ladder_is_the_canonical_seven_rungs() {
144        // Golden contract: with no cores, the ladder is exactly these seven rungs, in this
145        // order, each disabled. This is the shape /v1/status, the dashboard, and embedded
146        // all serialize — locking the three projections to one source.
147        let caps = assemble_capabilities(&Cores::default()).await;
148        let rungs: Vec<(&str, &str, bool, bool)> = caps
149            .iter()
150            .map(|c| {
151                (
152                    c.status.name.as_str(),
153                    c.status.summary.as_str(),
154                    c.status.healthy,
155                    c.enabled,
156                )
157            })
158            .collect();
159        assert_eq!(
160            rungs,
161            vec![
162                ("mdns", "disabled", false, false),
163                ("certmesh", "disabled", false, false),
164                ("dns", "disabled", false, false),
165                ("health", "disabled", false, false),
166                ("proxy", "disabled", false, false),
167                ("udp", "disabled", false, false),
168                ("runtime", "disabled", false, false),
169            ]
170        );
171    }
172
173    #[tokio::test]
174    async fn capability_status_projection_matches_v1_status_shape() {
175        // The `/v1/status` projection drops `enabled` and serializes {name, summary, healthy}.
176        let caps = assemble_capabilities(&Cores::default()).await;
177        let statuses: Vec<CapabilityStatus> = caps.into_iter().map(|c| c.status).collect();
178        let json = serde_json::to_value(&statuses).unwrap();
179        let first = &json[0];
180        assert_eq!(first["name"], "mdns");
181        assert_eq!(first["summary"], "disabled");
182        assert_eq!(first["healthy"], false);
183        assert!(first.get("enabled").is_none(), "/v1/status omits `enabled`");
184    }
185}