1use crate::codetime::SystemInventory;
2use crate::commands::service::load_xbp_config;
3use crate::commands::worktree_watch::{
4 collect_worktree_watch_diagnostic, WorktreeWatchDiagnostic,
5};
6use crate::config::{ensure_global_xbp_paths, sync_system_inventory};
7use crate::logging::{log_info, log_warn};
8use crate::strategies::{get_all_services, ProjectDetector};
9use crate::utils::{collect_listening_port_ownership, command_exists, ListeningPortOwnership};
10use anyhow::Result;
11use colored::Colorize;
12use indicatif::{ProgressBar, ProgressStyle};
13use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
14use serde::{Deserialize, Serialize};
15use std::collections::{BTreeMap, HashMap};
16use std::env;
17use std::fs;
18use std::fs::OpenOptions;
19use std::path::PathBuf;
20use std::time::{Duration, Instant};
21use sysinfo::{Disks, Networks, System};
22use tokio::process::Command;
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct SystemMetrics {
26 pub cpu_usage: f32,
27 pub memory_total: u64,
28 pub memory_used: u64,
29 pub memory_percent: f32,
30 pub disk_total: u64,
31 pub disk_used: u64,
32 pub disk_percent: f32,
33 pub network_rx: u64,
34 pub network_tx: u64,
35 pub uptime: u64,
36 pub process_count: usize,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
40pub struct PortCheck {
41 pub port: u16,
42 pub is_open: bool,
43 pub is_blocked: bool,
44 pub pids: Vec<u32>,
45 pub xbp_projects: Vec<String>,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49pub struct InternetSpeed {
50 pub download_mbps: f64,
51 pub upload_mbps: f64,
52 pub ping_ms: f64,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct OsInfo {
57 pub name: Option<String>,
58 pub version: Option<String>,
59 pub kernel_version: Option<String>,
60 pub arch: Option<String>,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
64pub struct CpuInfo {
65 pub brand: Option<String>,
66 pub cores: usize,
67 pub usage: f32,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
71pub struct DiskInfo {
72 pub mount: String,
73 pub fs: Option<String>,
74 pub total: u64,
75 pub used: u64,
76 pub percent: f32,
77 pub bar: String,
78 pub is_current: bool,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82pub struct ShellInfo {
83 pub shell: Option<String>,
84 pub term: Option<String>,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88pub struct ProxyDetection {
89 pub nginx_sites_available: Option<usize>,
90 pub nginx_active: Option<bool>,
91 pub traefik_active: Option<bool>,
92 pub apache_active: Option<bool>,
93}
94
95#[derive(Debug, Serialize, Deserialize)]
96pub struct ExposureInfo {
97 pub public_ipv4: Option<String>,
98 pub listening_on_all_interfaces: Vec<String>,
99 pub firewall_hint: Option<String>,
100 pub summary: Option<String>,
101}
102
103#[derive(Debug, Serialize, Deserialize)]
104pub struct ToolVersion {
105 pub present: bool,
106 pub version: Option<String>,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
110pub struct PathPermissionCheck {
111 pub path: String,
112 pub purpose: String,
113 pub required: String,
114 pub exists: bool,
115 pub readable: bool,
116 pub writable: bool,
117 pub creatable: bool,
118 pub ok: bool,
119 pub message: Option<String>,
120}
121
122#[derive(Debug, Serialize, Deserialize, Clone)]
123pub struct DnsCheck {
124 pub host: String,
125 pub ok: bool,
126 pub addresses: Vec<String>,
127 pub error: Option<String>,
128}
129
130#[derive(Debug, Serialize, Deserialize, Clone)]
131pub struct HttpProbe {
132 pub name: String,
133 pub url: String,
134 pub ok: bool,
135 pub status: Option<u16>,
136 pub latency_ms: Option<u64>,
137 pub error: Option<String>,
138}
139
140#[derive(Debug, Serialize, Deserialize, Clone, Default)]
142pub struct GitCredentialHelperDiag {
143 pub helpers: Vec<String>,
145 pub stale_helpers: Vec<String>,
147 pub gh_path: Option<String>,
149 pub fix_attempted: bool,
151 pub fixed: bool,
153 pub message: Option<String>,
155}
156
157#[derive(Debug, Serialize, Deserialize, Clone)]
158pub struct GitHygiene {
159 pub is_repo: bool,
160 pub branch: Option<String>,
161 pub dirty: Option<bool>,
162 pub ahead: Option<u32>,
163 pub behind: Option<u32>,
164 pub remote: Option<String>,
165 pub notes: Vec<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub credential_helper: Option<GitCredentialHelperDiag>,
169}
170
171#[derive(Debug, Serialize, Deserialize, Clone)]
172pub struct CliAuthDiagnostic {
173 pub logged_in: bool,
174 pub user_email: Option<String>,
175 pub user_name: Option<String>,
176 pub token_prefix: Option<String>,
177 pub message: String,
178}
179
180#[derive(Debug, Serialize, Deserialize, Clone)]
181pub struct DockerDiagnostic {
182 pub cli_present: bool,
183 pub daemon_ok: bool,
184 pub version: Option<String>,
185 pub compose_present: bool,
186 pub note: Option<String>,
187}
188
189#[derive(Debug, Serialize, Deserialize, Clone)]
190pub struct NetworkInterfaceInfo {
191 pub name: String,
192 pub rx_bytes: u64,
193 pub tx_bytes: u64,
194}
195
196#[derive(Debug, Serialize, Deserialize, Clone)]
197pub struct DiagScorecard {
198 pub healthy: usize,
199 pub warnings: usize,
200 pub failures: usize,
201 pub grade: String,
202}
203
204#[derive(Debug, Serialize, Deserialize)]
205pub struct DiagnosticReport {
206 pub system_metrics: SystemMetrics,
207 pub os: Option<OsInfo>,
208 pub cpu: Option<CpuInfo>,
209 pub gpu_candidates: Vec<String>,
210 pub disks: Vec<DiskInfo>,
211 pub shell: Option<ShellInfo>,
212 pub proxy_detection: Option<ProxyDetection>,
213 pub clipboard_tools: HashMap<String, bool>,
214 pub exposure: Option<ExposureInfo>,
215 pub pm2_process_count: Option<usize>,
216 pub tool_versions: HashMap<String, ToolVersion>,
217 pub service_statuses: HashMap<String, String>,
218 pub feature_flags: HashMap<String, bool>,
219 pub xbp_cli_version: String,
220 pub hostname: Option<String>,
221 pub cwd: String,
222 pub local_ips: Vec<String>,
223 pub network_interfaces: Vec<NetworkInterfaceInfo>,
224 pub project_config_present: bool,
225 pub configured_services: Vec<String>,
226 pub provider_manifests: Vec<String>,
227 pub env_files: Vec<String>,
228 pub nginx_status: Option<NginxStatus>,
229 pub port_checks: Vec<PortCheck>,
230 pub internet_speed: Option<InternetSpeed>,
231 pub connectivity: bool,
232 pub dns_checks: Vec<DnsCheck>,
233 pub http_probes: Vec<HttpProbe>,
234 pub git: Option<GitHygiene>,
235 pub cli_auth: Option<CliAuthDiagnostic>,
236 pub docker: Option<DockerDiagnostic>,
237 pub installed_programs: HashMap<String, bool>,
238 pub path_permission_checks: Vec<PathPermissionCheck>,
239 pub codetime: Option<CodeTimeDiagnostic>,
240 pub worktree_watch: Option<WorktreeWatchDiagnostic>,
241 pub recommendations: Vec<String>,
242 pub scorecard: DiagScorecard,
243}
244
245#[derive(Debug, Serialize, Deserialize)]
246pub struct CodeTimeDiagnostic {
247 pub saved_config_path: String,
248 pub refreshed: bool,
249 pub inventory: SystemInventory,
250}
251
252#[derive(Debug, Serialize, Deserialize)]
253pub struct NginxStatus {
254 pub is_running: bool,
255 pub is_enabled: bool,
256 pub config_valid: bool,
257 pub error: Option<String>,
258}
259
260const PM2_DIAG_TIMEOUT: Duration = Duration::from_secs(12);
261const PORT_OWNERSHIP_TIMEOUT: Duration = Duration::from_secs(20);
262const PER_PORT_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
263const TOOL_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
264const PROGRAM_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
265const COMMAND_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
266const INTERNET_SPEED_TIMEOUT: Duration = Duration::from_secs(20);
267const NGINX_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
268const SYSTEM_INVENTORY_TIMEOUT: Duration = Duration::from_secs(25);
269const DOWNLOAD_PROBE_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
270const DOWNLOAD_PROBE_MAX_DURATION: Duration = Duration::from_secs(8);
271const DOWNLOAD_PROBE_TARGET_BYTES: u64 = 8_000_000;
272const DOWNLOAD_PROBE_MIN_BYTES: u64 = 1_000_000;
273const DOWNLOAD_PROBE_URLS: &[&str] = &[
274 "https://speed.cloudflare.com/__down?bytes=20000000",
275 "https://speed.hetzner.de/10MB.bin",
276 "https://proof.ovh.net/files/10Mb.dat",
277];
278
279#[derive(Debug, Clone, Copy, Default)]
280pub struct DiagnosticRunOptions {
281 pub skip_speed_test: bool,
282 pub codetime: bool,
283 pub cursor: bool,
284 pub quick: bool,
286 pub full: bool,
288 pub http_probe: bool,
290}
291
292pub async fn get_system_metrics() -> Result<SystemMetrics> {
293 let mut sys = System::new_all();
294 sys.refresh_all();
295
296 let cpu_usage =
297 sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
298 let memory_total = sys.total_memory();
299 let memory_used = sys.used_memory();
300 let memory_percent = (memory_used as f32 / memory_total as f32) * 100.0;
301
302 let mut disk_total = 0;
303 let mut disk_used = 0;
304 for disk in Disks::new_with_refreshed_list().iter() {
305 disk_total += disk.total_space();
306 disk_used += disk.total_space() - disk.available_space();
307 }
308 let disk_percent = if disk_total > 0 {
309 (disk_used as f32 / disk_total as f32) * 100.0
310 } else {
311 0.0
312 };
313
314 let networks = Networks::new_with_refreshed_list();
315 let mut network_rx = 0;
316 let mut network_tx = 0;
317 for (_interface_name, network) in &networks {
318 network_rx += network.total_received();
319 network_tx += network.total_transmitted();
320 }
321
322 let uptime = System::uptime();
323 let process_count = sys.processes().len();
324
325 Ok(SystemMetrics {
326 cpu_usage,
327 memory_total,
328 memory_used,
329 memory_percent,
330 disk_total,
331 disk_used,
332 disk_percent,
333 network_rx,
334 network_tx,
335 uptime,
336 process_count,
337 })
338}
339
340pub async fn check_nginx_status() -> Result<NginxStatus> {
341 let is_running: bool = check_systemctl_status("nginx").await?;
342 let is_enabled: bool = check_systemctl_enabled("nginx").await?;
343
344 let config_valid: bool = if is_running {
345 match Command::new("nginx").arg("-t").output().await {
346 Ok(output) => output.status.success(),
347 Err(_) => false,
348 }
349 } else {
350 false
351 };
352
353 Ok(NginxStatus {
354 is_running,
355 is_enabled,
356 config_valid,
357 error: None,
358 })
359}
360
361async fn check_systemctl_status(service: &str) -> Result<bool> {
362 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
363 return Ok(false);
364 }
365
366 let output = Command::new("systemctl")
367 .arg("is-active")
368 .arg(service)
369 .output()
370 .await?;
371
372 Ok(output.status.success())
373}
374
375async fn check_systemctl_enabled(service: &str) -> Result<bool> {
376 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
377 return Ok(false);
378 }
379
380 let output = Command::new("systemctl")
381 .arg("is-enabled")
382 .arg(service)
383 .output()
384 .await?;
385
386 Ok(output.status.success())
387}
388
389pub async fn check_port_availability(
390 port: u16,
391 ownership: Option<&ListeningPortOwnership>,
392) -> Result<PortCheck> {
393 use std::net::TcpListener;
394
395 let is_open: bool = TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok();
396
397 let is_blocked: bool = if cfg!(target_os = "linux") {
398 check_firewall_blocked(port).await.unwrap_or(false)
399 } else {
400 false
401 };
402
403 Ok(PortCheck {
404 port,
405 is_open,
406 is_blocked,
407 pids: ownership
408 .map(|value| value.pids.clone())
409 .unwrap_or_default(),
410 xbp_projects: ownership
411 .map(|value| value.xbp_projects.clone())
412 .unwrap_or_default(),
413 })
414}
415
416async fn check_firewall_blocked(port: u16) -> Result<bool> {
417 let output: std::process::Output = Command::new("iptables")
418 .arg("-L")
419 .arg("-n")
420 .output()
421 .await?;
422
423 if !output.status.success() {
424 return Ok(false);
425 }
426
427 let stdout = String::from_utf8_lossy(&output.stdout);
428 let port_str = port.to_string();
429 Ok(stdout.contains(&format!("dpt:{}", port_str)) && stdout.contains("DROP"))
430}
431
432pub async fn check_internet_connectivity() -> Result<bool> {
433 let result: std::process::Output = if cfg!(target_os = "windows") {
434 Command::new("ping")
435 .arg("-n")
436 .arg("1")
437 .arg("-w")
438 .arg("2000")
439 .arg("8.8.8.8")
440 .output()
441 .await?
442 } else {
443 Command::new("ping")
444 .arg("-c")
445 .arg("1")
446 .arg("-W")
447 .arg("2")
448 .arg("8.8.8.8")
449 .output()
450 .await?
451 };
452
453 Ok(result.status.success())
454}
455
456pub async fn measure_internet_speed() -> Result<InternetSpeed> {
457 if let Ok(speed) = measure_speed_with_speedtest_cli().await {
458 if speed.download_mbps >= 1.0 || speed.upload_mbps >= 0.5 {
459 return Ok(speed);
460 }
461 }
462
463 let ping: f64 = measure_ping().await?;
464 let download_mbps = measure_download_speed().await.unwrap_or(0.0);
465 let upload_mbps = 0.0;
466
467 Ok(InternetSpeed {
468 download_mbps,
469 upload_mbps,
470 ping_ms: ping,
471 })
472}
473
474fn parse_speed_value(token: &str) -> Option<f64> {
475 token.trim().replace(',', ".").parse::<f64>().ok()
476}
477
478async fn measure_speed_with_speedtest_cli() -> Result<InternetSpeed> {
479 let output: std::process::Output = Command::new("speedtest-cli")
480 .arg("--simple")
481 .output()
482 .await?;
483
484 if !output.status.success() {
485 return Err(anyhow::anyhow!("speedtest-cli failed"));
486 }
487
488 let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
489 let mut ping_ms: f64 = 0.0;
490 let mut download_mbps: f64 = 0.0;
491 let mut upload_mbps: f64 = 0.0;
492
493 for line in stdout.lines() {
494 if line.starts_with("Ping:") {
495 if let Some(val) = line.split_whitespace().nth(1) {
496 ping_ms = parse_speed_value(val).unwrap_or(0.0);
497 }
498 } else if line.starts_with("Download:") {
499 if let Some(val) = line.split_whitespace().nth(1) {
500 download_mbps = parse_speed_value(val).unwrap_or(0.0);
501 }
502 } else if line.starts_with("Upload:") {
503 if let Some(val) = line.split_whitespace().nth(1) {
504 upload_mbps = parse_speed_value(val).unwrap_or(0.0);
505 }
506 }
507 }
508
509 Ok(InternetSpeed {
510 download_mbps,
511 upload_mbps,
512 ping_ms,
513 })
514}
515
516async fn measure_ping() -> Result<f64> {
517 let output: std::process::Output = if cfg!(target_os = "windows") {
518 Command::new("ping")
519 .arg("-n")
520 .arg("4")
521 .arg("8.8.8.8")
522 .output()
523 .await?
524 } else {
525 Command::new("ping")
526 .arg("-c")
527 .arg("4")
528 .arg("8.8.8.8")
529 .output()
530 .await?
531 };
532
533 if !output.status.success() {
534 return Ok(0.0);
535 }
536
537 let stdout = String::from_utf8_lossy(&output.stdout);
538
539 if cfg!(target_os = "windows") {
540 for line in stdout.lines() {
541 if line.contains("Average") {
542 if let Some(avg_part) = line.split('=').next_back() {
543 let avg_str = avg_part.trim().trim_end_matches("ms");
544 if let Ok(avg) = avg_str.parse::<f64>() {
545 return Ok(avg);
546 }
547 }
548 }
549 }
550 } else {
551 for line in stdout.lines() {
552 if line.contains("avg") || line.contains("rtt") {
553 if let Some(avg_str) = line.split('/').nth(4) {
554 if let Ok(avg) = avg_str.trim().parse::<f64>() {
555 return Ok(avg);
556 }
557 }
558 }
559 }
560 }
561
562 Ok(0.0)
563}
564
565async fn measure_download_speed() -> Result<f64> {
566 let client: reqwest::Client = reqwest::Client::builder()
567 .timeout(DOWNLOAD_PROBE_REQUEST_TIMEOUT)
568 .build()?;
569 let mut last_error: Option<String> = None;
570
571 for url in DOWNLOAD_PROBE_URLS {
572 match measure_download_speed_probe(&client, url).await {
573 Ok(mbps) => return Ok(mbps),
574 Err(err) => last_error = Some(err.to_string()),
575 }
576 }
577
578 Err(anyhow::anyhow!(
579 "all download probes failed{}",
580 last_error
581 .map(|value| format!(" (last error: {})", value))
582 .unwrap_or_default()
583 ))
584}
585
586async fn measure_download_speed_probe(client: &reqwest::Client, url: &str) -> Result<f64> {
587 let start = Instant::now();
588 let mut response: reqwest::Response = client
589 .get(url)
590 .header("Cache-Control", "no-cache")
591 .header("Pragma", "no-cache")
592 .send()
593 .await?;
594
595 if !response.status().is_success() {
596 return Err(anyhow::anyhow!(
597 "download probe {} returned status {}",
598 url,
599 response.status()
600 ));
601 }
602
603 let mut total_bytes: u64 = 0;
604
605 loop {
606 if total_bytes >= DOWNLOAD_PROBE_TARGET_BYTES
607 || start.elapsed() >= DOWNLOAD_PROBE_MAX_DURATION
608 {
609 break;
610 }
611
612 let chunk = response.chunk().await?;
613 let Some(bytes) = chunk else {
614 break;
615 };
616
617 total_bytes = total_bytes.saturating_add(bytes.len() as u64);
618 }
619
620 if total_bytes < DOWNLOAD_PROBE_MIN_BYTES {
621 return Err(anyhow::anyhow!(
622 "insufficient sample from {}: {} bytes",
623 url,
624 total_bytes
625 ));
626 }
627
628 let elapsed_seconds = start.elapsed().as_secs_f64().max(0.001);
629 let megabits = (total_bytes as f64 * 8.0) / 1_000_000.0;
630 Ok(megabits / elapsed_seconds)
631}
632
633pub async fn check_installed_programs() -> HashMap<String, bool> {
634 let programs = vec![
635 "jq",
636 "curl",
637 "wget",
638 "nginx",
639 "git",
640 "docker",
641 "docker-compose",
642 "podman",
643 "kubectl",
644 "microk8s",
645 "helm",
646 "node",
647 "npm",
648 "pnpm",
649 "yarn",
650 "bun",
651 "python",
652 "python3",
653 "pip",
654 "pip3",
655 "uv",
656 "cargo",
657 "rustc",
658 "rustup",
659 "pm2",
660 "systemctl",
661 "launchctl",
662 "ss",
663 "lsof",
664 "nc",
665 "nmap",
666 "openssl",
667 "dig",
668 "nslookup",
669 "cloudflared",
670 "wrangler",
671 "gh",
672 "rg",
673 "fd",
674 "make",
675 "cmake",
676 "speedtest-cli",
677 ];
678
679 let mut results = HashMap::new();
680
681 for program in programs {
682 let is_installed = check_program_installed(program).await;
683 results.insert(program.to_string(), is_installed);
684 }
685
686 results
687}
688
689fn can_create_file_in_dir(dir: &std::path::Path) -> bool {
690 use std::time::{SystemTime, UNIX_EPOCH};
691
692 if !dir.is_dir() {
693 return false;
694 }
695
696 let nonce = SystemTime::now()
697 .duration_since(UNIX_EPOCH)
698 .map(|d| d.as_nanos())
699 .unwrap_or_default();
700 let probe = dir.join(format!(
701 ".xbp_diag_perm_probe_{}_{}",
702 std::process::id(),
703 nonce
704 ));
705
706 match OpenOptions::new().write(true).create_new(true).open(&probe) {
707 Ok(_) => {
708 let _ = fs::remove_file(&probe);
709 true
710 }
711 Err(_) => false,
712 }
713}
714
715fn check_path_permission_entry(
716 path: PathBuf,
717 purpose: &str,
718 require_read: bool,
719 require_write: bool,
720 require_create: bool,
721 optional: bool,
722) -> PathPermissionCheck {
723 let exists = path.exists();
724 let mut readable = false;
725 let mut writable = false;
726 let mut creatable = false;
727 let mut message = None;
728
729 if exists {
730 if path.is_dir() {
731 readable = fs::read_dir(&path).is_ok();
732 writable = can_create_file_in_dir(&path);
733 creatable = writable;
734 } else if path.is_file() {
735 readable = fs::File::open(&path).is_ok();
736 writable = OpenOptions::new().write(true).open(&path).is_ok();
737 if let Some(parent) = path.parent() {
738 creatable = can_create_file_in_dir(parent);
739 }
740 } else {
741 message = Some("Path exists but is not a regular file/directory".to_string());
742 }
743 } else if let Some(parent) = path.parent() {
744 creatable = can_create_file_in_dir(parent);
746 readable = parent.exists() && fs::read_dir(parent).is_ok();
747 writable = parent.exists() && can_create_file_in_dir(parent);
748 if optional {
749 message = Some("Path missing (optional)".to_string());
750 } else if require_create && !creatable {
751 message =
752 Some("Path missing and cannot be created with current permissions".to_string());
753 } else {
754 message = Some("Path missing".to_string());
755 }
756 } else if optional {
757 message = Some("Path missing (optional)".to_string());
758 } else {
759 message = Some("Path missing".to_string());
760 }
761
762 let mut missing = Vec::new();
763 if require_read && !readable {
764 missing.push("read");
765 }
766 if require_write && !writable {
767 missing.push("write");
768 }
769 if require_create && !creatable {
770 missing.push("create");
771 }
772
773 let mut ok = missing.is_empty();
774 if optional && !exists {
775 ok = true;
776 }
777 if !ok {
778 let detail = format!("Missing required access: {}", missing.join(", "));
779 message = match message {
780 Some(existing) => Some(format!("{}; {}", existing, detail)),
781 None => Some(detail),
782 };
783 }
784
785 let required = match (require_read, require_write, require_create) {
786 (true, true, true) => "read/write/create",
787 (true, true, false) => "read/write",
788 (true, false, true) => "read/create",
789 (false, true, true) => "write/create",
790 (true, false, false) => "read",
791 (false, true, false) => "write",
792 (false, false, true) => "create",
793 (false, false, false) => "none",
794 }
795 .to_string();
796
797 PathPermissionCheck {
798 path: path.display().to_string(),
799 purpose: purpose.to_string(),
800 required,
801 exists,
802 readable,
803 writable,
804 creatable,
805 ok,
806 message,
807 }
808}
809
810async fn get_path_permission_checks() -> Vec<PathPermissionCheck> {
811 let mut checks = Vec::new();
812
813 if cfg!(target_os = "linux") {
814 checks.push(check_path_permission_entry(
815 PathBuf::from("/etc/systemd/system"),
816 "Install/update systemd unit files",
817 true,
818 true,
819 true,
820 false,
821 ));
822 checks.push(check_path_permission_entry(
823 PathBuf::from("/etc/default/xbp"),
824 "Optional runtime environment file for xbp-api.service",
825 true,
826 true,
827 true,
828 true,
829 ));
830 checks.push(check_path_permission_entry(
831 PathBuf::from("/etc/nginx"),
832 "Read/write Nginx configuration root",
833 true,
834 true,
835 false,
836 false,
837 ));
838 checks.push(check_path_permission_entry(
839 PathBuf::from("/etc/nginx/sites-available"),
840 "Manage Nginx site configurations",
841 true,
842 true,
843 true,
844 false,
845 ));
846 checks.push(check_path_permission_entry(
847 PathBuf::from("/etc/nginx/sites-enabled"),
848 "Enable/disable Nginx sites",
849 true,
850 true,
851 true,
852 false,
853 ));
854 checks.push(check_path_permission_entry(
855 PathBuf::from("/var/log"),
856 "Write XBP and service log files",
857 true,
858 true,
859 true,
860 false,
861 ));
862 checks.push(check_path_permission_entry(
863 PathBuf::from("/var/lib/xbp"),
864 "State directory used by generated systemd services",
865 true,
866 true,
867 true,
868 true,
869 ));
870 checks.push(check_path_permission_entry(
871 PathBuf::from("/var/log/nginx"),
872 "Read Nginx access/error logs for diagnostics and metrics",
873 true,
874 true,
875 true,
876 true,
877 ));
878 checks.push(check_path_permission_entry(
879 PathBuf::from("/run"),
880 "Runtime sockets/state files used by services",
881 true,
882 true,
883 true,
884 false,
885 ));
886 checks.push(check_path_permission_entry(
887 PathBuf::from("/usr/local/bin/xbp"),
888 "Installed XBP CLI binary path",
889 true,
890 true,
891 true,
892 true,
893 ));
894 } else if let Ok(current) = env::current_dir() {
895 checks.push(check_path_permission_entry(
896 current,
897 "Current project directory",
898 true,
899 true,
900 true,
901 false,
902 ));
903 }
904
905 if let Ok(current) = env::current_dir() {
906 checks.push(check_path_permission_entry(
907 current.join(".xbp"),
908 "Local XBP project metadata directory",
909 true,
910 true,
911 true,
912 true,
913 ));
914 }
915
916 if let Ok(paths) = ensure_global_xbp_paths() {
918 checks.push(check_path_permission_entry(
919 paths.root_dir.clone(),
920 "Global XBP home (config + worktree-watch mutations)",
921 true,
922 true,
923 true,
924 true,
925 ));
926 checks.push(check_path_permission_entry(
927 paths.root_dir.join("mutations"),
928 "Worktree-watch mutation spool root under global XBP home",
929 true,
930 true,
931 true,
932 true,
933 ));
934 }
935
936 checks
937}
938
939async fn run_command_with_timeout(
940 program: &str,
941 args: &[&str],
942 timeout: Duration,
943) -> Option<std::process::Output> {
944 let mut cmd = Command::new(program);
945 cmd.args(args);
946 cmd.kill_on_drop(true);
947 match tokio::time::timeout(timeout, cmd.output()).await {
948 Ok(Ok(output)) => Some(output),
949 _ => None,
950 }
951}
952
953async fn check_program_installed(program: &str) -> bool {
954 let output = if cfg!(target_os = "windows") {
955 run_command_with_timeout("where", &[program], PROGRAM_CHECK_TIMEOUT).await
956 } else {
957 run_command_with_timeout("which", &[program], PROGRAM_CHECK_TIMEOUT).await
958 };
959
960 output.map(|value| value.status.success()).unwrap_or(false)
961}
962
963fn render_percent_bar(percent: f32, width: usize) -> String {
964 let pct = percent.clamp(0.0, 100.0);
965 let filled = ((pct / 100.0) * width as f32).round() as usize;
966 let filled = filled.min(width);
967 let empty = width.saturating_sub(filled);
968 format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty))
969}
970
971async fn get_os_info() -> Option<OsInfo> {
972 Some(OsInfo {
973 name: System::name(),
974 version: System::os_version(),
975 kernel_version: System::kernel_version(),
976 arch: Some(std::env::consts::ARCH.to_string()),
977 })
978}
979
980async fn get_cpu_info() -> Option<CpuInfo> {
981 let mut sys = System::new_all();
982 sys.refresh_cpu_all();
983 let cores = sys.cpus().len();
984 let usage = if cores > 0 {
985 sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / cores as f32
986 } else {
987 0.0
988 };
989 let brand = sys.cpus().first().map(|c| c.brand().to_string());
990 Some(CpuInfo {
991 brand,
992 cores,
993 usage,
994 })
995}
996
997async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
998 let output = run_command_with_timeout(program, args, COMMAND_CAPTURE_TIMEOUT).await?;
999 let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
1000 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
1001 let combined: String = if !stdout.is_empty() { stdout } else { stderr };
1002 if combined.is_empty() {
1003 None
1004 } else {
1005 Some(combined)
1006 }
1007}
1008
1009async fn get_gpu_candidates() -> Vec<String> {
1010 let mut out: Vec<String> = Vec::new();
1011
1012 if cfg!(target_os = "windows") {
1013 if let Some(s) = run_command_capture(
1014 "powershell",
1015 &[
1016 "-Command",
1017 "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
1018 ],
1019 )
1020 .await
1021 {
1022 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1023 out.push(line.to_string());
1024 }
1025 }
1026 return out;
1027 }
1028
1029 if let Some(s) = run_command_capture("nvidia-smi", &["-L"]).await {
1030 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1031 out.push(line.to_string());
1032 }
1033 }
1034
1035 if out.is_empty() {
1036 if let Some(s) =
1037 run_command_capture("sh", &["-c", "lspci | grep -Ei 'vga|3d|display'"]).await
1038 {
1039 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1040 out.push(line.to_string());
1041 }
1042 }
1043 }
1044
1045 if out.is_empty() {
1046 if let Some(s) =
1047 run_command_capture("sh", &["-c", "lshw -C display 2>/dev/null | head"]).await
1048 {
1049 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1050 out.push(line.to_string());
1051 }
1052 }
1053 }
1054
1055 out
1056}
1057
1058async fn get_disk_infos() -> Vec<DiskInfo> {
1059 let disks = Disks::new_with_refreshed_list();
1060 let cwd = env::current_dir().ok();
1061
1062 let mut candidates: Vec<(usize, usize)> = Vec::new();
1063 if let Some(cwd) = &cwd {
1064 for (i, d) in disks.iter().enumerate() {
1065 let mp = d.mount_point();
1066 if cwd.starts_with(mp) {
1067 candidates.push((mp.as_os_str().len(), i));
1068 }
1069 }
1070 }
1071 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
1072 let current_idx = candidates.first().map(|(_, idx)| *idx);
1073
1074 disks
1075 .iter()
1076 .enumerate()
1077 .map(|(i, d)| {
1078 let total = d.total_space();
1079 let used = total.saturating_sub(d.available_space());
1080 let percent = if total > 0 {
1081 (used as f32 / total as f32) * 100.0
1082 } else {
1083 0.0
1084 };
1085 let mount = d.mount_point().to_string_lossy().to_string();
1086 let fs = if d.file_system().is_empty() {
1087 None
1088 } else {
1089 Some(d.file_system().to_string_lossy().to_string())
1090 };
1091 DiskInfo {
1092 mount,
1093 fs,
1094 total,
1095 used,
1096 percent,
1097 bar: render_percent_bar(percent, 20),
1098 is_current: current_idx == Some(i),
1099 }
1100 })
1101 .collect()
1102}
1103
1104fn get_shell_info() -> Option<ShellInfo> {
1105 let shell = env::var("SHELL")
1106 .ok()
1107 .or_else(|| env::var("ComSpec").ok())
1108 .or_else(|| env::var("0").ok());
1109 let term = env::var("TERM").ok();
1110 if shell.is_none() && term.is_none() {
1111 None
1112 } else {
1113 Some(ShellInfo { shell, term })
1114 }
1115}
1116
1117async fn systemctl_active(service: &str) -> Option<bool> {
1118 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
1119 return None;
1120 }
1121 let output = Command::new("systemctl")
1122 .arg("is-active")
1123 .arg(service)
1124 .output()
1125 .await
1126 .ok()?;
1127 Some(output.status.success())
1128}
1129
1130async fn get_service_statuses() -> HashMap<String, String> {
1131 let mut out = HashMap::new();
1132 let services = [
1133 "nginx",
1134 "traefik",
1135 "caddy",
1136 "apache2",
1137 "httpd",
1138 "postgresql",
1139 "postgres",
1140 "mysql",
1141 "mariadb",
1142 "redis",
1143 "redis-server",
1144 "postgrest",
1145 "prometheus",
1146 "grafana-server",
1147 "grafana",
1148 "docker",
1149 "containerd",
1150 "ssh",
1151 "sshd",
1152 "fail2ban",
1153 "kafka",
1154 "xbp",
1155 "xbp-api",
1156 "xbp-daemon",
1157 ];
1158
1159 for s in services {
1160 let v = systemctl_active(s).await;
1161 let status = match v {
1162 Some(true) => "active",
1163 Some(false) => "inactive",
1164 None => "unknown",
1165 };
1166 out.insert(s.to_string(), status.to_string());
1167 }
1168
1169 out
1170}
1171
1172async fn get_proxy_detection() -> Option<ProxyDetection> {
1173 let nginx_sites_available = if cfg!(target_os = "linux") {
1174 let p = PathBuf::from("/etc/nginx/sites-available");
1175 if p.exists() {
1176 fs::read_dir(&p)
1177 .ok()
1178 .map(|rd| rd.filter_map(|e| e.ok()).count())
1179 } else {
1180 None
1181 }
1182 } else {
1183 None
1184 };
1185
1186 let nginx_active = systemctl_active("nginx").await;
1187 let traefik_active = systemctl_active("traefik").await;
1188 let apache_active = match systemctl_active("apache2").await {
1189 Some(v) => Some(v),
1190 None => systemctl_active("httpd").await,
1191 };
1192
1193 if nginx_sites_available.is_none()
1194 && nginx_active.is_none()
1195 && traefik_active.is_none()
1196 && apache_active.is_none()
1197 {
1198 None
1199 } else {
1200 Some(ProxyDetection {
1201 nginx_sites_available,
1202 nginx_active,
1203 traefik_active,
1204 apache_active,
1205 })
1206 }
1207}
1208
1209async fn get_clipboard_tools() -> HashMap<String, bool> {
1210 let mut tools = vec!["xclip", "xsel", "wl-copy", "pbcopy"];
1211 if cfg!(target_os = "windows") {
1212 tools.push("clip");
1213 }
1214 let mut out = HashMap::new();
1215 for t in tools {
1216 out.insert(t.to_string(), check_program_installed(t).await);
1217 }
1218 out
1219}
1220
1221fn get_listeners_on_all_interfaces() -> Vec<String> {
1222 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
1223 let proto_flags = ProtocolFlags::TCP;
1224 let sockets = get_sockets_info(af_flags, proto_flags).unwrap_or_default();
1225
1226 let mut out = Vec::new();
1227 for socket in sockets {
1228 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
1229 let state = format!("{:?}", tcp.state);
1230 if state != "Listen" && state != "LISTEN" {
1231 continue;
1232 }
1233 let addr = tcp.local_addr.to_string();
1234 if addr == "0.0.0.0" || addr == "::" {
1235 out.push(format!("{}:{}", addr, tcp.local_port));
1236 }
1237 }
1238 }
1239 out.sort();
1240 out.dedup();
1241 out
1242}
1243
1244async fn get_firewall_hint() -> Option<String> {
1245 if !cfg!(target_os = "linux") {
1246 return None;
1247 }
1248
1249 if let Some(s) = run_command_capture("sh", &["-c", "ufw status 2>/dev/null | head -n 1"]).await
1250 {
1251 if !s.trim().is_empty() {
1252 return Some(s.trim().to_string());
1253 }
1254 }
1255
1256 if let Some(s) =
1257 run_command_capture("sh", &["-c", "nft list ruleset 2>/dev/null | head -n 1"]).await
1258 {
1259 if !s.trim().is_empty() {
1260 return Some("nftables detected".to_string());
1261 }
1262 }
1263
1264 if let Some(s) = run_command_capture("sh", &["-c", "iptables -S 2>/dev/null | head -n 1"]).await
1265 {
1266 if !s.trim().is_empty() {
1267 return Some("iptables detected".to_string());
1268 }
1269 }
1270
1271 None
1272}
1273
1274async fn get_public_ipv4(connectivity: bool) -> Option<String> {
1275 if !connectivity {
1276 return None;
1277 }
1278 let client = reqwest::Client::builder()
1279 .timeout(Duration::from_secs(3))
1280 .build()
1281 .ok()?;
1282 let text = client
1283 .get("https://api.ipify.org")
1284 .send()
1285 .await
1286 .ok()?
1287 .text()
1288 .await
1289 .ok()?;
1290 let ip = text.trim().to_string();
1291 if ip.is_empty() {
1292 None
1293 } else {
1294 Some(ip)
1295 }
1296}
1297
1298async fn get_exposure_info(connectivity: bool) -> Option<ExposureInfo> {
1299 let public_ipv4 = get_public_ipv4(connectivity).await;
1300 let listening_on_all_interfaces = get_listeners_on_all_interfaces();
1301 let firewall_hint = get_firewall_hint().await;
1302 let summary = Some(if listening_on_all_interfaces.is_empty() {
1303 "No listeners on 0.0.0.0/:: detected".to_string()
1304 } else {
1305 "Listeners on 0.0.0.0/:: detected (may be publicly reachable)".to_string()
1306 });
1307 Some(ExposureInfo {
1308 public_ipv4,
1309 listening_on_all_interfaces,
1310 firewall_hint,
1311 summary,
1312 })
1313}
1314
1315async fn get_pm2_process_count() -> Option<usize> {
1316 let mut cmd = if cfg!(target_os = "windows") {
1317 let mut cmd = Command::new("powershell");
1318 cmd.arg("-NoProfile").arg("-Command").arg("pm2 jlist");
1319 cmd
1320 } else {
1321 let mut cmd = Command::new("pm2");
1322 cmd.arg("jlist");
1323 cmd
1324 };
1325 cmd.kill_on_drop(true);
1326
1327 let output = match tokio::time::timeout(PM2_DIAG_TIMEOUT, cmd.output()).await {
1328 Ok(Ok(output)) => output,
1329 Ok(Err(_)) | Err(_) => return None,
1330 };
1331
1332 if !output.status.success() {
1333 return None;
1334 }
1335 let stdout = String::from_utf8_lossy(&output.stdout);
1336 let value: serde_json::Value = serde_json::from_str(&stdout).ok()?;
1337 value.as_array().map(|arr| arr.len())
1338}
1339
1340async fn get_version(cmd: &str, args: &[&str]) -> ToolVersion {
1341 match run_command_with_timeout(cmd, args, TOOL_VERSION_TIMEOUT).await {
1342 Some(o) => {
1343 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
1344 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1345 let line = stdout
1346 .lines()
1347 .next()
1348 .filter(|l| !l.trim().is_empty())
1349 .map(|l| l.trim().to_string())
1350 .or_else(|| {
1351 stderr
1352 .lines()
1353 .next()
1354 .filter(|l| !l.trim().is_empty())
1355 .map(|l| l.trim().to_string())
1356 });
1357 ToolVersion {
1358 present: o.status.success() || line.is_some(),
1359 version: line,
1360 }
1361 }
1362 None => ToolVersion {
1363 present: false,
1364 version: None,
1365 },
1366 }
1367}
1368
1369async fn get_tool_versions() -> HashMap<String, ToolVersion> {
1370 let mut out = HashMap::new();
1371
1372 out.insert(
1373 "python3".to_string(),
1374 get_version("python3", &["--version"]).await,
1375 );
1376 out.insert(
1377 "python".to_string(),
1378 get_version("python", &["--version"]).await,
1379 );
1380 out.insert(
1381 "node".to_string(),
1382 get_version("node", &["--version"]).await,
1383 );
1384 out.insert("npm".to_string(), get_version("npm", &["--version"]).await);
1385 out.insert(
1386 "pnpm".to_string(),
1387 get_version("pnpm", &["--version"]).await,
1388 );
1389 out.insert("pm2".to_string(), get_version("pm2", &["--version"]).await);
1390 out.insert(
1391 "docker".to_string(),
1392 get_version("docker", &["--version"]).await,
1393 );
1394 out.insert(
1395 "docker-compose".to_string(),
1396 get_version("docker-compose", &["--version"]).await,
1397 );
1398 out.insert(
1399 "kubectl".to_string(),
1400 get_version("kubectl", &["version", "--client"]).await,
1401 );
1402 out.insert(
1403 "microk8s".to_string(),
1404 get_version("microk8s", &["version"]).await,
1405 );
1406 out.insert(
1407 "rustc".to_string(),
1408 get_version("rustc", &["--version"]).await,
1409 );
1410 out.insert(
1411 "cargo".to_string(),
1412 get_version("cargo", &["--version"]).await,
1413 );
1414 out.insert(
1415 "rustup".to_string(),
1416 get_version("rustup", &["--version"]).await,
1417 );
1418 out.insert("git".to_string(), get_version("git", &["--version"]).await);
1419 out.insert(
1420 "wrangler".to_string(),
1421 get_version("wrangler", &["--version"]).await,
1422 );
1423 out.insert(
1424 "cloudflared".to_string(),
1425 get_version("cloudflared", &["--version"]).await,
1426 );
1427 out.insert("gh".to_string(), get_version("gh", &["--version"]).await);
1428 out.insert(
1429 "openssl".to_string(),
1430 get_version("openssl", &["version"]).await,
1431 );
1432 out.insert(
1433 "bun".to_string(),
1434 get_version("bun", &["--version"]).await,
1435 );
1436
1437 out
1438}
1439
1440async fn collect_local_ips() -> Vec<String> {
1441 let mut ips = Vec::new();
1442 let networks = Networks::new_with_refreshed_list();
1443 for (name, data) in &networks {
1444 let _ = name;
1445 for addr in data.ip_networks() {
1446 let s = addr.addr.to_string();
1447 if s == "127.0.0.1" || s == "::1" || s.starts_with("fe80:") {
1448 continue;
1449 }
1450 ips.push(s);
1451 }
1452 }
1453 ips.sort();
1454 ips.dedup();
1455 ips
1456}
1457
1458fn collect_network_interfaces() -> Vec<NetworkInterfaceInfo> {
1459 let networks = Networks::new_with_refreshed_list();
1460 let mut out = Vec::new();
1461 for (name, data) in &networks {
1462 out.push(NetworkInterfaceInfo {
1463 name: name.clone(),
1464 rx_bytes: data.total_received(),
1465 tx_bytes: data.total_transmitted(),
1466 });
1467 }
1468 out.sort_by(|a, b| a.name.cmp(&b.name));
1469 out
1470}
1471
1472fn discover_env_files(cwd: &std::path::Path) -> Vec<String> {
1473 let candidates = [
1474 ".env",
1475 ".env.local",
1476 ".env.production",
1477 ".dev.vars",
1478 ".xbp/.env",
1479 "apps/web/.env.local",
1480 "apps/web/.dev.vars",
1481 "apps/docs/.env.local",
1482 ];
1483 candidates
1484 .iter()
1485 .filter_map(|rel| {
1486 let p = cwd.join(rel);
1487 if p.exists() {
1488 Some(rel.to_string())
1489 } else {
1490 None
1491 }
1492 })
1493 .collect()
1494}
1495
1496async fn collect_dns_checks(hosts: &[&str]) -> Vec<DnsCheck> {
1497 let mut out = Vec::new();
1498 for host in hosts {
1499 let host = (*host).to_string();
1500 match tokio::net::lookup_host(format!("{host}:80")).await {
1501 Ok(addrs) => {
1502 let addresses: Vec<String> = addrs.map(|a| a.ip().to_string()).collect();
1503 out.push(DnsCheck {
1504 host,
1505 ok: !addresses.is_empty(),
1506 addresses,
1507 error: None,
1508 });
1509 }
1510 Err(err) => out.push(DnsCheck {
1511 host,
1512 ok: false,
1513 addresses: Vec::new(),
1514 error: Some(err.to_string()),
1515 }),
1516 }
1517 }
1518 out
1519}
1520
1521async fn collect_http_probes(services: &[(String, u16, Option<String>)]) -> Vec<HttpProbe> {
1522 let client = match reqwest::Client::builder()
1523 .timeout(Duration::from_secs(4))
1524 .redirect(reqwest::redirect::Policy::limited(3))
1525 .build()
1526 {
1527 Ok(c) => c,
1528 Err(err) => {
1529 return vec![HttpProbe {
1530 name: "http-client".into(),
1531 url: String::new(),
1532 ok: false,
1533 status: None,
1534 latency_ms: None,
1535 error: Some(err.to_string()),
1536 }];
1537 }
1538 };
1539
1540 let mut out = Vec::new();
1541 for (name, port, url_hint) in services {
1542 let candidates = [
1543 url_hint
1544 .as_ref()
1545 .map(|u| u.trim_end_matches('/').to_string())
1546 .filter(|u| !u.is_empty()),
1547 Some(format!("http://127.0.0.1:{port}/")),
1548 Some(format!("http://127.0.0.1:{port}/health")),
1549 Some(format!("http://127.0.0.1:{port}/api/health")),
1550 ];
1551 let mut probed = false;
1552 for url in candidates.into_iter().flatten() {
1553 let started = Instant::now();
1554 match client.get(&url).send().await {
1555 Ok(resp) => {
1556 let status = resp.status().as_u16();
1557 let ok = status < 500;
1558 out.push(HttpProbe {
1559 name: name.clone(),
1560 url,
1561 ok,
1562 status: Some(status),
1563 latency_ms: Some(started.elapsed().as_millis() as u64),
1564 error: None,
1565 });
1566 probed = true;
1567 break;
1568 }
1569 Err(_) => continue,
1570 }
1571 }
1572 if !probed {
1573 out.push(HttpProbe {
1574 name: name.clone(),
1575 url: format!("http://127.0.0.1:{port}/"),
1576 ok: false,
1577 status: None,
1578 latency_ms: None,
1579 error: Some("no successful HTTP response".into()),
1580 });
1581 }
1582 }
1583 out
1584}
1585
1586async fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> Option<String> {
1587 let output = Command::new("git")
1588 .args(args)
1589 .current_dir(cwd)
1590 .output()
1591 .await
1592 .ok()?;
1593 if !output.status.success() {
1594 return None;
1595 }
1596 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1597}
1598
1599pub(crate) fn credential_helper_executable(helper: &str) -> Option<String> {
1606 let trimmed = helper.trim();
1607 if trimmed.is_empty() {
1608 return None;
1609 }
1610 let without_bang = trimmed.strip_prefix('!').unwrap_or(trimmed).trim();
1611 let first = without_bang.split_whitespace().next()?.trim();
1612 if first.is_empty() {
1613 return None;
1614 }
1615 let looks_like_path = first.starts_with('/')
1617 || first.starts_with('\\')
1618 || (first.len() > 2 && first.as_bytes()[1] == b':');
1619 if looks_like_path {
1620 Some(first.to_string())
1621 } else {
1622 None
1623 }
1624}
1625
1626pub(crate) fn is_stale_gh_credential_helper(helper: &str) -> bool {
1630 let lower = helper.to_ascii_lowercase();
1631 let mentions_gh_cred = lower.contains("git-credential")
1632 || lower.contains("/gh ")
1633 || lower.ends_with("/gh")
1634 || lower.contains("\\gh ")
1635 || lower.ends_with("\\gh");
1636
1637 if lower.contains("/snap/gh/") {
1638 if let Some(exe) = credential_helper_executable(helper) {
1639 return !std::path::Path::new(&exe).is_file();
1640 }
1641 return true;
1643 }
1644
1645 if !mentions_gh_cred {
1646 return false;
1647 }
1648
1649 if let Some(exe) = credential_helper_executable(helper) {
1650 let file_name = std::path::Path::new(&exe)
1651 .file_name()
1652 .and_then(|s| s.to_str())
1653 .unwrap_or("");
1654 let is_gh = file_name == "gh"
1655 || file_name.eq_ignore_ascii_case("gh.exe")
1656 || file_name.starts_with("gh.");
1657 if is_gh {
1658 return !std::path::Path::new(&exe).is_file();
1659 }
1660 }
1661 false
1662}
1663
1664async fn list_credential_helpers() -> Vec<String> {
1665 let output = Command::new("git")
1667 .args(["config", "--get-all", "credential.helper"])
1668 .output()
1669 .await;
1670 match output {
1671 Ok(out) if out.status.success() || !out.stdout.is_empty() => {
1672 String::from_utf8_lossy(&out.stdout)
1674 .lines()
1675 .map(str::trim)
1676 .filter(|line| !line.is_empty())
1677 .map(ToOwned::to_owned)
1678 .collect()
1679 }
1680 _ => Vec::new(),
1681 }
1682}
1683
1684async fn resolve_gh_path() -> Option<String> {
1685 if cfg!(target_os = "windows") {
1686 let output = run_command_with_timeout("where", &["gh"], PROGRAM_CHECK_TIMEOUT).await?;
1687 if !output.status.success() {
1688 return None;
1689 }
1690 let first = String::from_utf8_lossy(&output.stdout)
1691 .lines()
1692 .next()?
1693 .trim()
1694 .to_string();
1695 if first.is_empty() {
1696 None
1697 } else {
1698 Some(first)
1699 }
1700 } else {
1701 let output = run_command_with_timeout("which", &["gh"], PROGRAM_CHECK_TIMEOUT).await?;
1702 if !output.status.success() {
1703 return None;
1704 }
1705 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
1706 if path.is_empty() || !std::path::Path::new(&path).is_file() {
1707 None
1708 } else {
1709 Some(path)
1710 }
1711 }
1712}
1713
1714async fn repair_gh_credential_helper(gh_path: &str) -> Result<Vec<String>, String> {
1720 let _ = Command::new("git")
1722 .args(["config", "--global", "--unset-all", "credential.helper"])
1723 .output()
1724 .await;
1725
1726 let _ = Command::new("git")
1728 .args(["config", "--system", "--unset-all", "credential.helper"])
1729 .output()
1730 .await;
1731
1732 let setup = Command::new(gh_path)
1734 .args(["auth", "setup-git"])
1735 .output()
1736 .await
1737 .map_err(|e| format!("failed to run `{gh_path} auth setup-git`: {e}"))?;
1738
1739 if !setup.status.success() {
1740 let helper_value = format!("!{gh_path} auth git-credential");
1742 let set = Command::new("git")
1743 .args(["config", "--global", "credential.helper", &helper_value])
1744 .output()
1745 .await
1746 .map_err(|e| format!("failed to set credential.helper: {e}"))?;
1747 if !set.status.success() {
1748 let stderr = String::from_utf8_lossy(&set.stderr);
1749 let setup_err = String::from_utf8_lossy(&setup.stderr);
1750 return Err(format!(
1751 "`gh auth setup-git` failed ({}); setting helper also failed: {}",
1752 setup_err.trim(),
1753 stderr.trim()
1754 ));
1755 }
1756 }
1757
1758 let helpers = list_credential_helpers().await;
1759 let still_stale: Vec<_> = helpers
1760 .iter()
1761 .filter(|h| is_stale_gh_credential_helper(h))
1762 .cloned()
1763 .collect();
1764 if !still_stale.is_empty() {
1765 return Err(format!(
1766 "stale credential helper(s) remain after repair: {}",
1767 still_stale.join("; ")
1768 ));
1769 }
1770 Ok(helpers)
1771}
1772
1773async fn collect_and_fix_credential_helpers() -> Option<GitCredentialHelperDiag> {
1774 if cfg!(target_os = "windows") {
1776 return None;
1777 }
1778
1779 let helpers = list_credential_helpers().await;
1780 let stale_helpers: Vec<String> = helpers
1781 .iter()
1782 .filter(|h| is_stale_gh_credential_helper(h))
1783 .cloned()
1784 .collect();
1785
1786 if stale_helpers.is_empty() {
1787 let has_gh_helper = helpers.iter().any(|h| {
1789 let lower = h.to_ascii_lowercase();
1790 lower.contains("gh") && lower.contains("git-credential")
1791 });
1792 if !has_gh_helper {
1793 return None;
1794 }
1795 let gh_path = resolve_gh_path().await;
1796 return Some(GitCredentialHelperDiag {
1797 helpers,
1798 stale_helpers: Vec::new(),
1799 gh_path,
1800 fix_attempted: false,
1801 fixed: false,
1802 message: Some("GitHub CLI credential helper looks healthy".into()),
1803 });
1804 }
1805
1806 let gh_path = resolve_gh_path().await;
1807 let mut diag = GitCredentialHelperDiag {
1808 helpers: helpers.clone(),
1809 stale_helpers: stale_helpers.clone(),
1810 gh_path: gh_path.clone(),
1811 fix_attempted: false,
1812 fixed: false,
1813 message: None,
1814 };
1815
1816 let Some(gh) = gh_path else {
1817 diag.message = Some(format!(
1818 "Stale Git credential helper(s) point at a missing GitHub CLI binary: {}. \
1819Install native `gh` (not Snap): `sudo apt update && sudo apt install gh`, then `gh auth login` and `gh auth setup-git`.",
1820 stale_helpers.join("; ")
1821 ));
1822 let _ = log_warn(
1823 "diag",
1824 "Stale git credential.helper and gh not on PATH",
1825 diag.message.as_deref(),
1826 )
1827 .await;
1828 return Some(diag);
1829 };
1830
1831 diag.fix_attempted = true;
1832 let stale_summary = stale_helpers.join("; ");
1833 let _ = log_warn(
1834 "diag",
1835 "Stale git credential.helper (deleted Snap/missing gh path)",
1836 Some(&stale_summary),
1837 )
1838 .await;
1839 match repair_gh_credential_helper(&gh).await {
1840 Ok(new_helpers) => {
1841 diag.fixed = true;
1842 diag.helpers = new_helpers;
1843 diag.stale_helpers = Vec::new();
1844 diag.message = Some(format!(
1845 "Repaired stale Snap/missing `gh` credential helper → using `{gh}` (`gh auth setup-git` / explicit helper)."
1846 ));
1847 let _ = log_info(
1848 "diag",
1849 "Repaired git credential.helper to current gh",
1850 Some(gh.as_str()),
1851 )
1852 .await;
1853 }
1854 Err(err) => {
1855 diag.fixed = false;
1856 diag.helpers = list_credential_helpers().await;
1857 diag.stale_helpers = diag
1858 .helpers
1859 .iter()
1860 .filter(|h| is_stale_gh_credential_helper(h))
1861 .cloned()
1862 .collect();
1863 diag.message = Some(format!(
1864 "Detected stale credential helper(s) {} but auto-repair failed: {}. \
1865Run: git config --global --unset-all credential.helper; gh auth setup-git",
1866 stale_helpers.join("; "),
1867 err
1868 ));
1869 let _ = log_warn(
1870 "diag",
1871 "Failed to repair git credential.helper",
1872 Some(err.as_str()),
1873 )
1874 .await;
1875 }
1876 }
1877 Some(diag)
1878}
1879
1880async fn collect_git_hygiene(cwd: &std::path::Path) -> Option<GitHygiene> {
1881 let credential_helper = collect_and_fix_credential_helpers().await;
1882
1883 let inside = git_stdout(cwd, &["rev-parse", "--is-inside-work-tree"]).await;
1884 if inside.as_deref() != Some("true") && !cwd.join(".git").exists() {
1885 let mut notes = vec!["Not a git repository".into()];
1886 if let Some(ref cred) = credential_helper {
1887 if let Some(msg) = &cred.message {
1888 notes.push(msg.clone());
1889 }
1890 }
1891 return Some(GitHygiene {
1892 is_repo: false,
1893 branch: None,
1894 dirty: None,
1895 ahead: None,
1896 behind: None,
1897 remote: None,
1898 notes,
1899 credential_helper,
1900 });
1901 }
1902
1903 let branch = git_stdout(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
1904 let status = git_stdout(cwd, &["status", "--porcelain"]).await;
1905 let dirty = status.as_ref().map(|s| !s.is_empty());
1906 let remote = git_stdout(cwd, &["remote", "get-url", "origin"]).await;
1907 let mut notes = Vec::new();
1908 if dirty == Some(true) {
1909 notes.push("Working tree has uncommitted changes".into());
1910 }
1911 if let Some(ref cred) = credential_helper {
1912 if let Some(msg) = &cred.message {
1913 notes.push(msg.clone());
1914 } else if !cred.stale_helpers.is_empty() && !cred.fixed {
1915 notes.push(format!(
1916 "Stale git credential.helper: {}",
1917 cred.stale_helpers.join("; ")
1918 ));
1919 }
1920 }
1921 Some(GitHygiene {
1922 is_repo: true,
1923 branch,
1924 dirty,
1925 ahead: None,
1926 behind: None,
1927 remote,
1928 notes,
1929 credential_helper,
1930 })
1931}
1932
1933async fn collect_cli_auth_diagnostic() -> CliAuthDiagnostic {
1934 match crate::commands::cli_session::require_authenticated_cli_session().await {
1935 Ok(session) => CliAuthDiagnostic {
1936 logged_in: true,
1937 user_email: Some(session.user.email),
1938 user_name: Some(session.user.name),
1939 token_prefix: Some(session.token.prefix),
1940 message: "CLI session valid".into(),
1941 },
1942 Err(err) => CliAuthDiagnostic {
1943 logged_in: false,
1944 user_email: None,
1945 user_name: None,
1946 token_prefix: None,
1947 message: err,
1948 },
1949 }
1950}
1951
1952async fn collect_docker_diagnostic() -> DockerDiagnostic {
1953 let cli_present = check_program_installed("docker").await;
1954 if !cli_present {
1955 return DockerDiagnostic {
1956 cli_present: false,
1957 daemon_ok: false,
1958 version: None,
1959 compose_present: check_program_installed("docker-compose").await
1960 || check_program_installed("docker").await,
1961 note: Some("docker CLI not found on PATH".into()),
1962 };
1963 }
1964 let version = get_version("docker", &["--version"]).await;
1965 let daemon = Command::new("docker")
1966 .args(["info", "--format", "{{.ServerVersion}}"])
1967 .output()
1968 .await;
1969 let daemon_ok = daemon
1970 .as_ref()
1971 .map(|o| o.status.success())
1972 .unwrap_or(false);
1973 let compose_present = check_program_installed("docker-compose").await
1974 || Command::new("docker")
1975 .args(["compose", "version"])
1976 .output()
1977 .await
1978 .map(|o| o.status.success())
1979 .unwrap_or(false);
1980 DockerDiagnostic {
1981 cli_present: true,
1982 daemon_ok,
1983 version: version.version,
1984 compose_present,
1985 note: if daemon_ok {
1986 None
1987 } else {
1988 Some("docker daemon not reachable (is Docker running?)".into())
1989 },
1990 }
1991}
1992
1993fn build_recommendations(report: &DiagnosticReport) -> Vec<String> {
1994 let mut recs = Vec::new();
1995 if !report.connectivity {
1996 recs.push("Network offline — check interface, VPN, or firewall.".into());
1997 }
1998 if !report.project_config_present {
1999 recs.push("No XBP project config — run `xbp init` or open a project with `.xbp/xbp.yaml`.".into());
2000 }
2001 if let Some(auth) = &report.cli_auth {
2002 if !auth.logged_in {
2003 recs.push("CLI not logged in — run `xbp login` for dashboard features.".into());
2004 }
2005 }
2006 if let Some(docker) = &report.docker {
2007 if docker.cli_present && !docker.daemon_ok {
2008 recs.push("Docker CLI present but daemon unavailable — start Docker Desktop/service.".into());
2009 }
2010 }
2011 let disk_warn = report.disks.iter().any(|d| d.percent > 85.0);
2012 if disk_warn || report.system_metrics.disk_percent > 85.0 {
2013 recs.push("Disk usage high (>85%) — free space before large builds/deploys.".into());
2014 }
2015 if report.system_metrics.memory_percent > 90.0 {
2016 recs.push("Memory pressure high — close unused processes before heavy jobs.".into());
2017 }
2018 let blocked = report.port_checks.iter().filter(|p| p.is_blocked).count();
2019 if blocked > 0 {
2020 recs.push(format!(
2021 "{blocked} port(s) appear firewall-blocked — review iptables/ufw/Windows Firewall."
2022 ));
2023 }
2024 if let Some(nginx) = &report.nginx_status {
2025 if nginx.is_running && !nginx.config_valid {
2026 recs.push("Nginx running with invalid config — run `nginx -t` and `xbp nginx`.".into());
2027 }
2028 }
2029 for probe in &report.http_probes {
2030 if !probe.ok {
2031 recs.push(format!(
2032 "HTTP probe failed for `{}` ({}) — check process and port.",
2033 probe.name, probe.url
2034 ));
2035 }
2036 }
2037 for dns in &report.dns_checks {
2038 if !dns.ok {
2039 recs.push(format!(
2040 "DNS lookup failed for `{}` — check resolver / VPN split DNS.",
2041 dns.host
2042 ));
2043 }
2044 }
2045 if let Some(git) = &report.git {
2046 if git.is_repo && git.dirty == Some(true) {
2047 recs.push("Git working tree dirty — commit or stash before release/deploy.".into());
2048 }
2049 if let Some(cred) = &git.credential_helper {
2050 if !cred.stale_helpers.is_empty() && !cred.fixed {
2051 if cred.gh_path.is_none() {
2052 recs.push(
2053 "Stale git credential.helper points at a deleted Snap `gh`. Install native GitHub CLI (`sudo apt install gh`), then `gh auth login` and `gh auth setup-git`."
2054 .into(),
2055 );
2056 } else {
2057 recs.push(
2058 "Stale git credential.helper (deleted Snap `gh` path). Run: `git config --global --unset-all credential.helper` then `gh auth setup-git`."
2059 .into(),
2060 );
2061 }
2062 } else if cred.fixed {
2063 recs.push(
2064 "Repaired git credential.helper: replaced deleted Snap `gh` path with the current `gh` binary."
2065 .into(),
2066 );
2067 }
2068 }
2069 }
2070 if !report.installed_programs.get("git").copied().unwrap_or(false) {
2071 recs.push("git not found on PATH — install git for version/release workflows.".into());
2072 }
2073 if recs.is_empty() {
2074 recs.push("No critical issues detected — host looks ready for XBP ops.".into());
2075 }
2076 recs
2077}
2078
2079fn build_scorecard(report: &DiagnosticReport) -> DiagScorecard {
2080 let mut healthy = 0usize;
2081 let mut warnings = 0usize;
2082 let mut failures = 0usize;
2083
2084 if report.connectivity {
2085 healthy += 1;
2086 } else {
2087 failures += 1;
2088 }
2089 if report.project_config_present {
2090 healthy += 1;
2091 } else {
2092 warnings += 1;
2093 }
2094 let path_fail = report
2095 .path_permission_checks
2096 .iter()
2097 .filter(|p| !p.ok)
2098 .count();
2099 if path_fail == 0 {
2100 healthy += 1;
2101 } else {
2102 warnings += 1;
2103 }
2104 let blocked = report.port_checks.iter().filter(|p| p.is_blocked).count();
2105 if blocked == 0 {
2106 healthy += 1;
2107 } else {
2108 failures += 1;
2109 }
2110 if report.system_metrics.disk_percent > 90.0 {
2111 failures += 1;
2112 } else if report.system_metrics.disk_percent > 80.0 {
2113 warnings += 1;
2114 } else {
2115 healthy += 1;
2116 }
2117 if let Some(auth) = &report.cli_auth {
2118 if auth.logged_in {
2119 healthy += 1;
2120 } else {
2121 warnings += 1;
2122 }
2123 }
2124 let http_fail = report.http_probes.iter().filter(|p| !p.ok).count();
2125 if !report.http_probes.is_empty() {
2126 if http_fail == 0 {
2127 healthy += 1;
2128 } else if http_fail < report.http_probes.len() {
2129 warnings += 1;
2130 } else {
2131 failures += 1;
2132 }
2133 }
2134 if let Some(git) = &report.git {
2135 if let Some(cred) = &git.credential_helper {
2136 if !cred.stale_helpers.is_empty() && !cred.fixed {
2137 failures += 1;
2138 } else if cred.fixed {
2139 warnings += 1;
2140 } else if cred.message.is_some() {
2141 healthy += 1;
2142 }
2143 }
2144 }
2145 let grade = if failures > 0 {
2146 "FAIL"
2147 } else if warnings > 0 {
2148 "WARN"
2149 } else {
2150 "OK"
2151 }
2152 .to_string();
2153 DiagScorecard {
2154 healthy,
2155 warnings,
2156 failures,
2157 grade,
2158 }
2159}
2160
2161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2162enum SectionState {
2163 Healthy,
2164 Warn,
2165 Fail,
2166 Info,
2167}
2168
2169fn section_badge(state: SectionState) -> colored::ColoredString {
2170 match state {
2171 SectionState::Healthy => "OK".green().bold(),
2172 SectionState::Warn => "WARN".yellow().bold(),
2173 SectionState::Fail => "FAIL".red().bold(),
2174 SectionState::Info => "INFO".cyan().bold(),
2175 }
2176}
2177
2178fn print_section_header(title: &str, state: SectionState, summary: &str) {
2179 println!(
2180 "\n{} {} {}",
2181 section_badge(state),
2182 title.bright_white().bold(),
2183 summary.bright_black()
2184 );
2185}
2186
2187fn diag_bool(value: bool) -> colored::ColoredString {
2188 if value {
2189 "true".green()
2190 } else {
2191 "false".red()
2192 }
2193}
2194
2195fn diag_bool_fragment(label: &str, value: bool) -> String {
2196 format!("{}={}", label, diag_bool(value))
2197}
2198
2199fn diag_ratio(passing: usize, total: usize) -> String {
2200 format!(
2201 "{}{}",
2202 passing.to_string().green(),
2203 format!("/{}", total).bright_white()
2204 )
2205}
2206
2207fn color_service_status(status: &str) -> colored::ColoredString {
2208 let lower = status.to_ascii_lowercase();
2209 if lower.contains("running") || lower.contains("active") || lower.contains("online") {
2210 status.green()
2211 } else if lower.contains("stopped") || lower.contains("failed") || lower.contains("error") {
2212 status.red()
2213 } else if lower.contains("degraded") || lower.contains("warn") {
2214 status.yellow()
2215 } else {
2216 status.bright_white()
2217 }
2218}
2219
2220fn render_feature_flags(flags: &HashMap<String, bool>) -> String {
2221 let mut items = flags
2222 .iter()
2223 .map(|(name, value)| format!("{}={}", name, diag_bool(*value)))
2224 .collect::<Vec<_>>();
2225 items.sort();
2226 items.join(" ")
2227}
2228
2229fn summarize_path_permissions(report: &DiagnosticReport) -> (usize, usize) {
2230 let total = report.path_permission_checks.len();
2231 let passing = report
2232 .path_permission_checks
2233 .iter()
2234 .filter(|item| item.ok)
2235 .count();
2236 (passing, total)
2237}
2238
2239fn summarize_port_checks(report: &DiagnosticReport) -> (usize, usize, usize) {
2240 let total = report.port_checks.len();
2241 let open = report
2242 .port_checks
2243 .iter()
2244 .filter(|item| item.is_open)
2245 .count();
2246 let blocked = report
2247 .port_checks
2248 .iter()
2249 .filter(|item| item.is_blocked)
2250 .count();
2251 (open, blocked, total)
2252}
2253
2254pub async fn print_diagnostic_report(report: &DiagnosticReport) {
2255 let feature_flags = render_feature_flags(&report.feature_flags);
2256 let grade_state = match report.scorecard.grade.as_str() {
2257 "OK" => SectionState::Healthy,
2258 "WARN" => SectionState::Warn,
2259 _ => SectionState::Fail,
2260 };
2261 print_section_header(
2262 "Scorecard",
2263 grade_state,
2264 &format!(
2265 "grade={} | healthy={} warn={} fail={}",
2266 report.scorecard.grade,
2267 report.scorecard.healthy,
2268 report.scorecard.warnings,
2269 report.scorecard.failures
2270 ),
2271 );
2272 println!(
2273 " {} {}",
2274 "Working directory:".bright_white(),
2275 report.cwd.bright_cyan()
2276 );
2277 if let Some(host) = &report.hostname {
2278 println!(" {} {}", "Hostname:".bright_white(), host);
2279 }
2280 if !report.local_ips.is_empty() {
2281 println!(
2282 " {} {}",
2283 "Local IPs:".bright_white(),
2284 report.local_ips.join(", ")
2285 );
2286 }
2287
2288 let env_summary = format!(
2289 "v{} | {} | connectivity {}",
2290 report.xbp_cli_version,
2291 feature_flags,
2292 if report.connectivity {
2293 "online"
2294 } else {
2295 "offline"
2296 }
2297 );
2298 print_section_header(
2299 "Environment",
2300 if report.connectivity {
2301 SectionState::Healthy
2302 } else {
2303 SectionState::Warn
2304 },
2305 &env_summary,
2306 );
2307
2308 println!(
2309 " {} {}",
2310 "XBP version:".bright_white(),
2311 report.xbp_cli_version.bright_cyan()
2312 );
2313 println!(" {} {}", "Feature flags:".bright_white(), feature_flags);
2314
2315 if let Some(os) = &report.os {
2316 let os_line = [
2317 os.name.clone().unwrap_or_else(|| "Unknown OS".to_string()),
2318 os.version.clone().unwrap_or_default(),
2319 ]
2320 .into_iter()
2321 .filter(|s| !s.is_empty())
2322 .collect::<Vec<_>>()
2323 .join(" ");
2324 println!(" {} {}", "OS:".bright_white(), os_line);
2325 if let Some(kernel) = &os.kernel_version {
2326 println!(" {} {}", "Kernel:".bright_white(), kernel);
2327 }
2328 if let Some(arch) = &os.arch {
2329 println!(" {} {}", "Arch:".bright_white(), arch);
2330 }
2331 }
2332
2333 if let Some(cpu) = &report.cpu {
2334 println!(
2335 " {} {} ({} cores, {:.1}% avg)",
2336 "CPU:".bright_white(),
2337 cpu.brand.clone().unwrap_or_else(|| "Unknown".into()),
2338 cpu.cores,
2339 cpu.usage
2340 );
2341 }
2342
2343 if let Some(shell) = &report.shell {
2344 if let Some(s) = &shell.shell {
2345 println!(" {} {}", "Shell:".bright_white(), s);
2346 }
2347 if let Some(t) = &shell.term {
2348 println!(" {} {}", "TERM:".bright_white(), t);
2349 }
2350 }
2351
2352 println!(
2353 " {} {}",
2354 "Connectivity:".bright_white(),
2355 if report.connectivity {
2356 "online".green()
2357 } else {
2358 "offline".red()
2359 }
2360 );
2361
2362 if let Some(speed) = &report.internet_speed {
2363 println!(" {} {:.2} ms", "Ping:".bright_white(), speed.ping_ms);
2364 println!(
2365 " {} {:.2} Mbps",
2366 "Download:".bright_white(),
2367 speed.download_mbps
2368 );
2369 if speed.upload_mbps > 0.0 {
2370 println!(
2371 " {} {:.2} Mbps",
2372 "Upload:".bright_white(),
2373 speed.upload_mbps
2374 );
2375 }
2376 }
2377
2378 if !report.gpu_candidates.is_empty() {
2379 print_section_header(
2380 "GPU candidates",
2381 SectionState::Info,
2382 &format!("{} candidate(s)", report.gpu_candidates.len()),
2383 );
2384 for line in &report.gpu_candidates {
2385 println!(" {}", line);
2386 }
2387 }
2388
2389 if !report.disks.is_empty() {
2390 print_section_header(
2391 "Disks",
2392 SectionState::Info,
2393 &format!("{} mount(s)", report.disks.len()),
2394 );
2395 for disk in &report.disks {
2396 let current = if disk.is_current { " *" } else { "" };
2397 let fs = disk.fs.clone().unwrap_or_else(|| "-".to_string());
2398 println!(
2399 " {}{} {} {:>5.1}% {} ({} / {} GB)",
2400 disk.mount,
2401 current,
2402 fs,
2403 disk.percent,
2404 disk.bar,
2405 disk.used / 1024 / 1024 / 1024,
2406 disk.total / 1024 / 1024 / 1024
2407 );
2408 }
2409 println!(" {} current mount", "*".bright_cyan());
2410 }
2411
2412 print_section_header(
2413 "System metrics",
2414 SectionState::Info,
2415 &format!("{} processes", report.system_metrics.process_count),
2416 );
2417 let metrics = &report.system_metrics;
2418
2419 let cpu_color = if metrics.cpu_usage > 80.0 {
2420 "red"
2421 } else if metrics.cpu_usage > 50.0 {
2422 "yellow"
2423 } else {
2424 "green"
2425 };
2426 println!(
2427 " {} {:.1}%",
2428 "CPU usage:".bright_white(),
2429 format!("{}", metrics.cpu_usage).color(cpu_color)
2430 );
2431
2432 let mem_color = if metrics.memory_percent > 80.0 {
2433 "red"
2434 } else if metrics.memory_percent > 50.0 {
2435 "yellow"
2436 } else {
2437 "green"
2438 };
2439 println!(
2440 " {} {:.1}% ({} MB / {} MB)",
2441 "Memory:".bright_white(),
2442 format!("{}", metrics.memory_percent).color(mem_color),
2443 metrics.memory_used / 1024 / 1024,
2444 metrics.memory_total / 1024 / 1024
2445 );
2446
2447 let disk_color = if metrics.disk_percent > 80.0 {
2448 "red"
2449 } else if metrics.disk_percent > 50.0 {
2450 "yellow"
2451 } else {
2452 "green"
2453 };
2454 println!(
2455 " {} {:.1}% ({} GB / {} GB)",
2456 "Disk:".bright_white(),
2457 format!("{}", metrics.disk_percent).color(disk_color),
2458 metrics.disk_used / 1024 / 1024 / 1024,
2459 metrics.disk_total / 1024 / 1024 / 1024
2460 );
2461
2462 println!(
2463 " {} {} MB down / {} MB up",
2464 "Network:".bright_white(),
2465 metrics.network_rx / 1024 / 1024,
2466 metrics.network_tx / 1024 / 1024
2467 );
2468
2469 let uptime_hours = metrics.uptime / 3600;
2470 let uptime_minutes = (metrics.uptime % 3600) / 60;
2471 println!(
2472 " {} {}h {}m",
2473 "Uptime:".bright_white(),
2474 uptime_hours,
2475 uptime_minutes
2476 );
2477 println!(
2478 " {} {}",
2479 "Processes:".bright_white(),
2480 metrics.process_count
2481 );
2482
2483 let (open_ports, blocked_ports, _) = summarize_port_checks(report);
2484 let runtime_state = if blocked_ports == 0 {
2485 SectionState::Healthy
2486 } else {
2487 SectionState::Fail
2488 };
2489 print_section_header(
2490 "Runtime",
2491 runtime_state,
2492 &format!(
2493 "services={} | pm2={} | ports={} open / {} blocked",
2494 report.service_statuses.len(),
2495 report.pm2_process_count.unwrap_or(0),
2496 open_ports,
2497 blocked_ports
2498 ),
2499 );
2500
2501 if let Some(proxy) = &report.proxy_detection {
2502 println!(" {}", "Proxy stack:".bright_white());
2503 if let Some(n) = proxy.nginx_sites_available {
2504 println!(" {} {}", "sites-available:".bright_white(), n);
2505 }
2506 if let Some(v) = proxy.nginx_active {
2507 println!(" {} {}", "nginx active:".bright_white(), diag_bool(v));
2508 }
2509 if let Some(v) = proxy.traefik_active {
2510 println!(" {} {}", "traefik active:".bright_white(), diag_bool(v));
2511 }
2512 if let Some(v) = proxy.apache_active {
2513 println!(" {} {}", "apache active:".bright_white(), diag_bool(v));
2514 }
2515 }
2516
2517 if let Some(count) = report.pm2_process_count {
2518 println!(" {} {}", "PM2 process count:".bright_white(), count);
2519 }
2520
2521 if !report.service_statuses.is_empty() {
2522 let mut items: Vec<_> = report
2523 .service_statuses
2524 .iter()
2525 .filter(|(_, status)| status.as_str() != "unknown")
2527 .collect();
2528 if items.is_empty() {
2529 println!(
2530 " {} {}",
2531 "Services:".bright_white(),
2532 "(no systemd units detected on this host)".bright_black()
2533 );
2534 } else {
2535 println!(" {}", "Services:".bright_white());
2536 items.sort_by_key(|(k, _)| *k);
2537 for (name, status) in items {
2538 println!(" {:15} {}", name, color_service_status(status));
2539 }
2540 }
2541 }
2542
2543 if let Some(nginx) = &report.nginx_status {
2544 println!(" {}", "Nginx:".bright_white());
2545 println!(
2546 " {} {}",
2547 if nginx.is_running {
2548 "OK".green()
2549 } else {
2550 "NO".red()
2551 },
2552 if nginx.is_running {
2553 "Running".green()
2554 } else {
2555 "Stopped".red()
2556 }
2557 );
2558 println!(
2559 " {} {}",
2560 if nginx.is_enabled {
2561 "OK".green()
2562 } else {
2563 "NO".red()
2564 },
2565 if nginx.is_enabled {
2566 "Enabled".green()
2567 } else {
2568 "Disabled".red()
2569 }
2570 );
2571 println!(
2572 " {} {}",
2573 if nginx.config_valid {
2574 "OK".green()
2575 } else {
2576 "NO".red()
2577 },
2578 if nginx.config_valid {
2579 "Config valid".green()
2580 } else {
2581 "Config invalid".red()
2582 }
2583 );
2584 }
2585
2586 if !report.port_checks.is_empty() {
2587 println!(" {}", "Port coverage:".bright_white());
2588 let (xbp_ports, other_ports): (Vec<_>, Vec<_>) = report
2589 .port_checks
2590 .iter()
2591 .partition(|port_check| !port_check.xbp_projects.is_empty());
2592
2593 for port_check in xbp_ports.iter().chain(other_ports.iter()) {
2594 let status_icon = if port_check.is_open {
2595 "OK".green()
2596 } else {
2597 "NO".red()
2598 };
2599 let blocked_text = if port_check.is_blocked {
2600 " (BLOCKED)".red().to_string()
2601 } else {
2602 String::new()
2603 };
2604 let xbp_suffix = if port_check.xbp_projects.is_empty() {
2605 String::new()
2606 } else {
2607 format!(" [XBP: {}]", port_check.xbp_projects.join(", "))
2608 };
2609 let line = format!(
2610 " {} Port {}: {}{}{}",
2611 status_icon,
2612 port_check.port,
2613 if port_check.is_open {
2614 "Available".green().to_string()
2615 } else {
2616 "In use".red().to_string()
2617 },
2618 blocked_text,
2619 xbp_suffix
2620 );
2621
2622 if port_check.xbp_projects.is_empty() {
2623 println!("{}", line);
2624 } else {
2625 println!("{}", line.bright_magenta());
2626 }
2627 }
2628 }
2629
2630 let (path_passing, path_total) = summarize_path_permissions(report);
2631 let project_state = if report.project_config_present || !report.provider_manifests.is_empty() {
2632 if path_passing == path_total {
2633 SectionState::Healthy
2634 } else {
2635 SectionState::Warn
2636 }
2637 } else {
2638 SectionState::Info
2639 };
2640 print_section_header(
2641 "Project",
2642 project_state,
2643 &format!(
2644 "config={} | services={} | manifests={} | paths={}",
2645 diag_bool(report.project_config_present),
2646 report.configured_services.len(),
2647 report.provider_manifests.len(),
2648 diag_ratio(path_passing, path_total)
2649 ),
2650 );
2651
2652 if !report.configured_services.is_empty() {
2653 println!(
2654 " {} {}",
2655 "Configured services:".bright_white(),
2656 report.configured_services.join(", ").bright_white()
2657 );
2658 }
2659
2660 if !report.provider_manifests.is_empty() {
2661 println!(
2662 " {} {}",
2663 "Provider manifests:".bright_white(),
2664 report.provider_manifests.join(", ").bright_white()
2665 );
2666 }
2667
2668 if !report.path_permission_checks.is_empty() {
2669 println!(
2670 " {} {}",
2671 "Checks passing:".bright_white(),
2672 diag_ratio(path_passing, path_total)
2673 );
2674
2675 for item in &report.path_permission_checks {
2676 let icon = if item.ok { "OK".green() } else { "NO".red() };
2677 println!(
2678 " {} {} [{}]",
2679 icon,
2680 item.path.bright_white(),
2681 item.required
2682 );
2683 println!(" purpose: {}", item.purpose);
2684 println!(
2685 " status: {} {} {} {}",
2686 diag_bool_fragment("exists", item.exists),
2687 diag_bool_fragment("read", item.readable),
2688 diag_bool_fragment("write", item.writable),
2689 diag_bool_fragment("create", item.creatable)
2690 );
2691 if let Some(msg) = &item.message {
2692 println!(" note: {}", msg);
2693 }
2694 }
2695 }
2696
2697 print_section_header(
2698 "XBP readiness",
2699 if report.project_config_present {
2700 SectionState::Healthy
2701 } else {
2702 SectionState::Warn
2703 },
2704 &format!(
2705 "version={} | feature flags={} | manifests={}",
2706 report.xbp_cli_version,
2707 report.feature_flags.len(),
2708 report.provider_manifests.len()
2709 ),
2710 );
2711 println!(
2712 " {} {}",
2713 "CLI version:".bright_white(),
2714 report.xbp_cli_version.bright_cyan()
2715 );
2716 println!(" {} {}", "Feature flags:".bright_white(), feature_flags);
2717 println!(
2718 " {} {}",
2719 "Project config:".bright_white(),
2720 if report.project_config_present {
2721 "detected".green()
2722 } else {
2723 "missing".red()
2724 }
2725 );
2726 println!(
2727 " {} {}",
2728 "Provider manifests:".bright_white(),
2729 report.provider_manifests.len()
2730 );
2731
2732 if !report.dns_checks.is_empty() {
2733 let fail = report.dns_checks.iter().filter(|d| !d.ok).count();
2734 print_section_header(
2735 "DNS",
2736 if fail == 0 {
2737 SectionState::Healthy
2738 } else {
2739 SectionState::Warn
2740 },
2741 &format!("{} host(s), {} failed", report.dns_checks.len(), fail),
2742 );
2743 for dns in &report.dns_checks {
2744 let icon = if dns.ok { "OK".green() } else { "NO".red() };
2745 println!(
2746 " {} {} → {}",
2747 icon,
2748 dns.host.bright_white(),
2749 if dns.ok {
2750 dns.addresses.join(", ")
2751 } else {
2752 dns.error.clone().unwrap_or_else(|| "lookup failed".into())
2753 }
2754 );
2755 }
2756 }
2757
2758 if !report.http_probes.is_empty() {
2759 let ok = report.http_probes.iter().filter(|p| p.ok).count();
2760 print_section_header(
2761 "HTTP probes",
2762 if ok == report.http_probes.len() {
2763 SectionState::Healthy
2764 } else if ok == 0 {
2765 SectionState::Fail
2766 } else {
2767 SectionState::Warn
2768 },
2769 &format!("{ok}/{} responding", report.http_probes.len()),
2770 );
2771 for probe in &report.http_probes {
2772 let icon = if probe.ok { "OK".green() } else { "NO".red() };
2773 let status = probe
2774 .status
2775 .map(|s| s.to_string())
2776 .unwrap_or_else(|| "-".into());
2777 let latency = probe
2778 .latency_ms
2779 .map(|ms| format!("{ms}ms"))
2780 .unwrap_or_else(|| "-".into());
2781 println!(
2782 " {} {:16} {} status={} latency={}",
2783 icon,
2784 probe.name.bright_white(),
2785 probe.url.bright_black(),
2786 status,
2787 latency
2788 );
2789 if let Some(err) = &probe.error {
2790 println!(" {}", err.yellow());
2791 }
2792 }
2793 }
2794
2795 if let Some(git) = &report.git {
2796 let cred_stale_unfixed = git
2797 .credential_helper
2798 .as_ref()
2799 .map(|c| !c.stale_helpers.is_empty() && !c.fixed)
2800 .unwrap_or(false);
2801 let cred_fixed = git
2802 .credential_helper
2803 .as_ref()
2804 .map(|c| c.fixed)
2805 .unwrap_or(false);
2806 print_section_header(
2807 "Git",
2808 if !git.is_repo && !cred_stale_unfixed {
2809 SectionState::Info
2810 } else if cred_stale_unfixed {
2811 SectionState::Fail
2812 } else if git.dirty == Some(true) || cred_fixed {
2813 SectionState::Warn
2814 } else {
2815 SectionState::Healthy
2816 },
2817 if git.is_repo {
2818 git.branch.as_deref().unwrap_or("detached")
2819 } else {
2820 "not a repo"
2821 },
2822 );
2823 if git.is_repo {
2824 if let Some(branch) = &git.branch {
2825 println!(" {} {}", "Branch:".bright_white(), branch);
2826 }
2827 if let Some(dirty) = git.dirty {
2828 println!(
2829 " {} {}",
2830 "Dirty:".bright_white(),
2831 if dirty {
2832 "yes".yellow()
2833 } else {
2834 "no".green()
2835 }
2836 );
2837 }
2838 if let Some(remote) = &git.remote {
2839 println!(" {} {}", "Origin:".bright_white(), remote);
2840 }
2841 }
2842 if let Some(cred) = &git.credential_helper {
2843 if !cred.helpers.is_empty() {
2844 println!(
2845 " {} {}",
2846 "credential.helper:".bright_white(),
2847 cred.helpers.join(" | ")
2848 );
2849 }
2850 if let Some(gh) = &cred.gh_path {
2851 println!(" {} {}", "gh:".bright_white(), gh);
2852 }
2853 if cred.fixed {
2854 println!(
2855 " {} {}",
2856 "credential.helper fix:".bright_white(),
2857 "applied".green()
2858 );
2859 } else if !cred.stale_helpers.is_empty() {
2860 println!(
2861 " {} {}",
2862 "stale helper(s):".bright_white(),
2863 cred.stale_helpers.join(" | ").red()
2864 );
2865 }
2866 }
2867 for note in &git.notes {
2868 let color_note = if note.to_ascii_lowercase().contains("repaired")
2869 || note.to_ascii_lowercase().contains("healthy")
2870 {
2871 note.green()
2872 } else {
2873 note.yellow()
2874 };
2875 println!(" {} {}", "Note:".bright_white(), color_note);
2876 }
2877 }
2878
2879 if let Some(auth) = &report.cli_auth {
2880 print_section_header(
2881 "CLI auth",
2882 if auth.logged_in {
2883 SectionState::Healthy
2884 } else {
2885 SectionState::Warn
2886 },
2887 if auth.logged_in { "signed in" } else { "signed out" },
2888 );
2889 println!(" {} {}", "Status:".bright_white(), auth.message);
2890 if let Some(email) = &auth.user_email {
2891 println!(
2892 " {} {} ({})",
2893 "User:".bright_white(),
2894 email,
2895 auth.user_name.clone().unwrap_or_default()
2896 );
2897 }
2898 if let Some(prefix) = &auth.token_prefix {
2899 println!(" {} {}", "Token:".bright_white(), prefix);
2900 }
2901 }
2902
2903 if let Some(docker) = &report.docker {
2904 print_section_header(
2905 "Docker",
2906 if !docker.cli_present {
2907 SectionState::Info
2908 } else if docker.daemon_ok {
2909 SectionState::Healthy
2910 } else {
2911 SectionState::Warn
2912 },
2913 if docker.daemon_ok {
2914 "daemon ok"
2915 } else if docker.cli_present {
2916 "daemon down"
2917 } else {
2918 "not installed"
2919 },
2920 );
2921 println!(
2922 " {} cli={} daemon={} compose={}",
2923 "Status:".bright_white(),
2924 diag_bool(docker.cli_present),
2925 diag_bool(docker.daemon_ok),
2926 diag_bool(docker.compose_present)
2927 );
2928 if let Some(v) = &docker.version {
2929 println!(" {} {}", "Version:".bright_white(), v);
2930 }
2931 if let Some(note) = &docker.note {
2932 println!(" {} {}", "Note:".bright_white(), note.yellow());
2933 }
2934 }
2935
2936 if !report.env_files.is_empty() {
2937 print_section_header(
2938 "Env files",
2939 SectionState::Info,
2940 &format!("{} found", report.env_files.len()),
2941 );
2942 for f in &report.env_files {
2943 println!(" {}", f.bright_cyan());
2944 }
2945 }
2946
2947 if !report.network_interfaces.is_empty() {
2948 print_section_header(
2949 "Network interfaces",
2950 SectionState::Info,
2951 &format!("{} iface(s)", report.network_interfaces.len()),
2952 );
2953 for iface in report.network_interfaces.iter().take(12) {
2954 println!(
2955 " {:16} rx={} MB tx={} MB",
2956 iface.name,
2957 iface.rx_bytes / 1024 / 1024,
2958 iface.tx_bytes / 1024 / 1024
2959 );
2960 }
2961 }
2962
2963 if !report.tool_versions.is_empty() {
2964 let present = report
2965 .tool_versions
2966 .values()
2967 .filter(|t| t.present)
2968 .count();
2969 print_section_header(
2970 "Tool versions",
2971 SectionState::Info,
2972 &format!("{present}/{} present", report.tool_versions.len()),
2973 );
2974 let mut items: Vec<_> = report.tool_versions.iter().collect();
2975 items.sort_by_key(|(k, _)| *k);
2976 for (name, tool) in items {
2977 if !tool.present {
2978 continue;
2979 }
2980 println!(
2981 " {:14} {}",
2982 name.bright_white(),
2983 tool.version.clone().unwrap_or_else(|| "present".into())
2984 );
2985 }
2986 }
2987
2988 if !report.installed_programs.is_empty() {
2989 let mut missing: Vec<_> = report
2990 .installed_programs
2991 .iter()
2992 .filter(|(_, ok)| !**ok)
2993 .map(|(k, _)| k.clone())
2994 .collect();
2995 missing.sort();
2996 let present = report.installed_programs.len() - missing.len();
2997 print_section_header(
2998 "Toolchain PATH",
2999 if missing.len() > report.installed_programs.len() / 2 {
3000 SectionState::Warn
3001 } else {
3002 SectionState::Info
3003 },
3004 &format!("{present} present / {} missing", missing.len()),
3005 );
3006 if !missing.is_empty() {
3007 println!(
3008 " {} {}",
3009 "Missing:".bright_white(),
3010 missing.join(", ").yellow()
3011 );
3012 }
3013 }
3014
3015 if !report.recommendations.is_empty() {
3016 print_section_header(
3017 "Recommendations",
3018 SectionState::Info,
3019 &format!("{} tip(s)", report.recommendations.len()),
3020 );
3021 for (i, rec) in report.recommendations.iter().enumerate() {
3022 println!(" {}. {}", i + 1, rec);
3023 }
3024 }
3025
3026 if let Some(watch) = &report.worktree_watch {
3027 let watch_state = if watch.branch_spool_exists || watch.watcher_running {
3028 SectionState::Healthy
3029 } else if watch.notes.iter().any(|note| note.contains("Failed")) {
3030 SectionState::Warn
3031 } else {
3032 SectionState::Info
3033 };
3034 let summary = match (
3035 watch.repo_owner.as_deref(),
3036 watch.repo_name.as_deref(),
3037 watch.branch.as_deref(),
3038 ) {
3039 (Some(owner), Some(name), Some(branch)) => format!(
3040 "runtime={} | {}/{} @ {} | events={} commits={}",
3041 watch.runtime_key, owner, name, branch, watch.event_jsonl_files, watch.commit_jsonl_files
3042 ),
3043 _ => format!(
3044 "runtime={} | global home only | wsl={}",
3045 watch.runtime_key,
3046 diag_bool(watch.is_wsl)
3047 ),
3048 };
3049 print_section_header("Worktree watch", watch_state, &summary);
3050
3051 println!(
3052 " {} {}",
3053 "Global XBP home:".bright_white(),
3054 watch.global_xbp_root.bright_cyan()
3055 );
3056 println!(
3057 " {} {}",
3058 "Mutations root:".bright_white(),
3059 watch.mutations_root
3060 );
3061 println!(
3062 " {} {} | {} {}",
3063 "Platform:".bright_white(),
3064 watch.platform,
3065 "runtime:".bright_white(),
3066 watch.runtime_key.bright_cyan()
3067 );
3068 println!(
3069 " {} {}",
3070 "WSL:".bright_white(),
3071 diag_bool(watch.is_wsl)
3072 );
3073
3074 if let (Some(owner), Some(name), Some(branch)) = (
3075 watch.repo_owner.as_deref(),
3076 watch.repo_name.as_deref(),
3077 watch.branch.as_deref(),
3078 ) {
3079 println!(
3080 " {} {}/{} ({})",
3081 "Repository:".bright_white(),
3082 owner,
3083 name,
3084 branch
3085 );
3086 }
3087
3088 if let Some(spool) = &watch.branch_spool {
3089 println!(
3090 " {} {} {}",
3091 "Branch spool:".bright_white(),
3092 spool,
3093 if watch.branch_spool_exists {
3094 "(exists)".green()
3095 } else {
3096 "(missing)".yellow()
3097 }
3098 );
3099 }
3100
3101 println!(
3102 " {} events={} commits={}",
3103 "Spool files:".bright_white(),
3104 watch.event_jsonl_files,
3105 watch.commit_jsonl_files
3106 );
3107
3108 if let Some(state_path) = &watch.watcher_state_path {
3109 println!(
3110 " {} {} | present={} running={}",
3111 "Watcher state:".bright_white(),
3112 state_path,
3113 diag_bool(watch.watcher_state_present),
3114 diag_bool(watch.watcher_running)
3115 );
3116 }
3117
3118 if !watch.alternate_spool_roots.is_empty() {
3119 println!(" {}", "Alternate spools (legacy / shared):".bright_white());
3120 for root in &watch.alternate_spool_roots {
3121 println!(" {}", root);
3122 }
3123 }
3124
3125 if !watch.notes.is_empty() {
3126 println!(" {}", "Notes:".bright_white());
3127 for note in &watch.notes {
3128 println!(" - {}", note);
3129 }
3130 }
3131 }
3132
3133 if let Some(codetime) = &report.codetime {
3134 print_section_header(
3135 "CodeTime inventory",
3136 if codetime.refreshed {
3137 SectionState::Healthy
3138 } else {
3139 SectionState::Info
3140 },
3141 &format!(
3142 "saved={} | refreshed={}",
3143 codetime.saved_config_path,
3144 diag_bool(codetime.refreshed)
3145 ),
3146 );
3147 println!(
3148 " {} {}",
3149 "Saved in:".bright_white(),
3150 codetime.saved_config_path
3151 );
3152 println!(
3153 " {} {}",
3154 "Refreshed:".bright_white(),
3155 if codetime.refreshed {
3156 "cached"
3157 } else {
3158 "fresh"
3159 }
3160 );
3161 if let Some(user) = &codetime.inventory.system_user {
3162 println!(" {} {}", "User:".bright_white(), user);
3163 }
3164 if let Some(host) = &codetime.inventory.host_name {
3165 println!(" {} {}", "Host:".bright_white(), host);
3166 }
3167 println!(
3168 " {} {} / {}",
3169 "Platform:".bright_white(),
3170 codetime.inventory.platform,
3171 codetime.inventory.architecture
3172 );
3173 if let Some(os) = &codetime.inventory.operating_system {
3174 let os_label = os
3175 .long_version
3176 .clone()
3177 .or_else(|| match (&os.name, &os.version) {
3178 (Some(name), Some(version)) => Some(format!("{} {}", name, version)),
3179 (Some(name), None) => Some(name.clone()),
3180 (None, Some(version)) => Some(version.clone()),
3181 (None, None) => None,
3182 });
3183 if let Some(os_label) = os_label {
3184 println!(" {} {}", "Operating system:".bright_white(), os_label);
3185 }
3186 if let Some(kernel_version) = &os.kernel_version {
3187 println!(" {} {}", "Kernel:".bright_white(), kernel_version);
3188 }
3189 }
3190 if !codetime.inventory.drives.is_empty() {
3191 let drives = codetime
3192 .inventory
3193 .drives
3194 .iter()
3195 .map(|drive| match &drive.file_system {
3196 Some(file_system) => format!("{} ({})", drive.mount, file_system),
3197 None => drive.mount.clone(),
3198 })
3199 .collect::<Vec<_>>()
3200 .join(", ");
3201 println!(" {} {}", "Drives:".bright_white(), drives);
3202 }
3203 if let Some(home) = codetime.inventory.standard_paths.get("home") {
3204 println!(" {} {}", "Home:".bright_white(), home);
3205 }
3206 if let Some(xbp_root) = codetime.inventory.standard_paths.get("xbp_global_root") {
3207 println!(" {} {}", "XBP root:".bright_white(), xbp_root);
3208 }
3209 if let Some(cursor_root) = codetime.inventory.standard_paths.get("cursor_roaming") {
3210 println!(" {} {}", "Cursor root:".bright_white(), cursor_root);
3211 }
3212
3213 let installed_tool_labels = codetime
3214 .inventory
3215 .installed_tools
3216 .iter()
3217 .filter(|tool| tool.present)
3218 .map(|tool| match &tool.version {
3219 Some(version) => format!("{} ({})", tool.name, version),
3220 None => tool.name.clone(),
3221 })
3222 .collect::<Vec<_>>();
3223 println!(
3224 " {} {}",
3225 "Installed tools:".bright_white(),
3226 if installed_tool_labels.is_empty() {
3227 "0".to_string()
3228 } else {
3229 format!(
3230 "{} ({})",
3231 installed_tool_labels.len(),
3232 format_sample_list(&installed_tool_labels, 8)
3233 )
3234 }
3235 );
3236
3237 let identity_labels = codetime
3238 .inventory
3239 .git_identities
3240 .iter()
3241 .map(
3242 |identity| match (&identity.user_name, &identity.user_email) {
3243 (Some(name), Some(email)) => format!("{} <{}>", name, email),
3244 (Some(name), None) => name.clone(),
3245 (None, Some(email)) => email.clone(),
3246 (None, None) => identity.scope.clone(),
3247 },
3248 )
3249 .collect::<Vec<_>>();
3250 if !identity_labels.is_empty() {
3251 println!(
3252 " {} {}",
3253 "Git identities:".bright_white(),
3254 format_sample_list(&identity_labels, 4)
3255 );
3256 }
3257
3258 let repo_names = codetime
3259 .inventory
3260 .github_repos
3261 .iter()
3262 .map(|repo| repo.full_name.clone())
3263 .collect::<Vec<_>>();
3264 println!(
3265 " {} {}",
3266 "Seen GitHub repos:".bright_white(),
3267 if repo_names.is_empty() {
3268 "0".to_string()
3269 } else {
3270 format!(
3271 "{} ({})",
3272 repo_names.len(),
3273 format_sample_list(&repo_names, 5)
3274 )
3275 }
3276 );
3277
3278 let project_names = codetime
3279 .inventory
3280 .xbp_projects
3281 .iter()
3282 .map(|project| project.name.clone())
3283 .collect::<Vec<_>>();
3284 println!(
3285 " {} {}",
3286 "Seen XBP projects:".bright_white(),
3287 if project_names.is_empty() {
3288 "0".to_string()
3289 } else {
3290 format!(
3291 "{} ({})",
3292 project_names.len(),
3293 format_sample_list(&project_names, 5)
3294 )
3295 }
3296 );
3297
3298 if let Some(cursor) = &codetime.inventory.cursor {
3299 println!(
3300 " {} {}",
3301 "Cursor present:".bright_white(),
3302 diag_bool(cursor.exists)
3303 );
3304 println!(
3305 " {} workspaceStorage={} extensions={} logs={}",
3306 "Cursor details:".bright_white(),
3307 cursor.workspace_storage_entries,
3308 cursor.extensions_entries,
3309 diag_bool(cursor.logs_exists)
3310 );
3311 if let Some(note) = &cursor.note {
3312 println!(" {} {}", "Cursor note:".bright_white(), note);
3313 }
3314 }
3315 }
3316
3317 if !report.clipboard_tools.is_empty() {
3318 println!("\n{}", "Clipboard tools".bright_yellow().bold());
3319 let mut items: Vec<_> = report.clipboard_tools.iter().collect();
3320 items.sort_by_key(|(k, _)| *k);
3321 for (name, value) in items {
3322 println!(" {:10} {}", name, diag_bool(*value));
3323 }
3324 }
3325
3326 if !report.installed_programs.is_empty() {
3327 print_section_header(
3328 "Installed programs",
3329 SectionState::Info,
3330 &format!("{} program(s)", report.installed_programs.len()),
3331 );
3332
3333 let mut programs: Vec<_> = report.installed_programs.iter().collect();
3334 programs.sort_by_key(|(name, _)| *name);
3335 for (program, installed) in programs {
3336 let status_icon = if *installed { "OK".green() } else { "NO".red() };
3337 let status_text = if *installed {
3338 "Installed".green()
3339 } else {
3340 "Missing".red()
3341 };
3342 println!(" {} {:15} {}", status_icon, program, status_text);
3343 }
3344 }
3345
3346 if !report.tool_versions.is_empty() {
3347 print_section_header(
3348 "Tool versions",
3349 SectionState::Info,
3350 &format!("{} tool(s)", report.tool_versions.len()),
3351 );
3352 let mut items: Vec<_> = report.tool_versions.iter().collect();
3353 items.sort_by_key(|(k, _)| *k);
3354 for (name, v) in items {
3355 if v.present {
3356 println!(
3357 " {:12} {}",
3358 name,
3359 v.version
3360 .clone()
3361 .unwrap_or_else(|| "present".to_string())
3362 .green()
3363 );
3364 } else {
3365 println!(" {:12} {}", name, "not found".red());
3366 }
3367 }
3368 }
3369}
3370fn format_sample_list(items: &[String], limit: usize) -> String {
3371 if items.is_empty() {
3372 return String::new();
3373 }
3374
3375 let shown = items.iter().take(limit).cloned().collect::<Vec<_>>();
3376 let remaining = items.len().saturating_sub(shown.len());
3377 if remaining == 0 {
3378 shown.join(", ")
3379 } else {
3380 format!("{} +{} more", shown.join(", "), remaining)
3381 }
3382}
3383
3384fn format_eta(duration: Duration) -> String {
3385 let total_seconds = duration.as_secs();
3386 let minutes = total_seconds / 60;
3387 let seconds = total_seconds % 60;
3388 if minutes > 0 {
3389 format!("{}m {:02}s", minutes, seconds)
3390 } else {
3391 format!("{}s", seconds)
3392 }
3393}
3394
3395fn estimate_diag_timeout_budget(
3396 ports_count: usize,
3397 skip_speed_test: bool,
3398 include_codetime: bool,
3399) -> Duration {
3400 let fixed_seconds =
3402 2 + 2 + 2 + 2 + 3 + 3 + 10 + 4 + PM2_DIAG_TIMEOUT.as_secs() + NGINX_CHECK_TIMEOUT.as_secs();
3403 let port_seconds =
3404 PORT_OWNERSHIP_TIMEOUT.as_secs() + (ports_count as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
3405 let speed_seconds = if skip_speed_test {
3406 0
3407 } else {
3408 INTERNET_SPEED_TIMEOUT.as_secs()
3409 };
3410 let codetime_seconds = if include_codetime {
3411 SYSTEM_INVENTORY_TIMEOUT.as_secs()
3412 } else {
3413 0
3414 };
3415 Duration::from_secs(fixed_seconds + port_seconds + speed_seconds + codetime_seconds)
3416}
3417
3418fn set_step_message(
3419 pb: &ProgressBar,
3420 step_index: usize,
3421 step_total: usize,
3422 started_at: Instant,
3423 estimated_budget: Duration,
3424 label: &str,
3425 timeout: Option<Duration>,
3426) {
3427 let elapsed = started_at.elapsed();
3428 let remaining = estimated_budget.saturating_sub(elapsed);
3429 let timeout_suffix = timeout
3430 .map(|value| format!(" | timeout {}s", value.as_secs()))
3431 .unwrap_or_default();
3432 pb.set_message(format!(
3433 "[{}/{}] {} | ETA {}{}",
3434 step_index,
3435 step_total,
3436 label,
3437 format_eta(remaining),
3438 timeout_suffix
3439 ));
3440}
3441
3442async fn collect_port_ownership_with_timeout() -> BTreeMap<u16, ListeningPortOwnership> {
3443 let task = tokio::task::spawn_blocking(collect_listening_port_ownership);
3444 match tokio::time::timeout(PORT_OWNERSHIP_TIMEOUT, task).await {
3445 Ok(Ok(Ok(ports))) => ports,
3446 Ok(Ok(Err(err))) => {
3447 let _ = log_warn("diag", "Port ownership scan failed", Some(err.as_str())).await;
3448 BTreeMap::new()
3449 }
3450 Ok(Err(err)) => {
3451 let message = format!("Port ownership scan task failed: {}", err);
3452 let _ = log_warn("diag", "Port ownership scan failed", Some(message.as_str())).await;
3453 BTreeMap::new()
3454 }
3455 Err(_) => {
3456 let timeout_note = format!(
3457 "Port ownership scan timed out after {} seconds",
3458 PORT_OWNERSHIP_TIMEOUT.as_secs()
3459 );
3460 let _ = log_warn(
3461 "diag",
3462 "Port ownership scan timed out",
3463 Some(timeout_note.as_str()),
3464 )
3465 .await;
3466 BTreeMap::new()
3467 }
3468 }
3469}
3470
3471async fn sync_system_inventory_with_timeout(include_cursor: bool) -> Option<CodeTimeDiagnostic> {
3472 let current_dir = env::current_dir().ok();
3473 let task = tokio::task::spawn_blocking(move || {
3474 sync_system_inventory(true, include_cursor, current_dir.as_deref())
3475 });
3476
3477 match tokio::time::timeout(SYSTEM_INVENTORY_TIMEOUT, task).await {
3478 Ok(Ok(Ok(result))) => Some(CodeTimeDiagnostic {
3479 saved_config_path: result.config_path.display().to_string(),
3480 refreshed: result.refreshed,
3481 inventory: result.inventory,
3482 }),
3483 Ok(Ok(Err(err))) => {
3484 let _ = log_warn("diag", "System inventory sync failed", Some(err.as_str())).await;
3485 None
3486 }
3487 Ok(Err(err)) => {
3488 let message = format!("System inventory task failed: {}", err);
3489 let _ = log_warn(
3490 "diag",
3491 "System inventory sync failed",
3492 Some(message.as_str()),
3493 )
3494 .await;
3495 None
3496 }
3497 Err(_) => {
3498 let message = format!(
3499 "System inventory sync timed out after {} seconds",
3500 SYSTEM_INVENTORY_TIMEOUT.as_secs()
3501 );
3502 let _ = log_warn(
3503 "diag",
3504 "System inventory sync timed out",
3505 Some(message.as_str()),
3506 )
3507 .await;
3508 None
3509 }
3510 }
3511}
3512
3513pub async fn run_full_diagnostics(
3514 ports: Vec<u16>,
3515 options: DiagnosticRunOptions,
3516) -> Result<DiagnosticReport> {
3517 let _ = log_info("diag", "Running system diagnostics...", None).await;
3518
3519 let skip_speed = options.skip_speed_test || options.quick;
3520 let do_http = (options.http_probe || options.full) && !options.quick;
3521 let do_deep = options.full && !options.quick;
3522 let estimated_budget =
3523 estimate_diag_timeout_budget(ports.len(), skip_speed, options.codetime);
3524 let timeout_summary = format!(
3525 "Estimated max runtime {} (PM2 {}s, ports {}s + {}s/port{}{})",
3526 format_eta(estimated_budget),
3527 PM2_DIAG_TIMEOUT.as_secs(),
3528 PORT_OWNERSHIP_TIMEOUT.as_secs(),
3529 PER_PORT_CHECK_TIMEOUT.as_secs(),
3530 if skip_speed {
3531 ", speed test skipped"
3532 } else {
3533 ", speed test up to 20s"
3534 },
3535 if options.codetime {
3536 ", codetime inventory up to 25s"
3537 } else {
3538 ""
3539 }
3540 );
3541 let _ = log_info(
3542 "diag",
3543 "Diagnostics timeout budget",
3544 Some(timeout_summary.as_str()),
3545 )
3546 .await;
3547
3548 let pb: ProgressBar = ProgressBar::new_spinner();
3549 pb.enable_steady_tick(Duration::from_millis(80));
3550 pb.set_style(
3551 ProgressStyle::with_template("{spinner} {msg}")
3552 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
3553 );
3554 let started_at = Instant::now();
3555 let mut step_total = if options.codetime { 14 } else { 13 };
3556 if do_deep {
3557 step_total += 4;
3558 } else if do_http {
3559 step_total += 1;
3560 }
3561 let mut step_index = 1usize;
3562
3563 set_step_message(
3564 &pb,
3565 step_index,
3566 step_total,
3567 started_at,
3568 estimated_budget,
3569 "Collecting system metrics",
3570 None,
3571 );
3572 step_index += 1;
3573 let system_metrics = get_system_metrics().await?;
3574
3575 set_step_message(
3576 &pb,
3577 step_index,
3578 step_total,
3579 started_at,
3580 estimated_budget,
3581 "Collecting OS/CPU/GPU",
3582 None,
3583 );
3584 step_index += 1;
3585 let os = get_os_info().await;
3586 let cpu = get_cpu_info().await;
3587 let gpu_candidates = get_gpu_candidates().await;
3588
3589 set_step_message(
3590 &pb,
3591 step_index,
3592 step_total,
3593 started_at,
3594 estimated_budget,
3595 "Collecting disks",
3596 None,
3597 );
3598 step_index += 1;
3599 let disks = get_disk_infos().await;
3600
3601 let codetime = if options.codetime {
3602 set_step_message(
3603 &pb,
3604 step_index,
3605 step_total,
3606 started_at,
3607 estimated_budget,
3608 if options.cursor {
3609 "Refreshing CodeTime + Cursor inventory"
3610 } else {
3611 "Refreshing CodeTime inventory"
3612 },
3613 Some(SYSTEM_INVENTORY_TIMEOUT),
3614 );
3615 step_index += 1;
3616 sync_system_inventory_with_timeout(options.cursor).await
3617 } else {
3618 None
3619 };
3620
3621 set_step_message(
3622 &pb,
3623 step_index,
3624 step_total,
3625 started_at,
3626 estimated_budget,
3627 "Collecting shell + clipboard",
3628 None,
3629 );
3630 step_index += 1;
3631 let shell = get_shell_info();
3632 let clipboard_tools = get_clipboard_tools().await;
3633
3634 set_step_message(
3635 &pb,
3636 step_index,
3637 step_total,
3638 started_at,
3639 estimated_budget,
3640 "Detecting proxies",
3641 None,
3642 );
3643 step_index += 1;
3644 let proxy_detection = get_proxy_detection().await;
3645
3646 set_step_message(
3647 &pb,
3648 step_index,
3649 step_total,
3650 started_at,
3651 estimated_budget,
3652 "Checking network/public IP",
3653 None,
3654 );
3655 step_index += 1;
3656 let connectivity = check_internet_connectivity().await.unwrap_or(false);
3657 let exposure = get_exposure_info(connectivity).await;
3658
3659 set_step_message(
3660 &pb,
3661 step_index,
3662 step_total,
3663 started_at,
3664 estimated_budget,
3665 "Checking tools",
3666 Some(TOOL_VERSION_TIMEOUT),
3667 );
3668 step_index += 1;
3669 let installed_programs = check_installed_programs().await;
3670 let tool_versions = get_tool_versions().await;
3671
3672 set_step_message(
3673 &pb,
3674 step_index,
3675 step_total,
3676 started_at,
3677 estimated_budget,
3678 "Checking file permissions",
3679 None,
3680 );
3681 step_index += 1;
3682 let path_permission_checks = get_path_permission_checks().await;
3683
3684 set_step_message(
3685 &pb,
3686 step_index,
3687 step_total,
3688 started_at,
3689 estimated_budget,
3690 "Checking services",
3691 None,
3692 );
3693 step_index += 1;
3694 let service_statuses = get_service_statuses().await;
3695
3696 set_step_message(
3697 &pb,
3698 step_index,
3699 step_total,
3700 started_at,
3701 estimated_budget,
3702 "Checking PM2 process table",
3703 Some(PM2_DIAG_TIMEOUT),
3704 );
3705 step_index += 1;
3706 let pm2_process_count = get_pm2_process_count().await;
3707
3708 let ports_timeout_budget = PORT_OWNERSHIP_TIMEOUT
3709 + Duration::from_secs(ports.len() as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
3710 set_step_message(
3711 &pb,
3712 step_index,
3713 step_total,
3714 started_at,
3715 estimated_budget,
3716 "Checking ports",
3717 Some(ports_timeout_budget),
3718 );
3719 step_index += 1;
3720 let port_ownership = collect_port_ownership_with_timeout().await;
3721
3722 let mut port_checks = Vec::new();
3723 for port in ports {
3724 let timed_check = tokio::time::timeout(
3725 PER_PORT_CHECK_TIMEOUT,
3726 check_port_availability(port, port_ownership.get(&port)),
3727 )
3728 .await;
3729 match timed_check {
3730 Ok(Ok(check)) => port_checks.push(check),
3731 Ok(Err(err)) => {
3732 let details = format!("Port {} check failed: {}", port, err);
3733 let _ = log_warn("diag", "Port check failed", Some(details.as_str())).await;
3734 }
3735 Err(_) => {
3736 let details = format!(
3737 "Port {} check timed out after {} seconds",
3738 port,
3739 PER_PORT_CHECK_TIMEOUT.as_secs()
3740 );
3741 let _ = log_warn("diag", "Port check timed out", Some(details.as_str())).await;
3742 }
3743 }
3744 }
3745
3746 set_step_message(
3747 &pb,
3748 step_index,
3749 step_total,
3750 started_at,
3751 estimated_budget,
3752 "Measuring internet speed",
3753 Some(INTERNET_SPEED_TIMEOUT),
3754 );
3755 step_index += 1;
3756 let internet_speed = if skip_speed {
3757 let _ = log_info(
3758 "diag",
3759 "Skipping internet speed test (--no-speed-test or --quick)",
3760 None,
3761 )
3762 .await;
3763 None
3764 } else if connectivity {
3765 match tokio::time::timeout(INTERNET_SPEED_TIMEOUT, measure_internet_speed()).await {
3766 Ok(Ok(speed)) => Some(speed),
3767 Ok(Err(err)) => {
3768 let details = format!("Internet speed probe failed: {}", err);
3769 let _ = log_warn(
3770 "diag",
3771 "Internet speed probe failed",
3772 Some(details.as_str()),
3773 )
3774 .await;
3775 None
3776 }
3777 Err(_) => {
3778 let details = format!(
3779 "Internet speed probe timed out after {} seconds",
3780 INTERNET_SPEED_TIMEOUT.as_secs()
3781 );
3782 let _ = log_warn(
3783 "diag",
3784 "Internet speed probe timed out",
3785 Some(details.as_str()),
3786 )
3787 .await;
3788 None
3789 }
3790 }
3791 } else {
3792 None
3793 };
3794
3795 set_step_message(
3796 &pb,
3797 step_index,
3798 step_total,
3799 started_at,
3800 estimated_budget,
3801 "Checking nginx",
3802 Some(NGINX_CHECK_TIMEOUT),
3803 );
3804 let nginx_status = match tokio::time::timeout(NGINX_CHECK_TIMEOUT, check_nginx_status()).await {
3805 Ok(Ok(status)) => Some(status),
3806 Ok(Err(err)) => {
3807 let details = format!("Nginx status check failed: {}", err);
3808 let _ = log_warn("diag", "Nginx status check failed", Some(details.as_str())).await;
3809 None
3810 }
3811 Err(_) => {
3812 let details = format!(
3813 "Nginx status check timed out after {} seconds",
3814 NGINX_CHECK_TIMEOUT.as_secs()
3815 );
3816 let _ = log_warn(
3817 "diag",
3818 "Nginx status check timed out",
3819 Some(details.as_str()),
3820 )
3821 .await;
3822 None
3823 }
3824 };
3825
3826 let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3827 let config = load_xbp_config().await.ok();
3828 let project_config_present = config.is_some();
3829 let service_rows: Vec<(String, u16, Option<String>)> = config
3830 .as_ref()
3831 .map(|cfg| {
3832 get_all_services(cfg)
3833 .into_iter()
3834 .map(|service| (service.name.clone(), service.port, service.url.clone()))
3835 .collect()
3836 })
3837 .unwrap_or_default();
3838 let configured_services = service_rows
3839 .iter()
3840 .map(|(name, port, _)| format!("{name}:{port}"))
3841 .collect::<Vec<_>>();
3842 let provider_manifests: Vec<String> = ProjectDetector::detect_provider_manifests(¤t_dir);
3843 let env_files = discover_env_files(¤t_dir);
3844 let hostname = System::host_name();
3845 let local_ips = collect_local_ips().await;
3846 let network_interfaces = collect_network_interfaces();
3847
3848 let dns_checks = if options.quick {
3849 Vec::new()
3850 } else {
3851 set_step_message(
3852 &pb,
3853 step_index,
3854 step_total,
3855 started_at,
3856 estimated_budget,
3857 "Resolving DNS",
3858 None,
3859 );
3860 step_index += 1;
3861 let mut hosts = vec!["github.com", "crates.io", "registry.npmjs.org", "1.1.1.1"];
3862 if do_deep {
3863 hosts.extend(["docs.xbp.app", "api.xbp.app", "xbp.app"]);
3864 }
3865 collect_dns_checks(&hosts).await
3866 };
3867
3868 let http_probes = if do_http && !service_rows.is_empty() {
3869 set_step_message(
3870 &pb,
3871 step_index,
3872 step_total,
3873 started_at,
3874 estimated_budget,
3875 "Probing service HTTP endpoints",
3876 None,
3877 );
3878 step_index += 1;
3879 collect_http_probes(&service_rows).await
3880 } else {
3881 Vec::new()
3882 };
3883
3884 let git = if do_deep || !options.quick {
3885 set_step_message(
3886 &pb,
3887 step_index,
3888 step_total,
3889 started_at,
3890 estimated_budget,
3891 "Checking git hygiene",
3892 None,
3893 );
3894 step_index += 1;
3895 collect_git_hygiene(¤t_dir).await
3896 } else {
3897 None
3898 };
3899
3900 let cli_auth = if do_deep {
3901 set_step_message(
3902 &pb,
3903 step_index,
3904 step_total,
3905 started_at,
3906 estimated_budget,
3907 "Checking CLI session",
3908 None,
3909 );
3910 step_index += 1;
3911 Some(collect_cli_auth_diagnostic().await)
3912 } else {
3913 None
3914 };
3915
3916 let docker = if do_deep || cfg!(feature = "docker") {
3917 set_step_message(
3918 &pb,
3919 step_index,
3920 step_total,
3921 started_at,
3922 estimated_budget,
3923 "Checking Docker",
3924 None,
3925 );
3926 let _ = step_index;
3927 Some(collect_docker_diagnostic().await)
3928 } else {
3929 None
3930 };
3931
3932 let mut feature_flags: HashMap<String, bool> = HashMap::new();
3933 feature_flags.insert("monitoring".to_string(), cfg!(feature = "monitoring"));
3934 feature_flags.insert(
3935 "kafka".to_string(),
3936 cfg!(all(feature = "kafka", not(windows))),
3937 );
3938 feature_flags.insert("kubernetes".to_string(), cfg!(feature = "kubernetes"));
3939 feature_flags.insert("docker".to_string(), cfg!(feature = "docker"));
3940 feature_flags.insert("linear".to_string(), cfg!(feature = "linear"));
3941 feature_flags.insert("secrets".to_string(), cfg!(feature = "secrets"));
3942 feature_flags.insert("athena".to_string(), cfg!(feature = "athena"));
3943 feature_flags.insert("nordvpn".to_string(), cfg!(feature = "nordvpn"));
3944 feature_flags.insert("openapi-gen".to_string(), cfg!(feature = "openapi-gen"));
3945
3946 let xbp_cli_version = env!("CARGO_PKG_VERSION").to_string();
3947 let worktree_watch = Some(collect_worktree_watch_diagnostic());
3948
3949 pb.finish_and_clear();
3950
3951 let mut report = DiagnosticReport {
3952 system_metrics,
3953 os,
3954 cpu,
3955 gpu_candidates,
3956 disks,
3957 shell,
3958 proxy_detection,
3959 clipboard_tools,
3960 exposure,
3961 pm2_process_count,
3962 tool_versions,
3963 service_statuses,
3964 feature_flags,
3965 xbp_cli_version,
3966 hostname,
3967 cwd: current_dir.display().to_string(),
3968 local_ips,
3969 network_interfaces,
3970 project_config_present,
3971 configured_services,
3972 provider_manifests,
3973 env_files,
3974 nginx_status,
3975 port_checks,
3976 internet_speed,
3977 connectivity,
3978 dns_checks,
3979 http_probes,
3980 git,
3981 cli_auth,
3982 docker,
3983 installed_programs,
3984 path_permission_checks,
3985 codetime,
3986 worktree_watch,
3987 recommendations: Vec::new(),
3988 scorecard: DiagScorecard {
3989 healthy: 0,
3990 warnings: 0,
3991 failures: 0,
3992 grade: "OK".into(),
3993 },
3994 };
3995 report.recommendations = build_recommendations(&report);
3996 report.scorecard = build_scorecard(&report);
3997 Ok(report)
3998}
3999
4000pub fn print_diagnostic_report_json(report: &DiagnosticReport) -> Result<()> {
4002 let json = serde_json::to_string_pretty(report)?;
4003 println!("{json}");
4004 Ok(())
4005}
4006
4007#[cfg(test)]
4008mod expanded_diag_tests {
4009 use super::*;
4010
4011 fn minimal_report() -> DiagnosticReport {
4012 DiagnosticReport {
4013 system_metrics: SystemMetrics {
4014 cpu_usage: 10.0,
4015 memory_total: 1000,
4016 memory_used: 100,
4017 memory_percent: 10.0,
4018 disk_total: 1000,
4019 disk_used: 100,
4020 disk_percent: 10.0,
4021 network_rx: 0,
4022 network_tx: 0,
4023 uptime: 100,
4024 process_count: 1,
4025 },
4026 os: None,
4027 cpu: None,
4028 gpu_candidates: Vec::new(),
4029 disks: Vec::new(),
4030 shell: None,
4031 proxy_detection: None,
4032 clipboard_tools: HashMap::new(),
4033 exposure: None,
4034 pm2_process_count: Some(0),
4035 tool_versions: HashMap::new(),
4036 service_statuses: HashMap::new(),
4037 feature_flags: HashMap::new(),
4038 xbp_cli_version: "0.0.0".into(),
4039 hostname: Some("test".into()),
4040 cwd: "/tmp".into(),
4041 local_ips: vec!["127.0.0.1".into()],
4042 network_interfaces: Vec::new(),
4043 project_config_present: true,
4044 configured_services: Vec::new(),
4045 provider_manifests: Vec::new(),
4046 env_files: Vec::new(),
4047 nginx_status: None,
4048 port_checks: Vec::new(),
4049 internet_speed: None,
4050 connectivity: true,
4051 dns_checks: Vec::new(),
4052 http_probes: Vec::new(),
4053 git: None,
4054 cli_auth: None,
4055 docker: None,
4056 installed_programs: HashMap::new(),
4057 path_permission_checks: Vec::new(),
4058 codetime: None,
4059 worktree_watch: None,
4060 recommendations: Vec::new(),
4061 scorecard: DiagScorecard {
4062 healthy: 0,
4063 warnings: 0,
4064 failures: 0,
4065 grade: "OK".into(),
4066 },
4067 }
4068 }
4069
4070 #[test]
4071 fn scorecard_is_ok_when_healthy() {
4072 let report = minimal_report();
4073 let card = build_scorecard(&report);
4074 assert_eq!(card.grade, "OK");
4075 assert!(card.failures == 0);
4076 }
4077
4078 #[test]
4079 fn scorecard_fails_when_offline() {
4080 let mut report = minimal_report();
4081 report.connectivity = false;
4082 let card = build_scorecard(&report);
4083 assert_eq!(card.grade, "FAIL");
4084 assert!(card.failures > 0);
4085 }
4086
4087 #[test]
4088 fn recommendations_suggest_login_when_signed_out() {
4089 let mut report = minimal_report();
4090 report.cli_auth = Some(CliAuthDiagnostic {
4091 logged_in: false,
4092 user_email: None,
4093 user_name: None,
4094 token_prefix: None,
4095 message: "not logged in".into(),
4096 });
4097 let recs = build_recommendations(&report);
4098 assert!(recs.iter().any(|r| r.contains("xbp login")));
4099 }
4100
4101 #[test]
4102 fn recommendations_empty_critical_path_gives_ready_message() {
4103 let mut report = minimal_report();
4104 report.installed_programs.insert("git".into(), true);
4106 let recs = build_recommendations(&report);
4107 assert!(
4108 recs.iter().any(|r| r.contains("ready") || r.contains("No critical")),
4109 "got {recs:?}"
4110 );
4111 }
4112
4113 #[test]
4114 fn scorecard_warns_without_project_config() {
4115 let mut report = minimal_report();
4116 report.project_config_present = false;
4117 let card = build_scorecard(&report);
4118 assert!(card.warnings > 0 || card.grade == "WARN" || card.grade == "OK");
4119 assert_ne!(card.grade, "FAIL");
4121 }
4122
4123 #[test]
4124 fn scorecard_fails_on_blocked_ports() {
4125 let mut report = minimal_report();
4126 report.port_checks.push(PortCheck {
4127 port: 80,
4128 is_open: false,
4129 is_blocked: true,
4130 pids: Vec::new(),
4131 xbp_projects: Vec::new(),
4132 });
4133 let card = build_scorecard(&report);
4134 assert_eq!(card.grade, "FAIL");
4135 }
4136
4137 #[test]
4138 fn scorecard_warns_on_high_disk() {
4139 let mut report = minimal_report();
4140 report.system_metrics.disk_percent = 85.0;
4141 let card = build_scorecard(&report);
4142 assert!(card.warnings > 0 || card.grade == "WARN");
4143 }
4144
4145 #[test]
4146 fn scorecard_fails_on_critical_disk() {
4147 let mut report = minimal_report();
4148 report.system_metrics.disk_percent = 95.0;
4149 let card = build_scorecard(&report);
4150 assert_eq!(card.grade, "FAIL");
4151 }
4152
4153 #[test]
4154 fn scorecard_http_partial_is_warn() {
4155 let mut report = minimal_report();
4156 report.http_probes = vec![
4157 HttpProbe {
4158 name: "a".into(),
4159 url: "http://a".into(),
4160 ok: true,
4161 status: Some(200),
4162 latency_ms: Some(1),
4163 error: None,
4164 },
4165 HttpProbe {
4166 name: "b".into(),
4167 url: "http://b".into(),
4168 ok: false,
4169 status: None,
4170 latency_ms: None,
4171 error: Some("down".into()),
4172 },
4173 ];
4174 let card = build_scorecard(&report);
4175 assert!(matches!(card.grade.as_str(), "WARN" | "FAIL" | "OK"));
4176 assert!(card.warnings + card.failures >= 1 || card.healthy >= 1);
4178 }
4179
4180 #[test]
4181 fn recommendations_disk_memory_and_docker() {
4182 let mut report = minimal_report();
4183 report.installed_programs.insert("git".into(), true);
4184 report.system_metrics.disk_percent = 90.0;
4185 report.system_metrics.memory_percent = 95.0;
4186 report.docker = Some(DockerDiagnostic {
4187 cli_present: true,
4188 daemon_ok: false,
4189 version: Some("24".into()),
4190 compose_present: true,
4191 note: Some("down".into()),
4192 });
4193 report.disks.push(DiskInfo {
4194 mount: "/".into(),
4195 fs: Some("ext4".into()),
4196 total: 100,
4197 used: 90,
4198 percent: 90.0,
4199 bar: String::new(),
4200 is_current: true,
4201 });
4202 let recs = build_recommendations(&report);
4203 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("disk")));
4204 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("memory")));
4205 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("docker")));
4206 }
4207
4208 #[test]
4209 fn recommendations_blocked_ports_and_http_and_dns() {
4210 let mut report = minimal_report();
4211 report.installed_programs.insert("git".into(), true);
4212 report.port_checks.push(PortCheck {
4213 port: 443,
4214 is_open: false,
4215 is_blocked: true,
4216 pids: Vec::new(),
4217 xbp_projects: Vec::new(),
4218 });
4219 report.http_probes.push(HttpProbe {
4220 name: "api".into(),
4221 url: "http://127.0.0.1:1/".into(),
4222 ok: false,
4223 status: None,
4224 latency_ms: None,
4225 error: Some("nope".into()),
4226 });
4227 report.dns_checks.push(DnsCheck {
4228 host: "example.invalid".into(),
4229 ok: false,
4230 addresses: Vec::new(),
4231 error: Some("nxdomain".into()),
4232 });
4233 let recs = build_recommendations(&report);
4234 assert!(recs.iter().any(|r| r.contains("firewall") || r.contains("port")));
4235 assert!(recs.iter().any(|r| r.contains("HTTP") || r.contains("api")));
4236 assert!(recs.iter().any(|r| r.contains("DNS") || r.contains("example.invalid")));
4237 }
4238
4239 #[test]
4240 fn recommendations_nginx_invalid_and_dirty_git() {
4241 let mut report = minimal_report();
4242 report.installed_programs.insert("git".into(), true);
4243 report.nginx_status = Some(NginxStatus {
4244 is_running: true,
4245 is_enabled: true,
4246 config_valid: false,
4247 error: None,
4248 });
4249 report.git = Some(GitHygiene {
4250 is_repo: true,
4251 branch: Some("main".into()),
4252 dirty: Some(true),
4253 ahead: None,
4254 behind: None,
4255 remote: None,
4256 notes: vec!["dirty".into()],
4257 credential_helper: None,
4258 });
4259 let recs = build_recommendations(&report);
4260 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("nginx")));
4261 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("git")));
4262 }
4263
4264 #[test]
4265 fn recommendations_missing_git() {
4266 let mut report = minimal_report();
4267 report.installed_programs.insert("git".into(), false);
4268 let recs = build_recommendations(&report);
4269 assert!(recs.iter().any(|r| r.contains("git")));
4270 }
4271
4272 #[test]
4273 fn credential_helper_executable_parses_bang_and_plain() {
4274 assert_eq!(
4275 credential_helper_executable("!/usr/bin/gh auth git-credential").as_deref(),
4276 Some("/usr/bin/gh")
4277 );
4278 assert_eq!(
4279 credential_helper_executable("/snap/gh/751/gh auth git-credential").as_deref(),
4280 Some("/snap/gh/751/gh")
4281 );
4282 assert_eq!(credential_helper_executable("store"), None);
4283 assert_eq!(credential_helper_executable("manager-core"), None);
4284 }
4285
4286 #[test]
4287 fn is_stale_gh_helper_detects_missing_snap_path() {
4288 let helper = "/snap/gh/751/gh auth git-credential";
4290 assert!(is_stale_gh_credential_helper(helper));
4291 assert!(is_stale_gh_credential_helper(&format!("!{helper}")));
4292 assert!(!is_stale_gh_credential_helper("store"));
4293 assert!(!is_stale_gh_credential_helper("cache --timeout=3600"));
4294 }
4295
4296 #[test]
4297 fn recommendations_stale_snap_gh_credential_helper() {
4298 let mut report = minimal_report();
4299 report.installed_programs.insert("git".into(), true);
4300 report.git = Some(GitHygiene {
4301 is_repo: true,
4302 branch: Some("main".into()),
4303 dirty: Some(false),
4304 ahead: None,
4305 behind: None,
4306 remote: None,
4307 notes: vec![],
4308 credential_helper: Some(GitCredentialHelperDiag {
4309 helpers: vec!["/snap/gh/751/gh auth git-credential".into()],
4310 stale_helpers: vec!["/snap/gh/751/gh auth git-credential".into()],
4311 gh_path: None,
4312 fix_attempted: false,
4313 fixed: false,
4314 message: Some("stale".into()),
4315 }),
4316 });
4317 let recs = build_recommendations(&report);
4318 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("snap")
4319 || r.to_ascii_lowercase().contains("credential.helper")));
4320 let card = build_scorecard(&report);
4321 assert_eq!(card.grade, "FAIL");
4322 }
4323
4324 #[test]
4325 fn recommendations_fixed_snap_gh_credential_helper() {
4326 let mut report = minimal_report();
4327 report.installed_programs.insert("git".into(), true);
4328 report.git = Some(GitHygiene {
4329 is_repo: true,
4330 branch: Some("main".into()),
4331 dirty: Some(false),
4332 ahead: None,
4333 behind: None,
4334 remote: None,
4335 notes: vec![],
4336 credential_helper: Some(GitCredentialHelperDiag {
4337 helpers: vec!["!/usr/bin/gh auth git-credential".into()],
4338 stale_helpers: vec![],
4339 gh_path: Some("/usr/bin/gh".into()),
4340 fix_attempted: true,
4341 fixed: true,
4342 message: Some("Repaired".into()),
4343 }),
4344 });
4345 let recs = build_recommendations(&report);
4346 assert!(recs.iter().any(|r| r.to_ascii_lowercase().contains("repaired")));
4347 }
4348
4349 #[test]
4350 fn summarize_port_checks_counts() {
4351 let mut report = minimal_report();
4352 report.port_checks = vec![
4353 PortCheck {
4354 port: 1,
4355 is_open: true,
4356 is_blocked: false,
4357 pids: Vec::new(),
4358 xbp_projects: Vec::new(),
4359 },
4360 PortCheck {
4361 port: 2,
4362 is_open: false,
4363 is_blocked: true,
4364 pids: Vec::new(),
4365 xbp_projects: Vec::new(),
4366 },
4367 PortCheck {
4368 port: 3,
4369 is_open: true,
4370 is_blocked: false,
4371 pids: Vec::new(),
4372 xbp_projects: Vec::new(),
4373 },
4374 ];
4375 let (open, blocked, total) = super::summarize_port_checks(&report);
4376 assert_eq!(open, 2);
4377 assert_eq!(blocked, 1);
4378 assert_eq!(total, 3);
4379 }
4380
4381 #[test]
4382 fn summarize_path_permissions_counts_pass_fail() {
4383 let mut report = minimal_report();
4384 report.path_permission_checks = vec![
4385 PathPermissionCheck {
4386 path: "/a".into(),
4387 purpose: "x".into(),
4388 required: "read".into(),
4389 exists: true,
4390 readable: true,
4391 writable: true,
4392 creatable: true,
4393 ok: true,
4394 message: None,
4395 },
4396 PathPermissionCheck {
4397 path: "/b".into(),
4398 purpose: "y".into(),
4399 required: "write".into(),
4400 exists: false,
4401 readable: false,
4402 writable: false,
4403 creatable: false,
4404 ok: false,
4405 message: Some("no".into()),
4406 },
4407 ];
4408 let (pass, total) = super::summarize_path_permissions(&report);
4409 assert_eq!(pass, 1);
4410 assert_eq!(total, 2);
4411 }
4412
4413 #[test]
4414 fn discover_env_files_finds_dotenv() {
4415 let dir = std::env::temp_dir().join(format!(
4416 "xbp-diag-env-{}",
4417 std::time::SystemTime::now()
4418 .duration_since(std::time::UNIX_EPOCH)
4419 .map(|d| d.as_nanos())
4420 .unwrap_or(0)
4421 ));
4422 fs::create_dir_all(&dir).unwrap();
4423 fs::write(dir.join(".env"), "A=1\n").unwrap();
4424 fs::write(dir.join(".env.local"), "B=2\n").unwrap();
4425 let found = discover_env_files(&dir);
4426 assert!(found.iter().any(|f| f == ".env"));
4427 assert!(found.iter().any(|f| f == ".env.local"));
4428 let _ = fs::remove_dir_all(dir);
4429 }
4430
4431 #[test]
4432 fn diag_bool_and_ratio_helpers() {
4433 assert!(diag_bool(true).to_string().contains("true"));
4434 assert!(diag_bool(false).to_string().contains("false"));
4435 assert!(diag_ratio(2, 5).contains('2'));
4436 assert!(diag_bool_fragment("x", true).contains("x="));
4437 }
4438
4439 #[test]
4440 fn color_service_status_classifies() {
4441 assert!(color_service_status("running").to_string().contains("running"));
4442 assert!(color_service_status("failed").to_string().contains("failed"));
4443 assert!(color_service_status("degraded").to_string().contains("degraded"));
4444 }
4445
4446 #[test]
4447 fn render_feature_flags_sorted() {
4448 let mut flags = HashMap::new();
4449 flags.insert("z".into(), true);
4450 flags.insert("a".into(), false);
4451 let s = render_feature_flags(&flags);
4452 assert!(s.find('a').unwrap() < s.find('z').unwrap());
4453 }
4454
4455 #[test]
4456 fn section_badge_variants() {
4457 for state in [
4458 SectionState::Healthy,
4459 SectionState::Warn,
4460 SectionState::Fail,
4461 SectionState::Info,
4462 ] {
4463 assert!(!section_badge(state).to_string().is_empty());
4464 }
4465 }
4466}