hotfix_cli/
lib.rs

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    /// Base URL of the hotfix web server
11    #[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    /// Check the health status of the server
26    Health,
27    /// Get current session information
28    SessionInfo,
29    /// Request a reset on next logon
30    Reset,
31    /// Shutdown the session
32    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}