Skip to main content

garnix_insights/
cli.rs

1//! Command-line interface for Garnix Insights
2
3use crate::client::GarnixClient;
4use crate::error::{GarnixError, GarnixResult};
5use crate::mcp::GarnixMcpServer;
6use crate::server::GarnixHttpServer;
7use clap::{Parser, Subcommand};
8use tracing::{error, info};
9
10/// Garnix Insights - Fetch CI build status from Garnix.io
11#[derive(Parser, Debug)]
12#[command(name = "garnix-insights")]
13#[command(about = "Fetch CI build status from Garnix.io")]
14#[command(version)]
15pub struct Cli {
16    /// Subcommand to run
17    #[command(subcommand)]
18    pub command: Option<Commands>,
19
20    /// JWT authentication token for Garnix.io API (can also be set via GARNIX_JWT_TOKEN env var)
21    #[arg(long, env = "GARNIX_JWT_TOKEN")]
22    pub jwt_token: Option<String>,
23
24    /// Git commit ID to fetch build status for (required for fetch command)
25    #[arg(long)]
26    pub commit_id: Option<String>,
27
28    /// Enable verbose logging
29    #[arg(short, long)]
30    pub verbose: bool,
31
32    /// Output format for results
33    #[arg(long, default_value = "human")]
34    pub format: OutputFormat,
35}
36
37/// Available output formats
38#[derive(clap::ValueEnum, Debug, Clone)]
39pub enum OutputFormat {
40    /// Human-readable output with colors and emojis
41    Human,
42    /// JSON output
43    Json,
44    /// Plain text output
45    Plain,
46}
47
48/// Available commands
49#[derive(Subcommand, Debug)]
50pub enum Commands {
51    /// Fetch build status for a specific commit (default command)
52    Fetch {
53        /// JWT authentication token
54        #[arg(long, env = "GARNIX_JWT_TOKEN")]
55        jwt_token: Option<String>,
56        /// Git commit ID
57        #[arg(long)]
58        commit_id: String,
59    },
60    /// Start HTTP server mode
61    Server {
62        /// Address to bind the server to
63        #[arg(long, default_value = "127.0.0.1")]
64        bind_address: String,
65        /// Port to bind the server to
66        #[arg(long, default_value = "8080")]
67        port: u16,
68    },
69    /// Start MCP (Model Context Protocol) server mode
70    Mcp,
71    /// Validate a JWT token
72    ValidateToken {
73        /// JWT authentication token to validate
74        #[arg(long, env = "GARNIX_JWT_TOKEN")]
75        jwt_token: String,
76    },
77    /// Get build logs for a specific build
78    Logs {
79        /// JWT authentication token
80        #[arg(long, env = "GARNIX_JWT_TOKEN")]
81        jwt_token: String,
82        /// Build ID to fetch logs for
83        #[arg(long)]
84        build_id: String,
85    },
86}
87
88impl Cli {
89    /// Parse command line arguments
90    pub fn parse_args() -> Self {
91        Self::parse()
92    }
93
94    /// Run the CLI application
95    pub async fn run(self) -> GarnixResult<()> {
96        // Initialize tracing
97        let level = if self.verbose {
98            tracing::Level::DEBUG
99        } else {
100            tracing::Level::INFO
101        };
102
103        tracing_subscriber::fmt().with_max_level(level).init();
104
105        info!("Starting Garnix Insights v{}", env!("CARGO_PKG_VERSION"));
106
107        let client = GarnixClient::new();
108
109        match &self.command {
110            Some(Commands::Fetch {
111                jwt_token,
112                commit_id,
113            }) => {
114                let token = jwt_token
115                    .as_ref()
116                    .or(self.jwt_token.as_ref())
117                    .ok_or_else(|| GarnixError::ConfigError("JWT token is required".to_string()))?;
118
119                self.fetch_build_status(&client, token, commit_id).await
120            }
121            Some(Commands::Server { bind_address, port }) => {
122                info!("Starting HTTP server on {}:{}", bind_address, port);
123                let server = GarnixHttpServer::with_client(client)
124                    .bind_address(bind_address.clone())
125                    .port(*port);
126                server.run().await
127            }
128            Some(Commands::Mcp) => {
129                info!("Starting MCP server");
130                let server = GarnixMcpServer::with_client(client);
131                server.run_stdio().await
132            }
133            Some(Commands::ValidateToken { jwt_token }) => {
134                self.validate_token(&client, jwt_token).await
135            }
136            Some(Commands::Logs {
137                jwt_token,
138                build_id,
139            }) => self.fetch_build_logs(&client, jwt_token, build_id).await,
140            None => {
141                // Default behavior - try to fetch build status if we have the required args
142                match (&self.jwt_token, &self.commit_id) {
143                    (Some(token), Some(commit_id)) => {
144                        self.fetch_build_status(&client, token, commit_id).await
145                    }
146                    _ => {
147                        error!("No command specified and missing required arguments");
148                        Err(GarnixError::ConfigError(
149                            "Either specify a subcommand or provide --jwt-token and --commit-id"
150                                .to_string(),
151                        ))
152                    }
153                }
154            }
155        }
156    }
157
158    /// Fetch and display build status
159    async fn fetch_build_status(
160        &self,
161        client: &GarnixClient,
162        jwt_token: &str,
163        commit_id: &str,
164    ) -> GarnixResult<()> {
165        info!("Fetching build status for commit: {}", commit_id);
166
167        let response = client.fetch_build_status(jwt_token, commit_id).await?;
168
169        match self.format {
170            OutputFormat::Json => {
171                println!("{}", serde_json::to_string_pretty(&response)?);
172            }
173            OutputFormat::Human => {
174                self.print_human_readable(&response);
175            }
176            OutputFormat::Plain => {
177                self.print_plain_text(&response);
178            }
179        }
180
181        Ok(())
182    }
183
184    /// Validate JWT token
185    async fn validate_token(&self, client: &GarnixClient, jwt_token: &str) -> GarnixResult<()> {
186        info!("Validating JWT token");
187
188        match client.validate_token(jwt_token).await {
189            Ok(_) => {
190                match self.format {
191                    OutputFormat::Json => {
192                        println!(r#"{{"valid": true, "message": "Token is valid"}}"#);
193                    }
194                    _ => {
195                        println!("[OK] JWT token is valid");
196                    }
197                }
198                Ok(())
199            }
200            Err(e) => {
201                match self.format {
202                    OutputFormat::Json => {
203                        println!(r#"{{"valid": false, "error": "{}"}}"#, e);
204                    }
205                    _ => {
206                        println!("[FAIL] JWT token is invalid: {}", e);
207                    }
208                }
209                Err(e)
210            }
211        }
212    }
213
214    /// Fetch and display build logs
215    async fn fetch_build_logs(
216        &self,
217        client: &GarnixClient,
218        jwt_token: &str,
219        build_id: &str,
220    ) -> GarnixResult<()> {
221        info!("Fetching build logs for build: {}", build_id);
222
223        let response = client.fetch_build_logs(jwt_token, build_id).await?;
224
225        match self.format {
226            OutputFormat::Json => {
227                println!("{}", serde_json::to_string_pretty(&response)?);
228            }
229            OutputFormat::Human | OutputFormat::Plain => {
230                if response.logs.is_empty() {
231                    println!("No logs available for build {}", build_id);
232                } else {
233                    println!(
234                        "Logs for build {} (finished: {}):",
235                        build_id, response.finished
236                    );
237                    println!("{}", "=".repeat(60));
238
239                    for log_entry in &response.logs {
240                        println!("[{}] {}", log_entry.timestamp, log_entry.log_message);
241                    }
242                }
243            }
244        }
245
246        Ok(())
247    }
248
249    /// Print build status in human-readable format
250    fn print_human_readable(&self, response: &crate::types::GarnixResponse) {
251        use crate::format_build_summary;
252
253        println!("{}", format_build_summary(response));
254
255        // Print individual builds if there are any failures
256        if response.summary.failed > 0 {
257            println!("\n[SEARCH] Failed Builds:");
258            for build in response.failed_builds() {
259                println!(
260                    "  • {} ({}): {}",
261                    build.package,
262                    build.system.as_deref().unwrap_or("unknown"),
263                    build.status_with_emoji()
264                );
265                if let Some(drv_path) = &build.drv_path {
266                    println!("    Derivation: {}", drv_path);
267                }
268            }
269        }
270
271        // Print success rate
272        let success_rate = response.success_rate();
273        let emoji = if success_rate == 100.0 {
274            "[SUCCESS]"
275        } else if success_rate >= 80.0 {
276            "[GOOD]"
277        } else {
278            "[WARNING]"
279        };
280        println!("\n{} Success Rate: {:.1}%", emoji, success_rate);
281    }
282
283    /// Print build status in plain text format
284    fn print_plain_text(&self, response: &crate::types::GarnixResponse) {
285        println!("Build Status for {}", response.summary.git_commit);
286        println!(
287            "Repository: {}/{}",
288            response.summary.repo_owner, response.summary.repo_name
289        );
290        println!("Branch: {}", response.summary.branch);
291        println!("Started: {}", response.summary.start_time);
292        println!();
293
294        println!("Summary:");
295        println!("  Succeeded: {}", response.summary.succeeded);
296        println!("  Failed: {}", response.summary.failed);
297        println!("  Pending: {}", response.summary.pending);
298        println!("  Cancelled: {}", response.summary.cancelled);
299        println!();
300
301        if !response.builds.is_empty() {
302            println!("Individual Builds:");
303            for build in &response.builds {
304                println!(
305                    "  {} - {} ({})",
306                    build.package,
307                    build.status,
308                    build.system.as_deref().unwrap_or("unknown")
309                );
310            }
311        }
312
313        println!("Success Rate: {:.1}%", response.success_rate());
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::types::{Build, GarnixResponse, Summary};
321
322    fn create_test_response() -> GarnixResponse {
323        GarnixResponse {
324            summary: Summary {
325                repo_owner: "testuser".to_string(),
326                repo_name: "testrepo".to_string(),
327                repo_is_public: true,
328                git_commit: "7a2f5e9c1b4d8a3e6f2a9e5c8b1d4f7a3c6e9b2d".to_string(),
329                branch: "main".to_string(),
330                req_user: "testuser".to_string(),
331                start_time: "2024-01-01T00:00:00Z".to_string(),
332                succeeded: 2,
333                failed: 1,
334                pending: 0,
335                cancelled: 0,
336            },
337            builds: vec![
338                Build {
339                    id: "build1".to_string(),
340                    repo_user: "testuser".to_string(),
341                    repo_name: "testrepo".to_string(),
342                    branch: "main".to_string(),
343                    repo_is_public: true,
344                    git_commit: "7a2f5e9c1b4d8a3e6f2a9e5c8b1d4f7a3c6e9b2d".to_string(),
345                    package: "package1".to_string(),
346                    package_type: "derivation".to_string(),
347                    system: Some("x86_64-linux".to_string()),
348                    req_user: "testuser".to_string(),
349                    status: "Success".to_string(),
350                    start_time: "2024-01-01T00:00:00Z".to_string(),
351                    end_time: "2024-01-01T00:01:00Z".to_string(),
352                    drv_path: None,
353                    output_paths: None,
354                    github_run_id: 123,
355                    wants_incrementalism: false,
356                    eval_host: "eval.garnix.io".to_string(),
357                    uploaded_to_cache: true,
358                },
359                Build {
360                    id: "build2".to_string(),
361                    repo_user: "testuser".to_string(),
362                    repo_name: "testrepo".to_string(),
363                    branch: "main".to_string(),
364                    repo_is_public: true,
365                    git_commit: "7a2f5e9c1b4d8a3e6f2a9e5c8b1d4f7a3c6e9b2d".to_string(),
366                    package: "package2".to_string(),
367                    package_type: "derivation".to_string(),
368                    system: Some("x86_64-linux".to_string()),
369                    req_user: "testuser".to_string(),
370                    status: "Failed".to_string(),
371                    start_time: "2024-01-01T00:00:00Z".to_string(),
372                    end_time: "2024-01-01T00:01:30Z".to_string(),
373                    drv_path: Some("/nix/store/test.drv".to_string()),
374                    output_paths: None,
375                    github_run_id: 124,
376                    wants_incrementalism: false,
377                    eval_host: "eval.garnix.io".to_string(),
378                    uploaded_to_cache: false,
379                },
380            ],
381            runs: vec![],
382        }
383    }
384
385    #[test]
386    fn test_cli_parsing() {
387        let cli = Cli::try_parse_from(&[
388            "garnix-insights",
389            "fetch",
390            "--jwt-token",
391            "test-token",
392            "--commit-id",
393            "5d9e2f7a1c4b8e3a6f1d9e2a7f5c8b3e6a1f4d9e",
394        ])
395        .unwrap();
396
397        match cli.command.unwrap() {
398            Commands::Fetch {
399                jwt_token,
400                commit_id,
401            } => {
402                assert_eq!(jwt_token.unwrap(), "test-token");
403                assert_eq!(commit_id, "5d9e2f7a1c4b8e3a6f1d9e2a7f5c8b3e6a1f4d9e");
404            }
405            _ => panic!("Wrong command parsed"),
406        }
407    }
408
409    #[test]
410    fn test_cli_server_parsing() {
411        let cli = Cli::try_parse_from(&[
412            "garnix-insights",
413            "server",
414            "--bind-address",
415            "0.0.0.0",
416            "--port",
417            "3000",
418        ])
419        .unwrap();
420
421        match cli.command.unwrap() {
422            Commands::Server { bind_address, port } => {
423                assert_eq!(bind_address, "0.0.0.0");
424                assert_eq!(port, 3000);
425            }
426            _ => panic!("Wrong command parsed"),
427        }
428    }
429
430    #[test]
431    fn test_cli_mcp_parsing() {
432        let cli = Cli::try_parse_from(&["garnix-insights", "mcp"]).unwrap();
433
434        assert!(matches!(cli.command.unwrap(), Commands::Mcp));
435    }
436
437    #[test]
438    fn test_cli_validate_token_parsing() {
439        let cli = Cli::try_parse_from(&[
440            "garnix-insights",
441            "validate-token",
442            "--jwt-token",
443            "test-token",
444        ])
445        .unwrap();
446
447        match cli.command.unwrap() {
448            Commands::ValidateToken { jwt_token } => {
449                assert_eq!(jwt_token, "test-token");
450            }
451            _ => panic!("Wrong command parsed"),
452        }
453    }
454
455    #[test]
456    fn test_cli_logs_parsing() {
457        let cli = Cli::try_parse_from(&[
458            "garnix-insights",
459            "logs",
460            "--jwt-token",
461            "test-token",
462            "--build-id",
463            "build123",
464        ])
465        .unwrap();
466
467        match cli.command.unwrap() {
468            Commands::Logs {
469                jwt_token,
470                build_id,
471            } => {
472                assert_eq!(jwt_token, "test-token");
473                assert_eq!(build_id, "build123");
474            }
475            _ => panic!("Wrong command parsed"),
476        }
477    }
478
479    #[test]
480    fn test_output_format_parsing() {
481        let cli = Cli::try_parse_from(&[
482            "garnix-insights",
483            "--format",
484            "json",
485            "fetch",
486            "--jwt-token",
487            "test",
488            "--commit-id",
489            "6e1f5a8c3d9b2e7a4f8c1e6a9d5b2f7a4c8e1b6d",
490        ])
491        .unwrap();
492
493        assert!(matches!(cli.format, OutputFormat::Json));
494    }
495
496    #[test]
497    fn test_verbose_flag() {
498        let cli = Cli::try_parse_from(&["garnix-insights", "--verbose", "mcp"]).unwrap();
499
500        assert!(cli.verbose);
501    }
502
503    #[test]
504    fn test_print_human_readable() {
505        let cli = Cli::try_parse_from(&["garnix-insights", "mcp"]).unwrap();
506        let response = create_test_response();
507
508        // This test mainly ensures the method doesn't panic
509        // In a real test environment, you might capture stdout to verify output
510        cli.print_human_readable(&response);
511    }
512
513    #[test]
514    fn test_print_plain_text() {
515        let cli = Cli::try_parse_from(&["garnix-insights", "mcp"]).unwrap();
516        let response = create_test_response();
517
518        // This test mainly ensures the method doesn't panic
519        cli.print_plain_text(&response);
520    }
521
522    #[test]
523    fn test_json_format_serialization() {
524        let response = create_test_response();
525
526        // Test that the response can be serialised to JSON without error
527        let json_result = serde_json::to_string_pretty(&response);
528        assert!(json_result.is_ok());
529
530        let json_string = json_result.unwrap();
531        assert!(json_string.contains("7a2f5e9c1b4d8a3e6f2a9e5c8b1d4f7a3c6e9b2d")); // commit ID
532        assert!(json_string.contains("testuser")); // repo owner
533    }
534
535    #[test]
536    fn test_cli_structure() {
537        let cli = Cli {
538            jwt_token: Some("token".to_string()),
539            commit_id: Some("8b3e6f1a4c9d2e7a5f8b1e4a7c2f5e9a6b3d8f1a".to_string()),
540            verbose: true,
541            format: OutputFormat::Json,
542            command: Some(Commands::Mcp),
543        };
544
545        assert!(cli.verbose);
546        assert!(matches!(cli.format, OutputFormat::Json));
547        assert_eq!(cli.jwt_token, Some("token".to_string()));
548        assert_eq!(
549            cli.commit_id,
550            Some("8b3e6f1a4c9d2e7a5f8b1e4a7c2f5e9a6b3d8f1a".to_string())
551        );
552    }
553
554    #[tokio::test]
555    async fn test_cli_error_handling() {
556        let cli = Cli {
557            jwt_token: None,
558            commit_id: Some("test123".to_string()),
559            verbose: false,
560            format: OutputFormat::Human,
561            command: Some(Commands::Fetch {
562                jwt_token: None,
563                commit_id: "test123".to_string(),
564            }),
565        };
566
567        // This should fail due to missing JWT token
568        let result = cli.run().await;
569        assert!(result.is_err());
570    }
571
572    #[test]
573    fn test_output_format_variants() {
574        // Test that all variants can be created
575        let _human = OutputFormat::Human;
576        let _json = OutputFormat::Json;
577        let _plain = OutputFormat::Plain;
578
579        // Test Debug trait
580        assert!(!format!("{:?}", OutputFormat::Human).is_empty());
581        assert!(!format!("{:?}", OutputFormat::Json).is_empty());
582        assert!(!format!("{:?}", OutputFormat::Plain).is_empty());
583    }
584}