hotfix_cli/
lib.rs

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    /// 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        /// Whether to reconnect after shutdown
34        #[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}