nblm_core/doctor/
checks.rs

1use colored::Colorize;
2use std::env;
3
4use crate::auth::{ensure_drive_scope, EnvTokenProvider};
5use crate::error::Error;
6
7/// Status of a diagnostic check
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum CheckStatus {
10    Pass,
11    Warning,
12    Error,
13}
14
15impl CheckStatus {
16    /// Convert status to exit code contribution
17    pub fn exit_code(&self) -> i32 {
18        match self {
19            CheckStatus::Pass => 0,
20            CheckStatus::Warning => 1,
21            CheckStatus::Error => 2,
22        }
23    }
24
25    /// Convert status to ASCII marker with aligned label
26    pub fn as_marker(&self) -> String {
27        let label = match self {
28            CheckStatus::Pass => "ok",
29            CheckStatus::Warning => "warn",
30            CheckStatus::Error => "error",
31        };
32        let total_width = "error".len() + 2; // include brackets
33        format!("{:>width$}", format!("[{}]", label), width = total_width)
34    }
35
36    /// Convert status to colored marker using the colored crate
37    pub fn as_marker_colored(&self) -> String {
38        let marker = self.as_marker();
39        match self {
40            CheckStatus::Pass => marker.green(),
41            CheckStatus::Warning => marker.yellow(),
42            CheckStatus::Error => marker.red(),
43        }
44        .to_string()
45    }
46}
47
48/// Result of a single diagnostic check
49#[derive(Debug, Clone)]
50pub struct CheckResult {
51    pub name: String,
52    pub status: CheckStatus,
53    pub message: String,
54    pub suggestion: Option<String>,
55}
56
57impl CheckResult {
58    pub fn new(name: impl Into<String>, status: CheckStatus, message: impl Into<String>) -> Self {
59        Self {
60            name: name.into(),
61            status,
62            message: message.into(),
63            suggestion: None,
64        }
65    }
66
67    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
68        self.suggestion = Some(suggestion.into());
69        self
70    }
71
72    /// Format check result for display
73    pub fn format(&self) -> String {
74        self.format_with_marker(self.status.as_marker())
75    }
76
77    /// Format check result for display with colored markers
78    pub fn format_colored(&self) -> String {
79        self.format_with_marker(self.status.as_marker_colored())
80    }
81
82    fn format_with_marker(&self, marker: String) -> String {
83        let mut output = format!("{} {}", marker, self.message);
84        if let Some(suggestion) = &self.suggestion {
85            output.push_str(&format!("\n       Suggestion: {}", suggestion));
86        }
87        output
88    }
89}
90
91/// Summary of all diagnostic checks
92#[derive(Debug)]
93pub struct DiagnosticsSummary {
94    pub checks: Vec<CheckResult>,
95}
96
97impl DiagnosticsSummary {
98    pub fn new(checks: Vec<CheckResult>) -> Self {
99        Self { checks }
100    }
101
102    /// Calculate the overall exit code
103    pub fn exit_code(&self) -> i32 {
104        self.checks
105            .iter()
106            .map(|check| check.status.exit_code())
107            .max()
108            .unwrap_or(0)
109    }
110
111    /// Count checks by status
112    pub fn count_by_status(&self, status: CheckStatus) -> usize {
113        self.checks
114            .iter()
115            .filter(|check| check.status == status)
116            .count()
117    }
118
119    /// Format summary for display
120    pub fn format_summary(&self) -> String {
121        let total = self.checks.len();
122        let failed =
123            self.count_by_status(CheckStatus::Error) + self.count_by_status(CheckStatus::Warning);
124
125        if failed == 0 {
126            format!("\nSummary: All {} checks passed.", total)
127        } else {
128            format!(
129                "\nSummary: {} checks failing out of {}. See above for details.",
130                failed, total
131            )
132        }
133    }
134
135    /// Format summary for display with color
136    pub fn format_summary_colored(&self) -> String {
137        let total = self.checks.len();
138        let failed =
139            self.count_by_status(CheckStatus::Error) + self.count_by_status(CheckStatus::Warning);
140
141        if failed == 0 {
142            format!(
143                "\n{}",
144                format!("Summary: All {} checks passed.", total).green()
145            )
146        } else {
147            format!(
148                "\n{}",
149                format!(
150                    "Summary: {} checks failing out of {}. See above for details.",
151                    failed, total
152                )
153                .yellow()
154            )
155        }
156    }
157}
158
159/// Configuration for an environment variable check
160pub struct EnvVarCheck {
161    pub name: &'static str,
162    pub required: bool,
163    pub suggestion: &'static str,
164    pub show_value: bool,
165}
166
167/// Static configuration table for environment variable checks
168const ENV_VAR_CHECKS: &[EnvVarCheck] = &[
169    EnvVarCheck {
170        name: "NBLM_PROJECT_NUMBER",
171        required: true,
172        suggestion: "export NBLM_PROJECT_NUMBER=<your-project-number>",
173        show_value: true,
174    },
175    EnvVarCheck {
176        name: "NBLM_ENDPOINT_LOCATION",
177        required: false,
178        suggestion: "export NBLM_ENDPOINT_LOCATION=us  # or 'eu' or 'global'",
179        show_value: true,
180    },
181    EnvVarCheck {
182        name: "NBLM_LOCATION",
183        required: false,
184        suggestion: "export NBLM_LOCATION=global",
185        show_value: true,
186    },
187    EnvVarCheck {
188        name: "NBLM_ACCESS_TOKEN",
189        required: false,
190        suggestion: "export NBLM_ACCESS_TOKEN=$(gcloud auth print-access-token)",
191        show_value: false,
192    },
193];
194
195/// Check a single environment variable
196fn check_env_var(config: &EnvVarCheck) -> CheckResult {
197    match env::var(config.name) {
198        Ok(value) if !value.is_empty() => {
199            let message = if config.show_value {
200                format!("{}={}", config.name, value)
201            } else {
202                format!("{} set (value hidden)", config.name)
203            };
204            CheckResult::new(
205                format!("env_var_{}", config.name.to_lowercase()),
206                CheckStatus::Pass,
207                message,
208            )
209        }
210        Ok(_) | Err(env::VarError::NotPresent) => {
211            let status = if config.required {
212                CheckStatus::Error
213            } else {
214                CheckStatus::Warning
215            };
216            CheckResult::new(
217                format!("env_var_{}", config.name.to_lowercase()),
218                status,
219                format!("{} missing", config.name),
220            )
221            .with_suggestion(config.suggestion)
222        }
223        Err(env::VarError::NotUnicode(_)) => CheckResult::new(
224            format!("env_var_{}", config.name.to_lowercase()),
225            CheckStatus::Error,
226            format!("{} contains invalid UTF-8", config.name),
227        ),
228    }
229}
230
231/// Run all environment variable checks
232pub fn check_environment_variables() -> Vec<CheckResult> {
233    ENV_VAR_CHECKS.iter().map(check_env_var).collect()
234}
235
236/// Configuration for a command availability check
237pub struct CommandCheck {
238    pub name: &'static str,
239    pub command: &'static str,
240    pub required: bool,
241    pub suggestion: &'static str,
242}
243
244/// Static configuration table for command checks
245const COMMAND_CHECKS: &[CommandCheck] = &[CommandCheck {
246    name: "gcloud",
247    command: "gcloud",
248    required: false,
249    suggestion: "Install Google Cloud CLI: https://cloud.google.com/sdk/docs/install",
250}];
251
252/// Check if a command is available in PATH
253fn check_command(config: &CommandCheck) -> CheckResult {
254    let status = std::process::Command::new(config.command)
255        .arg("--version")
256        .output();
257
258    match status {
259        Ok(output) if output.status.success() => {
260            let version = String::from_utf8_lossy(&output.stdout);
261            let version_line = version.lines().next().unwrap_or("").trim();
262            CheckResult::new(
263                format!("command_{}", config.name),
264                CheckStatus::Pass,
265                format!("{} is installed ({})", config.name, version_line),
266            )
267        }
268        _ => {
269            let status = if config.required {
270                CheckStatus::Error
271            } else {
272                CheckStatus::Warning
273            };
274            CheckResult::new(
275                format!("command_{}", config.name),
276                status,
277                format!("{} command not found", config.name),
278            )
279            .with_suggestion(config.suggestion)
280        }
281    }
282}
283
284/// Run all command availability checks
285pub fn check_commands() -> Vec<CheckResult> {
286    COMMAND_CHECKS.iter().map(check_command).collect()
287}
288
289/// Validate that `NBLM_ACCESS_TOKEN`, when present, grants Google Drive access.
290pub async fn check_drive_access_token() -> Vec<CheckResult> {
291    match env::var("NBLM_ACCESS_TOKEN") {
292        Ok(value) if !value.trim().is_empty() => {
293            let provider = EnvTokenProvider::new("NBLM_ACCESS_TOKEN");
294            match ensure_drive_scope(&provider).await {
295                Ok(_) => vec![CheckResult::new(
296                    "drive_scope_nblm_access_token",
297                    CheckStatus::Pass,
298                    "NBLM_ACCESS_TOKEN grants Google Drive access",
299                )],
300                Err(Error::TokenProvider(message)) => {
301                    if message.contains("missing the required drive.file scope") {
302                        vec![CheckResult::new(
303                            "drive_scope_nblm_access_token",
304                            CheckStatus::Warning,
305                            "NBLM_ACCESS_TOKEN lacks Google Drive scope",
306                        )
307                        .with_suggestion(
308                            "Run `gcloud auth login --enable-gdrive-access` and refresh NBLM_ACCESS_TOKEN",
309                        )]
310                    } else {
311                        vec![CheckResult::new(
312                            "drive_scope_nblm_access_token",
313                            CheckStatus::Warning,
314                            format!(
315                                "Could not confirm Google Drive scope for NBLM_ACCESS_TOKEN: {}",
316                                message
317                            ),
318                        )]
319                    }
320                }
321                Err(err) => vec![CheckResult::new(
322                    "drive_scope_nblm_access_token",
323                    CheckStatus::Warning,
324                    format!(
325                        "Could not confirm Google Drive scope for NBLM_ACCESS_TOKEN: {}",
326                        err
327                    ),
328                )],
329            }
330        }
331        _ => Vec::new(),
332    }
333}
334
335/// Check NotebookLM API connectivity by calling list_recently_viewed
336pub async fn check_api_connectivity() -> Vec<CheckResult> {
337    use crate::auth::GcloudTokenProvider;
338    use crate::client::NblmClient;
339    use crate::env::EnvironmentConfig;
340    use std::sync::Arc;
341
342    // Skip if required environment variables are missing
343    let project_number = match env::var("NBLM_PROJECT_NUMBER") {
344        Ok(val) if !val.is_empty() => val,
345        _ => {
346            // Don't report error here - env var check already handles this
347            return Vec::new();
348        }
349    };
350
351    let location = env::var("NBLM_LOCATION").unwrap_or_else(|_| "global".to_string());
352    let endpoint_location =
353        env::var("NBLM_ENDPOINT_LOCATION").unwrap_or_else(|_| "global".to_string());
354
355    // Try to construct environment config
356    let env_config =
357        match EnvironmentConfig::enterprise(project_number, location, endpoint_location) {
358            Ok(config) => config,
359            Err(err) => {
360                return vec![CheckResult::new(
361                "api_connectivity",
362                CheckStatus::Error,
363                format!("Cannot construct environment config: {}", err),
364            )
365            .with_suggestion(
366                "Ensure NBLM_PROJECT_NUMBER, NBLM_LOCATION, and NBLM_ENDPOINT_LOCATION are valid",
367            )];
368            }
369        };
370
371    // Create token provider - only use gcloud if NBLM_ACCESS_TOKEN is not set
372    let token_provider: Arc<dyn crate::auth::TokenProvider> =
373        match env::var("NBLM_ACCESS_TOKEN").ok().filter(|s| !s.is_empty()) {
374            Some(_) => Arc::new(crate::auth::EnvTokenProvider::new("NBLM_ACCESS_TOKEN")),
375            None => {
376                // Skip API check if gcloud is not available to avoid interactive prompts
377                if !is_gcloud_available() {
378                    return Vec::new();
379                }
380                Arc::new(GcloudTokenProvider::new("gcloud"))
381            }
382        };
383
384    // Try to create client
385    let client = match NblmClient::new(token_provider, env_config) {
386        Ok(client) => client,
387        Err(err) => {
388            return vec![CheckResult::new(
389                "api_connectivity",
390                CheckStatus::Error,
391                format!("Failed to create API client: {}", err),
392            )
393            .with_suggestion("Check your environment configuration and credentials")];
394        }
395    };
396
397    // Try to call list_recently_viewed
398    match client.list_recently_viewed(Some(1)).await {
399        Ok(_) => vec![CheckResult::new(
400            "api_connectivity",
401            CheckStatus::Pass,
402            "Successfully connected to NotebookLM API",
403        )],
404        Err(err) => {
405            let err_string = err.to_string();
406            let (status, message, suggestion) = categorize_api_error(&err_string);
407
408            vec![CheckResult::new("api_connectivity", status, message).with_suggestion(suggestion)]
409        }
410    }
411}
412
413/// Check if gcloud command is available
414fn is_gcloud_available() -> bool {
415    std::process::Command::new("gcloud")
416        .arg("--version")
417        .output()
418        .map(|output| output.status.success())
419        .unwrap_or(false)
420}
421
422/// Categorize API errors and provide actionable suggestions
423fn categorize_api_error(error: &str) -> (CheckStatus, String, &'static str) {
424    let error_lower = error.to_lowercase();
425
426    match () {
427        _ if error_lower.contains("401") || error_lower.contains("unauthorized") => (
428            CheckStatus::Error,
429            "Authentication failed (401 Unauthorized)".to_string(),
430            "Run `gcloud auth login` or `gcloud auth application-default login`",
431        ),
432        _ if error_lower.contains("403") || error_lower.contains("permission denied") => (
433            CheckStatus::Error,
434            "Permission denied (403 Forbidden)".to_string(),
435            "Ensure your account has NotebookLM API access and required IAM roles (e.g., aiplatform.user)",
436        ),
437        _ if error_lower.contains("404") || error_lower.contains("not found") => (
438            CheckStatus::Error,
439            "Resource not found (404)".to_string(),
440            "Verify NBLM_PROJECT_NUMBER is correct and the project has NotebookLM enabled",
441        ),
442        _
443            if error_lower.contains("timeout")
444                || error_lower.contains("connection")
445                || error_lower.contains("network") =>
446        {
447            (
448                CheckStatus::Error,
449                format!("Network error: {}", error),
450                "Check your internet connection and firewall settings",
451            )
452        }
453        _ => (
454            CheckStatus::Error,
455            format!("API error: {}", error),
456            "Check the error message above and your configuration",
457        ),
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use serial_test::serial;
465    use wiremock::matchers::{method, path, query_param};
466    use wiremock::{Mock, MockServer, ResponseTemplate};
467
468    struct EnvGuard {
469        key: &'static str,
470        original: Option<String>,
471    }
472
473    impl EnvGuard {
474        fn new(key: &'static str) -> Self {
475            let original = env::var(key).ok();
476            Self { key, original }
477        }
478    }
479
480    impl Drop for EnvGuard {
481        fn drop(&mut self) {
482            if let Some(value) = &self.original {
483                env::set_var(self.key, value);
484            } else {
485                env::remove_var(self.key);
486            }
487        }
488    }
489
490    #[test]
491    fn test_check_status_markers() {
492        assert_eq!(CheckStatus::Pass.as_marker(), "   [ok]");
493        assert_eq!(CheckStatus::Warning.as_marker(), " [warn]");
494        assert_eq!(CheckStatus::Error.as_marker(), "[error]");
495    }
496
497    #[test]
498    fn test_check_status_colored_markers() {
499        // Force colored output in tests (OK if target ignores it)
500        colored::control::set_override(true);
501
502        // Verify colored markers still include status labels
503        let ok = CheckStatus::Pass.as_marker_colored();
504        assert!(ok.contains("[ok]"));
505
506        let warn = CheckStatus::Warning.as_marker_colored();
507        assert!(warn.contains("[warn]"));
508
509        let err = CheckStatus::Error.as_marker_colored();
510        assert!(err.contains("[error]"));
511
512        // Reset override
513        colored::control::unset_override();
514    }
515
516    #[test]
517    fn test_check_status_exit_codes() {
518        assert_eq!(CheckStatus::Pass.exit_code(), 0);
519        assert_eq!(CheckStatus::Warning.exit_code(), 1);
520        assert_eq!(CheckStatus::Error.exit_code(), 2);
521    }
522
523    #[test]
524    fn test_check_result_format() {
525        let result = CheckResult::new("test", CheckStatus::Pass, "Test passed");
526        assert_eq!(result.format(), "   [ok] Test passed");
527
528        let result_with_suggestion = CheckResult::new("test", CheckStatus::Warning, "Test warning")
529            .with_suggestion("Try this fix");
530        assert!(result_with_suggestion.format().contains("Suggestion:"));
531    }
532
533    #[test]
534    fn test_check_result_format_colored() {
535        // Force colored output in tests
536        colored::control::set_override(true);
537
538        let result = CheckResult::new("test", CheckStatus::Pass, "Test passed");
539        let colored = result.format_colored();
540        assert!(colored.contains("\x1b["));
541        assert!(colored.contains("Test passed"));
542        assert!(colored.ends_with("Test passed"));
543
544        // Reset override
545        colored::control::unset_override();
546    }
547
548    #[test]
549    fn test_diagnostics_summary_exit_code() {
550        let summary = DiagnosticsSummary::new(vec![
551            CheckResult::new("test1", CheckStatus::Pass, "Pass"),
552            CheckResult::new("test2", CheckStatus::Pass, "Pass"),
553        ]);
554        assert_eq!(summary.exit_code(), 0);
555
556        let summary = DiagnosticsSummary::new(vec![
557            CheckResult::new("test1", CheckStatus::Pass, "Pass"),
558            CheckResult::new("test2", CheckStatus::Warning, "Warning"),
559        ]);
560        assert_eq!(summary.exit_code(), 1);
561
562        let summary = DiagnosticsSummary::new(vec![
563            CheckResult::new("test1", CheckStatus::Pass, "Pass"),
564            CheckResult::new("test2", CheckStatus::Error, "Error"),
565        ]);
566        assert_eq!(summary.exit_code(), 2);
567    }
568
569    #[test]
570    fn test_check_env_var_present() {
571        env::set_var("TEST_VAR", "test_value");
572        let config = EnvVarCheck {
573            name: "TEST_VAR",
574            required: true,
575            suggestion: "export TEST_VAR=value",
576            show_value: true,
577        };
578        let result = check_env_var(&config);
579        assert_eq!(result.status, CheckStatus::Pass);
580        assert!(result.message.contains("test_value"));
581        env::remove_var("TEST_VAR");
582    }
583
584    #[test]
585    fn test_check_env_var_missing_required() {
586        env::remove_var("MISSING_VAR");
587        let config = EnvVarCheck {
588            name: "MISSING_VAR",
589            required: true,
590            suggestion: "export MISSING_VAR=value",
591            show_value: true,
592        };
593        let result = check_env_var(&config);
594        assert_eq!(result.status, CheckStatus::Error);
595        assert!(result.message.contains("missing"));
596        assert!(result.suggestion.is_some());
597    }
598
599    #[test]
600    fn test_check_env_var_missing_optional() {
601        env::remove_var("OPTIONAL_VAR");
602        let config = EnvVarCheck {
603            name: "OPTIONAL_VAR",
604            required: false,
605            suggestion: "export OPTIONAL_VAR=value",
606            show_value: true,
607        };
608        let result = check_env_var(&config);
609        assert_eq!(result.status, CheckStatus::Warning);
610        assert!(result.message.contains("missing"));
611    }
612
613    #[test]
614    fn test_check_command_not_found() {
615        let config = CommandCheck {
616            name: "nonexistent_command_xyz",
617            command: "nonexistent_command_xyz",
618            required: false,
619            suggestion: "Install the command",
620        };
621        let result = check_command(&config);
622        assert_eq!(result.status, CheckStatus::Warning);
623        assert!(result.message.contains("not found"));
624        assert!(result.suggestion.is_some());
625    }
626
627    #[test]
628    fn test_check_command_required_not_found() {
629        let config = CommandCheck {
630            name: "nonexistent_required",
631            command: "nonexistent_required",
632            required: true,
633            suggestion: "Install the command",
634        };
635        let result = check_command(&config);
636        assert_eq!(result.status, CheckStatus::Error);
637        assert!(result.message.contains("not found"));
638    }
639
640    #[tokio::test]
641    #[serial]
642    async fn test_drive_access_check_passes_with_valid_scope() {
643        let token_guard = EnvGuard::new("NBLM_ACCESS_TOKEN");
644        let endpoint_guard = EnvGuard::new("NBLM_TOKENINFO_ENDPOINT");
645
646        env::set_var("NBLM_ACCESS_TOKEN", "test-token");
647
648        let server = MockServer::start().await;
649        let tokeninfo_url = format!("{}/tokeninfo", server.uri());
650        env::set_var("NBLM_TOKENINFO_ENDPOINT", &tokeninfo_url);
651
652        Mock::given(method("GET"))
653            .and(path("/tokeninfo"))
654            .and(query_param("access_token", "test-token"))
655            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
656                "scope": "https://www.googleapis.com/auth/drive.file"
657            })))
658            .expect(1)
659            .mount(&server)
660            .await;
661
662        let results = check_drive_access_token().await;
663        assert_eq!(results.len(), 1);
664        assert_eq!(results[0].status, CheckStatus::Pass);
665        assert!(results[0].message.contains("grants Google Drive access"));
666
667        drop(token_guard);
668        drop(endpoint_guard);
669    }
670
671    #[tokio::test]
672    #[serial]
673    async fn test_drive_access_check_reports_missing_scope() {
674        let token_guard = EnvGuard::new("NBLM_ACCESS_TOKEN");
675        let endpoint_guard = EnvGuard::new("NBLM_TOKENINFO_ENDPOINT");
676
677        env::set_var("NBLM_ACCESS_TOKEN", "test-token");
678
679        let server = MockServer::start().await;
680        let tokeninfo_url = format!("{}/tokeninfo", server.uri());
681        env::set_var("NBLM_TOKENINFO_ENDPOINT", &tokeninfo_url);
682
683        Mock::given(method("GET"))
684            .and(path("/tokeninfo"))
685            .and(query_param("access_token", "test-token"))
686            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
687                "scope": "https://www.googleapis.com/auth/cloud-platform"
688            })))
689            .expect(1)
690            .mount(&server)
691            .await;
692
693        let results = check_drive_access_token().await;
694        assert_eq!(results.len(), 1);
695        assert_eq!(results[0].status, CheckStatus::Warning);
696        assert!(results[0].message.contains("lacks Google Drive scope"));
697
698        drop(token_guard);
699        drop(endpoint_guard);
700    }
701
702    #[test]
703    fn test_categorize_api_error_401() {
704        let (status, message, suggestion) = categorize_api_error("401 Unauthorized");
705        assert_eq!(status, CheckStatus::Error);
706        assert!(message.contains("Authentication failed"));
707        assert!(suggestion.contains("gcloud auth login"));
708    }
709
710    #[test]
711    fn test_categorize_api_error_403() {
712        let (status, message, suggestion) = categorize_api_error("403 Permission denied");
713        assert_eq!(status, CheckStatus::Error);
714        assert!(message.contains("Permission denied"));
715        assert!(suggestion.contains("IAM roles"));
716    }
717
718    #[test]
719    fn test_categorize_api_error_404() {
720        let (status, message, suggestion) = categorize_api_error("404 Not found");
721        assert_eq!(status, CheckStatus::Error);
722        assert!(message.contains("Resource not found"));
723        assert!(suggestion.contains("NBLM_PROJECT_NUMBER"));
724    }
725
726    #[test]
727    fn test_categorize_api_error_timeout() {
728        let (status, message, suggestion) = categorize_api_error("Connection timeout");
729        assert_eq!(status, CheckStatus::Error);
730        assert!(message.contains("Network error"));
731        assert!(suggestion.contains("internet connection"));
732    }
733
734    #[test]
735    fn test_categorize_api_error_generic() {
736        let (status, message, suggestion) = categorize_api_error("Some random error");
737        assert_eq!(status, CheckStatus::Error);
738        assert!(message.contains("API error"));
739        assert!(suggestion.contains("configuration"));
740    }
741
742    #[tokio::test]
743    #[serial]
744    async fn test_check_api_connectivity_missing_project_number() {
745        let _guard = EnvGuard::new("NBLM_PROJECT_NUMBER");
746        env::remove_var("NBLM_PROJECT_NUMBER");
747
748        let results = check_api_connectivity().await;
749        assert_eq!(results.len(), 0);
750    }
751
752    #[test]
753    fn test_is_gcloud_available() {
754        // This test just verifies that is_gcloud_available() doesn't panic
755        // The actual result depends on whether gcloud is installed
756        let _ = is_gcloud_available();
757    }
758
759    #[test]
760    fn test_diagnostics_summary_count_by_status() {
761        let summary = DiagnosticsSummary::new(vec![
762            CheckResult::new("test1", CheckStatus::Pass, "Pass"),
763            CheckResult::new("test2", CheckStatus::Warning, "Warning"),
764            CheckResult::new("test3", CheckStatus::Error, "Error"),
765            CheckResult::new("test4", CheckStatus::Pass, "Pass"),
766        ]);
767
768        assert_eq!(summary.count_by_status(CheckStatus::Pass), 2);
769        assert_eq!(summary.count_by_status(CheckStatus::Warning), 1);
770        assert_eq!(summary.count_by_status(CheckStatus::Error), 1);
771    }
772
773    #[test]
774    fn test_diagnostics_summary_format() {
775        let summary = DiagnosticsSummary::new(vec![
776            CheckResult::new("test1", CheckStatus::Pass, "Pass"),
777            CheckResult::new("test2", CheckStatus::Pass, "Pass"),
778        ]);
779        let formatted = summary.format_summary();
780        assert!(formatted.contains("All 2 checks passed"));
781
782        let summary_with_failures = DiagnosticsSummary::new(vec![
783            CheckResult::new("test1", CheckStatus::Pass, "Pass"),
784            CheckResult::new("test2", CheckStatus::Warning, "Warning"),
785        ]);
786        let formatted_fail = summary_with_failures.format_summary();
787        assert!(formatted_fail.contains("1 checks failing out of 2"));
788    }
789
790    #[test]
791    fn test_check_result_with_suggestion() {
792        let result = CheckResult::new("test", CheckStatus::Warning, "Something wrong")
793            .with_suggestion("Fix it this way");
794
795        assert_eq!(result.suggestion, Some("Fix it this way".to_string()));
796        assert!(result.format().contains("Suggestion: Fix it this way"));
797    }
798
799    #[test]
800    fn test_check_env_var_hidden_value() {
801        env::set_var("SECRET_VAR", "secret_value");
802        let config = EnvVarCheck {
803            name: "SECRET_VAR",
804            required: true,
805            suggestion: "export SECRET_VAR=value",
806            show_value: false,
807        };
808        let result = check_env_var(&config);
809        assert_eq!(result.status, CheckStatus::Pass);
810        assert!(result.message.contains("value hidden"));
811        assert!(!result.message.contains("secret_value"));
812        env::remove_var("SECRET_VAR");
813    }
814
815    #[test]
816    fn test_check_environment_variables_integration() {
817        let results = check_environment_variables();
818        // Should return results for all configured env vars
819        assert_eq!(results.len(), ENV_VAR_CHECKS.len());
820    }
821
822    #[test]
823    fn test_check_commands_integration() {
824        let results = check_commands();
825        // Should return results for all configured commands
826        assert_eq!(results.len(), COMMAND_CHECKS.len());
827    }
828}