diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 19:32:43 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 19:32:43 +0900 |
| commit | 2a2e800100cdaf3fc5f74c3a00ecb73e9cac07f3 (patch) | |
| tree | d13e3643b9db1324edaa97b244587e8d36f396bb /crates/mozart/src | |
| parent | bde82d81e70b01fa9e822ff6dfe1d5c761d46c09 (diff) | |
| download | php-mozart-2a2e800100cdaf3fc5f74c3a00ecb73e9cac07f3.tar.gz php-mozart-2a2e800100cdaf3fc5f74c3a00ecb73e9cac07f3.tar.zst php-mozart-2a2e800100cdaf3fc5f74c3a00ecb73e9cac07f3.zip | |
feat(diagnose): implement diagnose command to check system health and connectivity
Adds 10 diagnostic checks: Mozart version, HTTPS/HTTP Packagist connectivity,
GitHub API, HTTP proxy detection, composer.json validation, composer.lock
freshness, git availability/version, disk free space, and cache directory
writability. Exit codes: 0=clean, 1=warnings, 2=errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src')
| -rw-r--r-- | crates/mozart/src/commands/diagnose.rs | 747 |
1 files changed, 745 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/diagnose.rs b/crates/mozart/src/commands/diagnose.rs index 648e16d..51f2127 100644 --- a/crates/mozart/src/commands/diagnose.rs +++ b/crates/mozart/src/commands/diagnose.rs @@ -1,8 +1,751 @@ use clap::Args; +use colored::Colorize; +use std::path::{Path, PathBuf}; #[derive(Args)] pub struct DiagnoseArgs {} -pub fn execute(_args: &DiagnoseArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +// ─── Check result ───────────────────────────────────────────────────────────── + +enum CheckResult { + /// OK, with optional detail string. + Ok(Option<String>), + /// WARNING + message. + Warning(String), + /// FAIL + message. + Fail(String), + /// SKIP + reason. + Skip(String), + /// Informational line (no pass/fail prefix). + Info(String), +} + +// ─── Output helpers ─────────────────────────────────────────────────────────── + +/// Print "Checking {label}: OK/WARNING/FAIL/SKIP" and ratchet exit_code. +/// +/// Exit code ratchet: Warning → 1 (if currently 0), Fail → 2 (always overrides 1). +fn print_check(label: &str, result: &CheckResult, exit_code: &mut i32) { + match result { + CheckResult::Ok(detail) => { + let ok_str = "OK".green().bold(); + match detail { + Some(d) => println!("Checking {label}: {ok_str} ({d})"), + None => println!("Checking {label}: {ok_str}"), + } + } + CheckResult::Warning(msg) => { + let warn_str = "WARNING".yellow().bold(); + println!("Checking {label}: {warn_str}"); + println!(" {}", msg.yellow()); + if *exit_code < 1 { + *exit_code = 1; + } + } + CheckResult::Fail(msg) => { + let fail_str = "FAIL".red().bold(); + println!("Checking {label}: {fail_str}"); + println!(" {}", msg.red()); + *exit_code = 2; + } + CheckResult::Skip(reason) => { + let skip_str = "SKIP".cyan().bold(); + println!("Checking {label}: {skip_str} ({reason})"); + } + CheckResult::Info(_) => { + // Info results are not "checked" — use print_info_line instead. + } + } +} + +/// Print an informational line (not a check result). +fn print_info_line(result: &CheckResult) { + if let CheckResult::Info(msg) = result { + println!("{msg}"); + } +} + +// ─── Individual checks ──────────────────────────────────────────────────────── + +/// Check 1: Mozart version info (informational). +fn check_version() -> CheckResult { + let version = env!("CARGO_PKG_VERSION"); + CheckResult::Info(format!("Mozart version {version}")) +} + +/// Check 2 & 3: HTTP/HTTPS connectivity to Packagist. +/// +/// Returns Ok if reachable, Fail if not, Skip if network is disabled. +fn check_http_connectivity(url: &str) -> CheckResult { + if std::env::var("COMPOSER_DISABLE_NETWORK").is_ok() { + return CheckResult::Skip("COMPOSER_DISABLE_NETWORK is set".to_string()); + } + + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(concat!("mozart/", env!("CARGO_PKG_VERSION"))) + .build() + { + Ok(c) => c, + Err(e) => return CheckResult::Fail(format!("Could not build HTTP client: {e}")), + }; + + match client.get(url).send() { + Ok(resp) => { + let status = resp.status(); + if status.is_success() || status.is_redirection() { + CheckResult::Ok(Some(format!("HTTP {}", status.as_u16()))) + } else { + CheckResult::Warning(format!("Received HTTP {} from {url}", status.as_u16())) + } + } + Err(e) => CheckResult::Fail(format!("Could not reach {url}: {e}")), + } +} + +/// Check 4: GitHub API connectivity. +fn check_github_api() -> CheckResult { + if std::env::var("COMPOSER_DISABLE_NETWORK").is_ok() { + return CheckResult::Skip("COMPOSER_DISABLE_NETWORK is set".to_string()); + } + + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(concat!("mozart/", env!("CARGO_PKG_VERSION"))) + .build() + { + Ok(c) => c, + Err(e) => return CheckResult::Fail(format!("Could not build HTTP client: {e}")), + }; + + let url = "https://api.github.com/"; + match client.get(url).send() { + Ok(resp) => { + let status = resp.status(); + if status.is_success() || status.is_redirection() { + CheckResult::Ok(Some(format!("HTTP {}", status.as_u16()))) + } else { + CheckResult::Warning(format!("Received HTTP {} from GitHub API", status.as_u16())) + } + } + Err(e) => CheckResult::Fail(format!("Could not reach GitHub API: {e}")), + } +} + +/// Check 5: HTTP proxy configuration. +/// +/// Reports any configured proxy environment variables as informational. +fn check_http_proxy() -> CheckResult { + let proxy_vars = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + ]; + + let mut found: Vec<String> = Vec::new(); + for var in &proxy_vars { + if let Ok(val) = std::env::var(var) { + found.push(format!("{var}={val}")); + } + } + + if found.is_empty() { + CheckResult::Ok(Some("no proxy configured".to_string())) + } else { + CheckResult::Ok(Some(found.join(", "))) + } +} + +/// Check 6: composer.json validation. +/// +/// Checks that it exists, is valid JSON, and has a `name` field. +fn check_composer_json(working_dir: &Path) -> CheckResult { + let path = working_dir.join("composer.json"); + + if !path.exists() { + return CheckResult::Warning(format!( + "composer.json not found in {}", + working_dir.display() + )); + } + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => return CheckResult::Fail(format!("Could not read composer.json: {e}")), + }; + + let value: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + return CheckResult::Fail(format!("composer.json is not valid JSON: {e}")); + } + }; + + let obj = match value.as_object() { + Some(o) => o, + None => { + return CheckResult::Fail( + "composer.json must be a JSON object at the top level".to_string(), + ); + } + }; + + if !obj.contains_key("name") { + return CheckResult::Warning("composer.json is missing the \"name\" field".to_string()); + } + + CheckResult::Ok(None) +} + +/// Check 7: composer.lock freshness. +/// +/// If composer.lock exists, verify its content-hash matches the current composer.json. +fn check_composer_lock(working_dir: &Path) -> CheckResult { + let lock_path = working_dir.join("composer.lock"); + + if !lock_path.exists() { + return CheckResult::Skip("composer.lock not found".to_string()); + } + + let composer_json_path = working_dir.join("composer.json"); + let composer_json_content = match std::fs::read_to_string(&composer_json_path) { + Ok(c) => c, + Err(_) => { + return CheckResult::Skip( + "could not read composer.json to compare against lock file".to_string(), + ); + } + }; + + let lock = match crate::lockfile::LockFile::read_from_file(&lock_path) { + Ok(l) => l, + Err(e) => return CheckResult::Fail(format!("composer.lock is invalid: {e}")), + }; + + if lock.is_fresh(&composer_json_content) { + CheckResult::Ok(None) + } else { + CheckResult::Warning( + "composer.lock is out of date; run \"mozart update\" or \"mozart install\" to refresh it".to_string(), + ) + } +} + +/// Check 8: Git availability and minimum version. +/// +/// Warns if git is not found or is older than 2.24.0. +fn check_git() -> CheckResult { + let output = match std::process::Command::new("git").arg("--version").output() { + Ok(o) => o, + Err(_) => { + return CheckResult::Warning( + "git not found in PATH; some features may not work".to_string(), + ); + } + }; + + if !output.status.success() { + return CheckResult::Warning("git --version returned a non-zero exit code".to_string()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let version_str = stdout.trim(); + + // Parse version from output like "git version 2.39.1" + match parse_git_version(version_str) { + Some((major, minor, _patch)) => { + // Require >= 2.24.0 + if major < 2 || (major == 2 && minor < 24) { + CheckResult::Warning(format!( + "git {version_str} is older than the recommended minimum 2.24.0" + )) + } else { + CheckResult::Ok(Some(version_str.to_string())) + } + } + None => CheckResult::Ok(Some(version_str.to_string())), + } +} + +/// Parse git version output (e.g. "git version 2.39.1") into (major, minor, patch). +fn parse_git_version(output: &str) -> Option<(u64, u64, u64)> { + // Extract the version number portion after "git version " + let version_part = output.strip_prefix("git version ").unwrap_or(output); + // Take only the first part before any space (e.g. "2.39.1.windows.1" → "2.39.1") + let first_part = version_part.split_whitespace().next()?; + let mut parts = first_part.split('.'); + let major: u64 = parts.next()?.parse().ok()?; + let minor: u64 = parts.next()?.parse().ok()?; + let patch: u64 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); + Some((major, minor, patch)) +} + +/// Check 9: Disk free space for a path. +/// +/// Warns if < 1 MiB free. Uses `df -P` for portable output. +fn check_disk_space(path: &Path, label: &str) -> CheckResult { + // Ensure the path exists before calling df + if !path.exists() { + return CheckResult::Skip(format!("{} does not exist", path.display())); + } + + let output = match std::process::Command::new("df") + .arg("-P") + .arg(path) + .output() + { + Ok(o) => o, + Err(_) => { + return CheckResult::Skip("df not available on this platform".to_string()); + } + }; + + if !output.status.success() { + return CheckResult::Skip(format!("df -P failed for {}", path.display())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + match parse_df_available_kib(&stdout) { + Some(avail_kib) => { + let avail_mib = avail_kib / 1024; + let one_mib_kib = 1024u64; + if avail_kib < one_mib_kib { + CheckResult::Warning(format!( + "Low disk space on {label}: only {}KiB available", + avail_kib + )) + } else { + CheckResult::Ok(Some(format!("{avail_mib}MiB free on {label}"))) + } + } + None => CheckResult::Skip("could not parse df output".to_string()), + } +} + +/// Parse the "Available" column (4th column) of `df -P` output, returning KiB. +/// +/// The -P (POSIX) format guarantees 1024-byte blocks in the "Available" column. +fn parse_df_available_kib(df_output: &str) -> Option<u64> { + // Skip the header line, then read the first data line + let data_line = df_output.lines().nth(1)?; + let mut cols = data_line.split_whitespace(); + // Columns: Filesystem, 1024-blocks, Used, Available, Capacity%, Mounted + cols.next()?; // Filesystem + cols.next()?; // 1024-blocks + cols.next()?; // Used + let available = cols.next()?; + available.parse::<u64>().ok() +} + +/// Check 10: Cache directory status. +/// +/// Checks that the cache directory exists and is writable. +fn check_cache_dir(cache_dir: &Path) -> CheckResult { + if !cache_dir.exists() { + // Try to create it + if let Err(e) = std::fs::create_dir_all(cache_dir) { + return CheckResult::Fail(format!( + "Cache directory {} does not exist and could not be created: {e}", + cache_dir.display() + )); + } + return CheckResult::Ok(Some(format!("created {}", cache_dir.display()))); + } + + // Check writability by attempting to create a temp file + let test_file = cache_dir.join(".mozart_write_test"); + match std::fs::write(&test_file, b"test") { + Ok(()) => { + let _ = std::fs::remove_file(&test_file); + CheckResult::Ok(Some(cache_dir.display().to_string())) + } + Err(e) => CheckResult::Fail(format!( + "Cache directory {} is not writable: {e}", + cache_dir.display() + )), + } +} + +// ─── Main execute function ───────────────────────────────────────────────────── + +pub fn execute(_args: &DiagnoseArgs, cli: &super::Cli) -> anyhow::Result<()> { + let working_dir = match &cli.working_dir { + Some(dir) => PathBuf::from(dir), + None => std::env::current_dir()?, + }; + + let mut exit_code: i32 = 0; + + // Determine cache directory (same logic as build_cache_config) + let cache_dir = if let Ok(dir) = std::env::var("COMPOSER_CACHE_DIR") { + PathBuf::from(dir) + } else { + let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + PathBuf::from(xdg) + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".cache") + } else { + PathBuf::from("/tmp") + }; + base.join("mozart") + }; + + // 1. Mozart version info + print_info_line(&check_version()); + println!(); + + // 2. HTTPS connectivity to Packagist + let https_result = check_http_connectivity("https://repo.packagist.org/packages.json"); + print_check( + "https connectivity to packagist", + &https_result, + &mut exit_code, + ); + + // 3. HTTP connectivity to Packagist + let http_result = check_http_connectivity("http://repo.packagist.org/packages.json"); + print_check( + "http connectivity to packagist", + &http_result, + &mut exit_code, + ); + + // 4. GitHub API connectivity + let github_result = check_github_api(); + print_check("github.com connectivity", &github_result, &mut exit_code); + + // 5. HTTP proxy config + let proxy_result = check_http_proxy(); + print_check("http proxy", &proxy_result, &mut exit_code); + + // 6. composer.json validation + let composer_json_result = check_composer_json(&working_dir); + print_check("composer.json", &composer_json_result, &mut exit_code); + + // 7. composer.lock freshness + let lock_result = check_composer_lock(&working_dir); + print_check("composer.lock", &lock_result, &mut exit_code); + + // 8. Git availability + let git_result = check_git(); + print_check("git", &git_result, &mut exit_code); + + // 9. Disk space — working directory + let disk_wd_result = check_disk_space(&working_dir, "working directory"); + print_check( + "disk free space (working directory)", + &disk_wd_result, + &mut exit_code, + ); + + // 9b. Disk space — cache directory + let disk_cache_result = check_disk_space(&cache_dir, "cache directory"); + print_check( + "disk free space (cache directory)", + &disk_cache_result, + &mut exit_code, + ); + + // 10. Cache directory status + let cache_result = check_cache_dir(&cache_dir); + print_check("cache directory", &cache_result, &mut exit_code); + + println!(); + if exit_code == 0 { + println!("{}", "No issues found.".green()); + } else if exit_code == 1 { + println!( + "{}", + "Some warnings were found. See above for details.".yellow() + ); + } else { + println!("{}", "Some errors were found. See above for details.".red()); + } + + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + // ── test_parse_git_version ──────────────────────────────────────────────── + + #[test] + fn test_parse_git_version() { + assert_eq!(parse_git_version("git version 2.39.1"), Some((2, 39, 1))); + assert_eq!(parse_git_version("git version 2.24.0"), Some((2, 24, 0))); + assert_eq!(parse_git_version("git version 1.9.5"), Some((1, 9, 5))); + // Windows-style suffix + assert_eq!( + parse_git_version("git version 2.40.1.windows.1"), + Some((2, 40, 1)) + ); + // No patch component + assert_eq!(parse_git_version("git version 2.39"), Some((2, 39, 0))); + // Bare version (no "git version" prefix) + assert_eq!(parse_git_version("3.0.0"), Some((3, 0, 0))); + } + + // ── test_check_composer_json_valid ──────────────────────────────────────── + + #[test] + fn test_check_composer_json_valid() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("composer.json"), + r#"{"name": "test/project", "require": {}}"#, + ) + .unwrap(); + + let result = check_composer_json(dir.path()); + assert!( + matches!(result, CheckResult::Ok(_)), + "expected Ok for valid composer.json" + ); + } + + // ── test_check_composer_json_missing ───────────────────────────────────── + + #[test] + fn test_check_composer_json_missing() { + let dir = tempdir().unwrap(); + // Do not write a composer.json + + let result = check_composer_json(dir.path()); + assert!( + matches!(result, CheckResult::Warning(_)), + "expected Warning when composer.json is missing" + ); + } + + // ── test_check_composer_json_invalid_json ───────────────────────────────── + + #[test] + fn test_check_composer_json_invalid_json() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("composer.json"), b"{ this is not json ").unwrap(); + + let result = check_composer_json(dir.path()); + assert!( + matches!(result, CheckResult::Fail(_)), + "expected Fail for invalid JSON" + ); + } + + // ── test_check_composer_lock_fresh ──────────────────────────────────────── + + #[test] + fn test_check_composer_lock_fresh() { + use crate::lockfile::LockFile; + + let dir = tempdir().unwrap(); + + let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#; + fs::write(dir.path().join("composer.json"), composer_json).unwrap(); + + let hash = LockFile::compute_content_hash(composer_json).unwrap(); + let lock = LockFile { + readme: LockFile::default_readme(), + content_hash: hash, + packages: vec![], + packages_dev: None, + aliases: vec![], + minimum_stability: "stable".to_string(), + stability_flags: serde_json::json!({}), + prefer_stable: false, + prefer_lowest: false, + platform: serde_json::json!({}), + platform_dev: serde_json::json!({}), + plugin_api_version: None, + }; + lock.write_to_file(&dir.path().join("composer.lock")) + .unwrap(); + + let result = check_composer_lock(dir.path()); + assert!( + matches!(result, CheckResult::Ok(_)), + "expected Ok for fresh lock file" + ); + } + + // ── test_check_composer_lock_stale ──────────────────────────────────────── + + #[test] + fn test_check_composer_lock_stale() { + use crate::lockfile::LockFile; + + let dir = tempdir().unwrap(); + + let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#; + fs::write(dir.path().join("composer.json"), composer_json).unwrap(); + + // Deliberately use a stale/wrong hash + let lock = LockFile { + readme: LockFile::default_readme(), + content_hash: "stale_hash_that_does_not_match".to_string(), + packages: vec![], + packages_dev: None, + aliases: vec![], + minimum_stability: "stable".to_string(), + stability_flags: serde_json::json!({}), + prefer_stable: false, + prefer_lowest: false, + platform: serde_json::json!({}), + platform_dev: serde_json::json!({}), + plugin_api_version: None, + }; + lock.write_to_file(&dir.path().join("composer.lock")) + .unwrap(); + + let result = check_composer_lock(dir.path()); + assert!( + matches!(result, CheckResult::Warning(_)), + "expected Warning for stale lock file" + ); + } + + // ── test_check_composer_lock_missing ───────────────────────────────────── + + #[test] + fn test_check_composer_lock_missing() { + let dir = tempdir().unwrap(); + // Do not write a composer.lock + + let result = check_composer_lock(dir.path()); + assert!( + matches!(result, CheckResult::Skip(_)), + "expected Skip when composer.lock is missing" + ); + } + + // ── test_check_disk_space_ok ────────────────────────────────────────────── + + #[test] + fn test_check_disk_space_ok() { + let dir = tempdir().unwrap(); + // Temp directories should always have plenty of free space + let result = check_disk_space(dir.path(), "temp"); + // Accept Ok or Skip (on platforms where df isn't available) + assert!( + matches!(result, CheckResult::Ok(_) | CheckResult::Skip(_)), + "expected Ok or Skip for disk space check on temp directory" + ); + } + + // ── test_check_result_exit_code_ratcheting ──────────────────────────────── + + #[test] + fn test_check_result_exit_code_ratcheting() { + let mut exit_code = 0i32; + + // Ok does not change exit code + print_check("label", &CheckResult::Ok(None), &mut exit_code); + assert_eq!(exit_code, 0); + + // Warning raises to 1 + print_check( + "label", + &CheckResult::Warning("warn".to_string()), + &mut exit_code, + ); + assert_eq!(exit_code, 1); + + // Another Ok does not lower from 1 + print_check("label", &CheckResult::Ok(None), &mut exit_code); + assert_eq!(exit_code, 1); + + // Fail raises to 2 + print_check( + "label", + &CheckResult::Fail("fail".to_string()), + &mut exit_code, + ); + assert_eq!(exit_code, 2); + + // Warning does not lower from 2 + print_check( + "label", + &CheckResult::Warning("another warn".to_string()), + &mut exit_code, + ); + assert_eq!(exit_code, 2); + } + + // ── test_check_http_proxy_none_set ─────────────────────────────────────── + + #[test] + fn test_check_http_proxy_none_set() { + // Remove all proxy vars for this test + let proxy_vars = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + ]; + for var in &proxy_vars { + // SAFETY: tests run single-threaded for env mutation purposes. + // We save and restore values to avoid polluting other tests. + unsafe { std::env::remove_var(var) }; + } + + let result = check_http_proxy(); + match &result { + CheckResult::Ok(detail) => { + let detail_str = detail.as_deref().unwrap_or(""); + assert!( + detail_str.contains("no proxy"), + "expected 'no proxy configured' detail, got: {detail_str:?}" + ); + } + other => panic!( + "expected Ok for proxy check with no proxy set, got: {:?}", + std::mem::discriminant(other) + ), + } + } + + // ── network tests (ignored by default) ─────────────────────────────────── + + #[test] + #[ignore] + fn test_check_https_packagist_connectivity() { + let result = check_http_connectivity("https://repo.packagist.org/packages.json"); + assert!( + matches!(result, CheckResult::Ok(_)), + "expected Ok for HTTPS Packagist connectivity" + ); + } + + #[test] + #[ignore] + fn test_check_http_packagist_connectivity() { + let result = check_http_connectivity("http://repo.packagist.org/packages.json"); + assert!( + matches!(result, CheckResult::Ok(_) | CheckResult::Warning(_)), + "expected Ok or Warning for HTTP Packagist connectivity" + ); + } + + #[test] + #[ignore] + fn test_check_github_api_connectivity() { + let result = check_github_api(); + assert!( + matches!(result, CheckResult::Ok(_)), + "expected Ok for GitHub API connectivity" + ); + } } |
