cellos_ctl/cmd/version.rs
1//! `cellctl version` — print client version, then attempt the server version.
2//!
3//! The client version is printed unconditionally on its own line so an operator
4//! always sees what they have locally even when the server is unreachable.
5//! After that, we attempt the server query and:
6//!
7//! - on success, print `server: cellos-server <ver> (<gitSha> <buildProfile>)`
8//! and exit 0 — fields the server omits (e.g. `gitSha` when the build did
9//! not capture one) are simply elided from the parenthetical;
10//! - on transport OR HTTP error, return a [`CtlError::api`] so the process
11//! exits non-zero. The error formatter prints
12//! `cellctl: api: unable to connect to the server: <reason>` to stderr,
13//! matching the SMOKE-TEST wave-2 contract.
14//!
15//! This matches `kubectl version`, which also exits non-zero when it cannot
16//! reach the API server. See SMOKE-TEST report Finding #2 (wave 2) and
17//! E2E report SRV-001 (wave 3, server route added).
18//!
19//! ## Wire shape (server side: `GET /v1/version`)
20//!
21//! ```json
22//! { "server": { "version": "0.5.0", "gitSha": "abc1234", "buildProfile": "release" },
23//! "api": { "version": "v1" } }
24//! ```
25//!
26//! `gitSha` is optional. We tolerate either the nested shape (added by
27//! SRV-001) or a flat legacy shape (`{ "version": "..." }`) so a cellctl
28//! pointed at an older server still prints something useful.
29
30use serde::Deserialize;
31use serde_json::Value;
32
33use crate::client::CellosClient;
34use crate::exit::{CtlError, CtlResult};
35
36/// Nested wire shape returned by SRV-001+ servers. All inner fields are
37/// optional so a partial response (or future field additions) does not
38/// fail the deserializer.
39#[derive(Debug, Default, Deserialize)]
40struct ServerVersion {
41 #[serde(default)]
42 version: Option<String>,
43 #[serde(rename = "gitSha", default)]
44 git_sha: Option<String>,
45 #[serde(rename = "buildProfile", default)]
46 build_profile: Option<String>,
47}
48
49pub async fn run(client: &CellosClient) -> CtlResult<()> {
50 println!("client: cellctl {}", env!("CARGO_PKG_VERSION"));
51
52 match client.get_json::<Value>("/v1/version").await {
53 Ok(v) => {
54 println!("server: {}", format_server_line(&v));
55 Ok(())
56 }
57 Err(e) => {
58 // Propagate as an API error so the process exits with code 2 —
59 // scripts and CI gates that branch on $? get an honest signal
60 // instead of a false-success. `CtlError::exit` formats this as
61 // `cellctl: api: unable to connect to the server: <reason>` on
62 // stderr, matching the existing error-output contract.
63 Err(CtlError::api(format!(
64 "unable to connect to the server: {e}"
65 )))
66 }
67 }
68}
69
70/// Render the `server:` line content given the raw JSON body. Public to
71/// the module so a focused unit test can pin the exact format without
72/// spinning up a live HTTP exchange.
73fn format_server_line(v: &Value) -> String {
74 // Prefer the nested `server` object (SRV-001 shape). If that's not
75 // present we fall back to the flat `{ "version": "..." }` legacy
76 // shape so cellctl 0.5.x can still talk to a 0.5.0 server.
77 let server = v
78 .get("server")
79 .and_then(|s| serde_json::from_value::<ServerVersion>(s.clone()).ok())
80 .unwrap_or_else(|| ServerVersion {
81 version: v.get("version").and_then(|x| x.as_str()).map(str::to_owned),
82 ..Default::default()
83 });
84
85 let version = server.version.as_deref().unwrap_or("unknown");
86
87 // Build the parenthetical from whichever optional fields are
88 // populated. An older server returns neither, so we drop the
89 // parens entirely rather than printing `cellos-server 0.5.0 ()`.
90 let mut parts: Vec<&str> = Vec::with_capacity(2);
91 if let Some(s) = server.git_sha.as_deref() {
92 parts.push(s);
93 }
94 if let Some(p) = server.build_profile.as_deref() {
95 parts.push(p);
96 }
97 if parts.is_empty() {
98 format!("cellos-server {version}")
99 } else {
100 format!("cellos-server {version} ({})", parts.join(" "))
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use serde_json::json;
108
109 #[test]
110 fn renders_full_nested_shape() {
111 let v = json!({
112 "server": {
113 "version": "0.5.0",
114 "gitSha": "abc1234",
115 "buildProfile": "release",
116 },
117 "api": { "version": "v1" },
118 });
119 assert_eq!(
120 format_server_line(&v),
121 "cellos-server 0.5.0 (abc1234 release)"
122 );
123 }
124
125 #[test]
126 fn renders_nested_shape_without_git_sha() {
127 // gitSha omitted by the server (no CELLOS_GIT_SHA at build) —
128 // the buildProfile is the only parenthetical content.
129 let v = json!({
130 "server": { "version": "0.5.0", "buildProfile": "debug" },
131 "api": { "version": "v1" },
132 });
133 assert_eq!(format_server_line(&v), "cellos-server 0.5.0 (debug)");
134 }
135
136 #[test]
137 fn renders_flat_legacy_shape() {
138 // Older server (pre-SRV-001) returning a bare `{ version }`.
139 let v = json!({ "version": "0.4.0" });
140 assert_eq!(format_server_line(&v), "cellos-server 0.4.0");
141 }
142
143 #[test]
144 fn renders_unknown_when_version_missing() {
145 let v = json!({ "server": { "buildProfile": "release" } });
146 assert_eq!(format_server_line(&v), "cellos-server unknown (release)");
147 }
148}