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