1use 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#[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 #[command(subcommand)]
18 pub command: Option<Commands>,
19
20 #[arg(long, env = "GARNIX_JWT_TOKEN")]
22 pub jwt_token: Option<String>,
23
24 #[arg(long)]
26 pub commit_id: Option<String>,
27
28 #[arg(short, long)]
30 pub verbose: bool,
31
32 #[arg(long, default_value = "human")]
34 pub format: OutputFormat,
35}
36
37#[derive(clap::ValueEnum, Debug, Clone)]
39pub enum OutputFormat {
40 Human,
42 Json,
44 Plain,
46}
47
48#[derive(Subcommand, Debug)]
50pub enum Commands {
51 Fetch {
53 #[arg(long, env = "GARNIX_JWT_TOKEN")]
55 jwt_token: Option<String>,
56 #[arg(long)]
58 commit_id: String,
59 },
60 Server {
62 #[arg(long, default_value = "127.0.0.1")]
64 bind_address: String,
65 #[arg(long, default_value = "8080")]
67 port: u16,
68 },
69 Mcp,
71 ValidateToken {
73 #[arg(long, env = "GARNIX_JWT_TOKEN")]
75 jwt_token: String,
76 },
77 Logs {
79 #[arg(long, env = "GARNIX_JWT_TOKEN")]
81 jwt_token: String,
82 #[arg(long)]
84 build_id: String,
85 },
86}
87
88impl Cli {
89 pub fn parse_args() -> Self {
91 Self::parse()
92 }
93
94 pub async fn run(self) -> GarnixResult<()> {
96 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 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 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 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 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 fn print_human_readable(&self, response: &crate::types::GarnixResponse) {
251 use crate::format_build_summary;
252
253 println!("{}", format_build_summary(response));
254
255 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 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 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 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 cli.print_plain_text(&response);
520 }
521
522 #[test]
523 fn test_json_format_serialization() {
524 let response = create_test_response();
525
526 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")); assert!(json_string.contains("testuser")); }
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 let result = cli.run().await;
569 assert!(result.is_err());
570 }
571
572 #[test]
573 fn test_output_format_variants() {
574 let _human = OutputFormat::Human;
576 let _json = OutputFormat::Json;
577 let _plain = OutputFormat::Plain;
578
579 assert!(!format!("{:?}", OutputFormat::Human).is_empty());
581 assert!(!format!("{:?}", OutputFormat::Json).is_empty());
582 assert!(!format!("{:?}", OutputFormat::Plain).is_empty());
583 }
584}