1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand};
3use owo_colors::OwoColorize;
4use serde::{Deserialize, Serialize};
5
6#[derive(Parser)]
7#[command(name = "hotfix")]
8#[command(about = "CLI tool for managing hotfix sessions", long_about = None)]
9pub struct Cli {
10 #[arg(
12 short,
13 long,
14 env = "HOTFIX_WEB_URL",
15 default_value = "http://localhost:9881"
16 )]
17 pub url: String,
18
19 #[command(subcommand)]
20 pub command: Command,
21}
22
23#[derive(Subcommand)]
24pub enum Command {
25 Health,
27 SessionInfo,
29 Reset,
31 Shutdown {
33 #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
35 reconnect: bool,
36 },
37}
38
39#[derive(Debug, Deserialize)]
40pub struct HealthResponse {
41 pub status: String,
42}
43
44#[derive(Debug, Deserialize)]
45pub struct SessionInfoResponse {
46 pub session_info: SessionInfo,
47}
48
49#[derive(Debug, Deserialize)]
50pub struct SessionInfo {
51 pub next_sender_seq_number: u64,
52 pub next_target_seq_number: u64,
53 pub status: String,
54}
55
56#[derive(Debug, Serialize)]
57pub struct ShutdownRequest {
58 pub reconnect: bool,
59}
60
61pub async fn run(cli: Cli) -> Result<()> {
62 let client = reqwest::Client::new();
63 let base_url = cli.url.trim_end_matches('/');
64
65 match cli.command {
66 Command::Health => {
67 let url = format!("{}/api/health", base_url);
68 let response = client
69 .get(&url)
70 .send()
71 .await
72 .context("Failed to send health request")?;
73
74 let status = response.status();
75 let health: HealthResponse = response
76 .json()
77 .await
78 .context("Failed to parse health response")?;
79
80 println!("{} {}", "Status:".bold(), status.to_string().bright_blue());
81 println!("{} {}", "Health:".bold(), health.status.green());
82 }
83 Command::SessionInfo => {
84 let url = format!("{}/api/session-info", base_url);
85 let response = client
86 .get(&url)
87 .send()
88 .await
89 .context("Failed to send session-info request")?;
90
91 let status = response.status();
92 let info: SessionInfoResponse = response
93 .json()
94 .await
95 .context("Failed to parse session-info response")?;
96
97 println!("{} {}", "Status:".bold(), status.to_string().bright_blue());
98 println!("{}", "Session Info:".bold().underline());
99 println!(
100 " {}: {}",
101 "Next Sender Seq Number".cyan(),
102 info.session_info.next_sender_seq_number
103 );
104 println!(
105 " {}: {}",
106 "Next Target Seq Number".cyan(),
107 info.session_info.next_target_seq_number
108 );
109 println!(
110 " {}: {}",
111 "Status".cyan(),
112 info.session_info.status.yellow()
113 );
114 }
115 Command::Reset => {
116 let url = format!("{}/api/reset", base_url);
117 let response = client
118 .post(&url)
119 .send()
120 .await
121 .context("Failed to send reset request")?;
122
123 let status = response.status();
124 if status.is_success() {
125 println!("{} {}", "Status:".bold(), status.to_string().bright_blue());
126 println!("{}", "Reset requested successfully".green());
127 } else {
128 let text = response.text().await.unwrap_or_default();
129 anyhow::bail!(
130 "{} with status {}: {}",
131 "Reset request failed".red(),
132 status,
133 text
134 );
135 }
136 }
137 Command::Shutdown { reconnect } => {
138 let url = format!("{}/api/shutdown", base_url);
139 let body = ShutdownRequest { reconnect };
140 let response = client
141 .post(&url)
142 .json(&body)
143 .send()
144 .await
145 .context("Failed to send shutdown request")?;
146
147 let status = response.status();
148 if status.is_success() {
149 println!("{} {}", "Status:".bold(), status.to_string().bright_blue());
150 println!("{}", "Shutdown requested successfully".green());
151 } else {
152 let text = response.text().await.unwrap_or_default();
153 anyhow::bail!(
154 "{} with status {}: {}",
155 "Shutdown request failed".red(),
156 status,
157 text
158 );
159 }
160 }
161 }
162
163 Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use wiremock::matchers::{method, path};
170 use wiremock::{Mock, MockServer, ResponseTemplate};
171
172 #[tokio::test]
173 async fn test_health_command_success() {
174 let mock_server = MockServer::start().await;
175
176 Mock::given(method("GET"))
177 .and(path("/api/health"))
178 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
179 "status": "healthy"
180 })))
181 .mount(&mock_server)
182 .await;
183
184 let cli = Cli {
185 url: mock_server.uri(),
186 command: Command::Health,
187 };
188
189 let result = run(cli).await;
190 assert!(result.is_ok());
191 }
192
193 #[tokio::test]
194 async fn test_session_info_command_success() {
195 let mock_server = MockServer::start().await;
196
197 Mock::given(method("GET"))
198 .and(path("/api/session-info"))
199 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
200 "session_info": {
201 "next_sender_seq_number": 42,
202 "next_target_seq_number": 99,
203 "status": "Active"
204 }
205 })))
206 .mount(&mock_server)
207 .await;
208
209 let cli = Cli {
210 url: mock_server.uri(),
211 command: Command::SessionInfo,
212 };
213
214 let result = run(cli).await;
215 assert!(result.is_ok());
216 }
217
218 #[tokio::test]
219 async fn test_reset_command_success() {
220 let mock_server = MockServer::start().await;
221
222 Mock::given(method("POST"))
223 .and(path("/api/reset"))
224 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
225 .mount(&mock_server)
226 .await;
227
228 let cli = Cli {
229 url: mock_server.uri(),
230 command: Command::Reset,
231 };
232
233 let result = run(cli).await;
234 assert!(result.is_ok());
235 }
236
237 #[tokio::test]
238 async fn test_shutdown_command_success() {
239 let mock_server = MockServer::start().await;
240
241 Mock::given(method("POST"))
242 .and(path("/api/shutdown"))
243 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
244 .mount(&mock_server)
245 .await;
246
247 let cli = Cli {
248 url: mock_server.uri(),
249 command: Command::Shutdown { reconnect: true },
250 };
251
252 let result = run(cli).await;
253 assert!(result.is_ok());
254 }
255
256 #[tokio::test]
257 async fn test_reset_command_handles_error() {
258 let mock_server = MockServer::start().await;
259
260 Mock::given(method("POST"))
261 .and(path("/api/reset"))
262 .respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
263 .mount(&mock_server)
264 .await;
265
266 let cli = Cli {
267 url: mock_server.uri(),
268 command: Command::Reset,
269 };
270
271 let result = run(cli).await;
272 assert!(result.is_err());
273 assert!(
274 result
275 .unwrap_err()
276 .to_string()
277 .contains("Reset request failed")
278 );
279 }
280
281 #[tokio::test]
282 async fn test_shutdown_command_handles_error() {
283 let mock_server = MockServer::start().await;
284
285 Mock::given(method("POST"))
286 .and(path("/api/shutdown"))
287 .respond_with(ResponseTemplate::new(500).set_body_string("Internal server error"))
288 .mount(&mock_server)
289 .await;
290
291 let cli = Cli {
292 url: mock_server.uri(),
293 command: Command::Shutdown { reconnect: true },
294 };
295
296 let result = run(cli).await;
297 assert!(result.is_err());
298 assert!(
299 result
300 .unwrap_err()
301 .to_string()
302 .contains("Shutdown request failed")
303 );
304 }
305
306 #[tokio::test]
307 async fn test_health_command_handles_invalid_json() {
308 let mock_server = MockServer::start().await;
309
310 Mock::given(method("GET"))
311 .and(path("/api/health"))
312 .respond_with(ResponseTemplate::new(200).set_body_string("invalid json"))
313 .mount(&mock_server)
314 .await;
315
316 let cli = Cli {
317 url: mock_server.uri(),
318 command: Command::Health,
319 };
320
321 let result = run(cli).await;
322 assert!(result.is_err());
323 assert!(
324 result
325 .unwrap_err()
326 .to_string()
327 .contains("Failed to parse health response")
328 );
329 }
330
331 #[tokio::test]
332 async fn test_session_info_command_handles_invalid_json() {
333 let mock_server = MockServer::start().await;
334
335 Mock::given(method("GET"))
336 .and(path("/api/session-info"))
337 .respond_with(ResponseTemplate::new(200).set_body_string("invalid json"))
338 .mount(&mock_server)
339 .await;
340
341 let cli = Cli {
342 url: mock_server.uri(),
343 command: Command::SessionInfo,
344 };
345
346 let result = run(cli).await;
347 assert!(result.is_err());
348 assert!(
349 result
350 .unwrap_err()
351 .to_string()
352 .contains("Failed to parse session-info response")
353 );
354 }
355
356 #[tokio::test]
357 async fn test_url_trailing_slash_is_trimmed() {
358 let mock_server = MockServer::start().await;
359
360 Mock::given(method("GET"))
361 .and(path("/api/health"))
362 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
363 "status": "healthy"
364 })))
365 .mount(&mock_server)
366 .await;
367
368 let url_with_slash = format!("{}/", mock_server.uri());
369 let cli = Cli {
370 url: url_with_slash,
371 command: Command::Health,
372 };
373
374 let result = run(cli).await;
375 assert!(result.is_ok());
376 }
377
378 #[tokio::test]
379 async fn test_session_info_with_different_status_values() {
380 let mock_server = MockServer::start().await;
381
382 Mock::given(method("GET"))
383 .and(path("/api/session-info"))
384 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
385 "session_info": {
386 "next_sender_seq_number": 1,
387 "next_target_seq_number": 1,
388 "status": "AwaitingLogon"
389 }
390 })))
391 .mount(&mock_server)
392 .await;
393
394 let cli = Cli {
395 url: mock_server.uri(),
396 command: Command::SessionInfo,
397 };
398
399 let result = run(cli).await;
400 assert!(result.is_ok());
401 }
402}