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)]
123pub struct DiagnosticReport {
124 pub system_metrics: SystemMetrics,
125 pub os: Option<OsInfo>,
126 pub cpu: Option<CpuInfo>,
127 pub gpu_candidates: Vec<String>,
128 pub disks: Vec<DiskInfo>,
129 pub shell: Option<ShellInfo>,
130 pub proxy_detection: Option<ProxyDetection>,
131 pub clipboard_tools: HashMap<String, bool>,
132 pub exposure: Option<ExposureInfo>,
133 pub pm2_process_count: Option<usize>,
134 pub tool_versions: HashMap<String, ToolVersion>,
135 pub service_statuses: HashMap<String, String>,
136 pub feature_flags: HashMap<String, bool>,
137 pub xbp_cli_version: String,
138 pub project_config_present: bool,
139 pub configured_services: Vec<String>,
140 pub provider_manifests: Vec<String>,
141 pub nginx_status: Option<NginxStatus>,
142 pub port_checks: Vec<PortCheck>,
143 pub internet_speed: Option<InternetSpeed>,
144 pub connectivity: bool,
145 pub installed_programs: HashMap<String, bool>,
146 pub path_permission_checks: Vec<PathPermissionCheck>,
147 pub codetime: Option<CodeTimeDiagnostic>,
148 pub worktree_watch: Option<WorktreeWatchDiagnostic>,
149}
150
151#[derive(Debug, Serialize, Deserialize)]
152pub struct CodeTimeDiagnostic {
153 pub saved_config_path: String,
154 pub refreshed: bool,
155 pub inventory: SystemInventory,
156}
157
158#[derive(Debug, Serialize, Deserialize)]
159pub struct NginxStatus {
160 pub is_running: bool,
161 pub is_enabled: bool,
162 pub config_valid: bool,
163 pub error: Option<String>,
164}
165
166const PM2_DIAG_TIMEOUT: Duration = Duration::from_secs(12);
167const PORT_OWNERSHIP_TIMEOUT: Duration = Duration::from_secs(20);
168const PER_PORT_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
169const TOOL_VERSION_TIMEOUT: Duration = Duration::from_secs(10);
170const PROGRAM_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
171const COMMAND_CAPTURE_TIMEOUT: Duration = Duration::from_secs(5);
172const INTERNET_SPEED_TIMEOUT: Duration = Duration::from_secs(20);
173const NGINX_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
174const SYSTEM_INVENTORY_TIMEOUT: Duration = Duration::from_secs(25);
175const DOWNLOAD_PROBE_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
176const DOWNLOAD_PROBE_MAX_DURATION: Duration = Duration::from_secs(8);
177const DOWNLOAD_PROBE_TARGET_BYTES: u64 = 8_000_000;
178const DOWNLOAD_PROBE_MIN_BYTES: u64 = 1_000_000;
179const DOWNLOAD_PROBE_URLS: &[&str] = &[
180 "https://speed.cloudflare.com/__down?bytes=20000000",
181 "https://speed.hetzner.de/10MB.bin",
182 "https://proof.ovh.net/files/10Mb.dat",
183];
184
185#[derive(Debug, Clone, Copy, Default)]
186pub struct DiagnosticRunOptions {
187 pub skip_speed_test: bool,
188 pub codetime: bool,
189 pub cursor: bool,
190}
191
192pub async fn get_system_metrics() -> Result<SystemMetrics> {
193 let mut sys = System::new_all();
194 sys.refresh_all();
195
196 let cpu_usage =
197 sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
198 let memory_total = sys.total_memory();
199 let memory_used = sys.used_memory();
200 let memory_percent = (memory_used as f32 / memory_total as f32) * 100.0;
201
202 let mut disk_total = 0;
203 let mut disk_used = 0;
204 for disk in Disks::new_with_refreshed_list().iter() {
205 disk_total += disk.total_space();
206 disk_used += disk.total_space() - disk.available_space();
207 }
208 let disk_percent = if disk_total > 0 {
209 (disk_used as f32 / disk_total as f32) * 100.0
210 } else {
211 0.0
212 };
213
214 let networks = Networks::new_with_refreshed_list();
215 let mut network_rx = 0;
216 let mut network_tx = 0;
217 for (_interface_name, network) in &networks {
218 network_rx += network.total_received();
219 network_tx += network.total_transmitted();
220 }
221
222 let uptime = System::uptime();
223 let process_count = sys.processes().len();
224
225 Ok(SystemMetrics {
226 cpu_usage,
227 memory_total,
228 memory_used,
229 memory_percent,
230 disk_total,
231 disk_used,
232 disk_percent,
233 network_rx,
234 network_tx,
235 uptime,
236 process_count,
237 })
238}
239
240pub async fn check_nginx_status() -> Result<NginxStatus> {
241 let is_running: bool = check_systemctl_status("nginx").await?;
242 let is_enabled: bool = check_systemctl_enabled("nginx").await?;
243
244 let config_valid: bool = if is_running {
245 match Command::new("nginx").arg("-t").output().await {
246 Ok(output) => output.status.success(),
247 Err(_) => false,
248 }
249 } else {
250 false
251 };
252
253 Ok(NginxStatus {
254 is_running,
255 is_enabled,
256 config_valid,
257 error: None,
258 })
259}
260
261async fn check_systemctl_status(service: &str) -> Result<bool> {
262 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
263 return Ok(false);
264 }
265
266 let output = Command::new("systemctl")
267 .arg("is-active")
268 .arg(service)
269 .output()
270 .await?;
271
272 Ok(output.status.success())
273}
274
275async fn check_systemctl_enabled(service: &str) -> Result<bool> {
276 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
277 return Ok(false);
278 }
279
280 let output = Command::new("systemctl")
281 .arg("is-enabled")
282 .arg(service)
283 .output()
284 .await?;
285
286 Ok(output.status.success())
287}
288
289pub async fn check_port_availability(
290 port: u16,
291 ownership: Option<&ListeningPortOwnership>,
292) -> Result<PortCheck> {
293 use std::net::TcpListener;
294
295 let is_open: bool = TcpListener::bind(format!("127.0.0.1:{}", port)).is_ok();
296
297 let is_blocked: bool = if cfg!(target_os = "linux") {
298 check_firewall_blocked(port).await.unwrap_or(false)
299 } else {
300 false
301 };
302
303 Ok(PortCheck {
304 port,
305 is_open,
306 is_blocked,
307 pids: ownership
308 .map(|value| value.pids.clone())
309 .unwrap_or_default(),
310 xbp_projects: ownership
311 .map(|value| value.xbp_projects.clone())
312 .unwrap_or_default(),
313 })
314}
315
316async fn check_firewall_blocked(port: u16) -> Result<bool> {
317 let output: std::process::Output = Command::new("iptables")
318 .arg("-L")
319 .arg("-n")
320 .output()
321 .await?;
322
323 if !output.status.success() {
324 return Ok(false);
325 }
326
327 let stdout = String::from_utf8_lossy(&output.stdout);
328 let port_str = port.to_string();
329 Ok(stdout.contains(&format!("dpt:{}", port_str)) && stdout.contains("DROP"))
330}
331
332pub async fn check_internet_connectivity() -> Result<bool> {
333 let result: std::process::Output = if cfg!(target_os = "windows") {
334 Command::new("ping")
335 .arg("-n")
336 .arg("1")
337 .arg("-w")
338 .arg("2000")
339 .arg("8.8.8.8")
340 .output()
341 .await?
342 } else {
343 Command::new("ping")
344 .arg("-c")
345 .arg("1")
346 .arg("-W")
347 .arg("2")
348 .arg("8.8.8.8")
349 .output()
350 .await?
351 };
352
353 Ok(result.status.success())
354}
355
356pub async fn measure_internet_speed() -> Result<InternetSpeed> {
357 if let Ok(speed) = measure_speed_with_speedtest_cli().await {
358 if speed.download_mbps >= 1.0 || speed.upload_mbps >= 0.5 {
359 return Ok(speed);
360 }
361 }
362
363 let ping: f64 = measure_ping().await?;
364 let download_mbps = measure_download_speed().await.unwrap_or(0.0);
365 let upload_mbps = 0.0;
366
367 Ok(InternetSpeed {
368 download_mbps,
369 upload_mbps,
370 ping_ms: ping,
371 })
372}
373
374fn parse_speed_value(token: &str) -> Option<f64> {
375 token.trim().replace(',', ".").parse::<f64>().ok()
376}
377
378async fn measure_speed_with_speedtest_cli() -> Result<InternetSpeed> {
379 let output: std::process::Output = Command::new("speedtest-cli")
380 .arg("--simple")
381 .output()
382 .await?;
383
384 if !output.status.success() {
385 return Err(anyhow::anyhow!("speedtest-cli failed"));
386 }
387
388 let stdout: std::borrow::Cow<'_, str> = String::from_utf8_lossy(&output.stdout);
389 let mut ping_ms: f64 = 0.0;
390 let mut download_mbps: f64 = 0.0;
391 let mut upload_mbps: f64 = 0.0;
392
393 for line in stdout.lines() {
394 if line.starts_with("Ping:") {
395 if let Some(val) = line.split_whitespace().nth(1) {
396 ping_ms = parse_speed_value(val).unwrap_or(0.0);
397 }
398 } else if line.starts_with("Download:") {
399 if let Some(val) = line.split_whitespace().nth(1) {
400 download_mbps = parse_speed_value(val).unwrap_or(0.0);
401 }
402 } else if line.starts_with("Upload:") {
403 if let Some(val) = line.split_whitespace().nth(1) {
404 upload_mbps = parse_speed_value(val).unwrap_or(0.0);
405 }
406 }
407 }
408
409 Ok(InternetSpeed {
410 download_mbps,
411 upload_mbps,
412 ping_ms,
413 })
414}
415
416async fn measure_ping() -> Result<f64> {
417 let output: std::process::Output = if cfg!(target_os = "windows") {
418 Command::new("ping")
419 .arg("-n")
420 .arg("4")
421 .arg("8.8.8.8")
422 .output()
423 .await?
424 } else {
425 Command::new("ping")
426 .arg("-c")
427 .arg("4")
428 .arg("8.8.8.8")
429 .output()
430 .await?
431 };
432
433 if !output.status.success() {
434 return Ok(0.0);
435 }
436
437 let stdout = String::from_utf8_lossy(&output.stdout);
438
439 if cfg!(target_os = "windows") {
440 for line in stdout.lines() {
441 if line.contains("Average") {
442 if let Some(avg_part) = line.split('=').next_back() {
443 let avg_str = avg_part.trim().trim_end_matches("ms");
444 if let Ok(avg) = avg_str.parse::<f64>() {
445 return Ok(avg);
446 }
447 }
448 }
449 }
450 } else {
451 for line in stdout.lines() {
452 if line.contains("avg") || line.contains("rtt") {
453 if let Some(avg_str) = line.split('/').nth(4) {
454 if let Ok(avg) = avg_str.trim().parse::<f64>() {
455 return Ok(avg);
456 }
457 }
458 }
459 }
460 }
461
462 Ok(0.0)
463}
464
465async fn measure_download_speed() -> Result<f64> {
466 let client: reqwest::Client = reqwest::Client::builder()
467 .timeout(DOWNLOAD_PROBE_REQUEST_TIMEOUT)
468 .build()?;
469 let mut last_error: Option<String> = None;
470
471 for url in DOWNLOAD_PROBE_URLS {
472 match measure_download_speed_probe(&client, url).await {
473 Ok(mbps) => return Ok(mbps),
474 Err(err) => last_error = Some(err.to_string()),
475 }
476 }
477
478 Err(anyhow::anyhow!(
479 "all download probes failed{}",
480 last_error
481 .map(|value| format!(" (last error: {})", value))
482 .unwrap_or_default()
483 ))
484}
485
486async fn measure_download_speed_probe(client: &reqwest::Client, url: &str) -> Result<f64> {
487 let start = Instant::now();
488 let mut response: reqwest::Response = client
489 .get(url)
490 .header("Cache-Control", "no-cache")
491 .header("Pragma", "no-cache")
492 .send()
493 .await?;
494
495 if !response.status().is_success() {
496 return Err(anyhow::anyhow!(
497 "download probe {} returned status {}",
498 url,
499 response.status()
500 ));
501 }
502
503 let mut total_bytes: u64 = 0;
504
505 loop {
506 if total_bytes >= DOWNLOAD_PROBE_TARGET_BYTES
507 || start.elapsed() >= DOWNLOAD_PROBE_MAX_DURATION
508 {
509 break;
510 }
511
512 let chunk = response.chunk().await?;
513 let Some(bytes) = chunk else {
514 break;
515 };
516
517 total_bytes = total_bytes.saturating_add(bytes.len() as u64);
518 }
519
520 if total_bytes < DOWNLOAD_PROBE_MIN_BYTES {
521 return Err(anyhow::anyhow!(
522 "insufficient sample from {}: {} bytes",
523 url,
524 total_bytes
525 ));
526 }
527
528 let elapsed_seconds = start.elapsed().as_secs_f64().max(0.001);
529 let megabits = (total_bytes as f64 * 8.0) / 1_000_000.0;
530 Ok(megabits / elapsed_seconds)
531}
532
533pub async fn check_installed_programs() -> HashMap<String, bool> {
534 let programs = vec![
535 "jq",
536 "curl",
537 "nginx",
538 "git",
539 "docker",
540 "kubectl",
541 "microk8s",
542 "node",
543 "npm",
544 "python",
545 "python3",
546 "pip",
547 "pip3",
548 "cargo",
549 "rustc",
550 "pm2",
551 "speedtest-cli",
552 "systemctl",
553 "launchctl",
554 ];
555
556 let mut results = HashMap::new();
557
558 for program in programs {
559 let is_installed = check_program_installed(program).await;
560 results.insert(program.to_string(), is_installed);
561 }
562
563 results
564}
565
566fn can_create_file_in_dir(dir: &std::path::Path) -> bool {
567 use std::time::{SystemTime, UNIX_EPOCH};
568
569 if !dir.is_dir() {
570 return false;
571 }
572
573 let nonce = SystemTime::now()
574 .duration_since(UNIX_EPOCH)
575 .map(|d| d.as_nanos())
576 .unwrap_or_default();
577 let probe = dir.join(format!(
578 ".xbp_diag_perm_probe_{}_{}",
579 std::process::id(),
580 nonce
581 ));
582
583 match OpenOptions::new().write(true).create_new(true).open(&probe) {
584 Ok(_) => {
585 let _ = fs::remove_file(&probe);
586 true
587 }
588 Err(_) => false,
589 }
590}
591
592fn check_path_permission_entry(
593 path: PathBuf,
594 purpose: &str,
595 require_read: bool,
596 require_write: bool,
597 require_create: bool,
598 optional: bool,
599) -> PathPermissionCheck {
600 let exists = path.exists();
601 let mut readable = false;
602 let mut writable = false;
603 let mut creatable = false;
604 let mut message = None;
605
606 if exists {
607 if path.is_dir() {
608 readable = fs::read_dir(&path).is_ok();
609 writable = can_create_file_in_dir(&path);
610 creatable = writable;
611 } else if path.is_file() {
612 readable = fs::File::open(&path).is_ok();
613 writable = OpenOptions::new().write(true).open(&path).is_ok();
614 if let Some(parent) = path.parent() {
615 creatable = can_create_file_in_dir(parent);
616 }
617 } else {
618 message = Some("Path exists but is not a regular file/directory".to_string());
619 }
620 } else if let Some(parent) = path.parent() {
621 creatable = can_create_file_in_dir(parent);
623 readable = parent.exists() && fs::read_dir(parent).is_ok();
624 writable = parent.exists() && can_create_file_in_dir(parent);
625 if optional {
626 message = Some("Path missing (optional)".to_string());
627 } else if require_create && !creatable {
628 message =
629 Some("Path missing and cannot be created with current permissions".to_string());
630 } else {
631 message = Some("Path missing".to_string());
632 }
633 } else if optional {
634 message = Some("Path missing (optional)".to_string());
635 } else {
636 message = Some("Path missing".to_string());
637 }
638
639 let mut missing = Vec::new();
640 if require_read && !readable {
641 missing.push("read");
642 }
643 if require_write && !writable {
644 missing.push("write");
645 }
646 if require_create && !creatable {
647 missing.push("create");
648 }
649
650 let mut ok = missing.is_empty();
651 if optional && !exists {
652 ok = true;
653 }
654 if !ok {
655 let detail = format!("Missing required access: {}", missing.join(", "));
656 message = match message {
657 Some(existing) => Some(format!("{}; {}", existing, detail)),
658 None => Some(detail),
659 };
660 }
661
662 let required = match (require_read, require_write, require_create) {
663 (true, true, true) => "read/write/create",
664 (true, true, false) => "read/write",
665 (true, false, true) => "read/create",
666 (false, true, true) => "write/create",
667 (true, false, false) => "read",
668 (false, true, false) => "write",
669 (false, false, true) => "create",
670 (false, false, false) => "none",
671 }
672 .to_string();
673
674 PathPermissionCheck {
675 path: path.display().to_string(),
676 purpose: purpose.to_string(),
677 required,
678 exists,
679 readable,
680 writable,
681 creatable,
682 ok,
683 message,
684 }
685}
686
687async fn get_path_permission_checks() -> Vec<PathPermissionCheck> {
688 let mut checks = Vec::new();
689
690 if cfg!(target_os = "linux") {
691 checks.push(check_path_permission_entry(
692 PathBuf::from("/etc/systemd/system"),
693 "Install/update systemd unit files",
694 true,
695 true,
696 true,
697 false,
698 ));
699 checks.push(check_path_permission_entry(
700 PathBuf::from("/etc/default/xbp"),
701 "Optional runtime environment file for xbp-api.service",
702 true,
703 true,
704 true,
705 true,
706 ));
707 checks.push(check_path_permission_entry(
708 PathBuf::from("/etc/nginx"),
709 "Read/write Nginx configuration root",
710 true,
711 true,
712 false,
713 false,
714 ));
715 checks.push(check_path_permission_entry(
716 PathBuf::from("/etc/nginx/sites-available"),
717 "Manage Nginx site configurations",
718 true,
719 true,
720 true,
721 false,
722 ));
723 checks.push(check_path_permission_entry(
724 PathBuf::from("/etc/nginx/sites-enabled"),
725 "Enable/disable Nginx sites",
726 true,
727 true,
728 true,
729 false,
730 ));
731 checks.push(check_path_permission_entry(
732 PathBuf::from("/var/log"),
733 "Write XBP and service log files",
734 true,
735 true,
736 true,
737 false,
738 ));
739 checks.push(check_path_permission_entry(
740 PathBuf::from("/var/lib/xbp"),
741 "State directory used by generated systemd services",
742 true,
743 true,
744 true,
745 true,
746 ));
747 checks.push(check_path_permission_entry(
748 PathBuf::from("/var/log/nginx"),
749 "Read Nginx access/error logs for diagnostics and metrics",
750 true,
751 true,
752 true,
753 true,
754 ));
755 checks.push(check_path_permission_entry(
756 PathBuf::from("/run"),
757 "Runtime sockets/state files used by services",
758 true,
759 true,
760 true,
761 false,
762 ));
763 checks.push(check_path_permission_entry(
764 PathBuf::from("/usr/local/bin/xbp"),
765 "Installed XBP CLI binary path",
766 true,
767 true,
768 true,
769 true,
770 ));
771 } else if let Ok(current) = env::current_dir() {
772 checks.push(check_path_permission_entry(
773 current,
774 "Current project directory",
775 true,
776 true,
777 true,
778 false,
779 ));
780 }
781
782 if let Ok(current) = env::current_dir() {
783 checks.push(check_path_permission_entry(
784 current.join(".xbp"),
785 "Local XBP project metadata directory",
786 true,
787 true,
788 true,
789 true,
790 ));
791 }
792
793 if let Ok(paths) = ensure_global_xbp_paths() {
795 checks.push(check_path_permission_entry(
796 paths.root_dir.clone(),
797 "Global XBP home (config + worktree-watch mutations)",
798 true,
799 true,
800 true,
801 true,
802 ));
803 checks.push(check_path_permission_entry(
804 paths.root_dir.join("mutations"),
805 "Worktree-watch mutation spool root under global XBP home",
806 true,
807 true,
808 true,
809 true,
810 ));
811 }
812
813 checks
814}
815
816async fn run_command_with_timeout(
817 program: &str,
818 args: &[&str],
819 timeout: Duration,
820) -> Option<std::process::Output> {
821 let mut cmd = Command::new(program);
822 cmd.args(args);
823 cmd.kill_on_drop(true);
824 match tokio::time::timeout(timeout, cmd.output()).await {
825 Ok(Ok(output)) => Some(output),
826 _ => None,
827 }
828}
829
830async fn check_program_installed(program: &str) -> bool {
831 let output = if cfg!(target_os = "windows") {
832 run_command_with_timeout("where", &[program], PROGRAM_CHECK_TIMEOUT).await
833 } else {
834 run_command_with_timeout("which", &[program], PROGRAM_CHECK_TIMEOUT).await
835 };
836
837 output.map(|value| value.status.success()).unwrap_or(false)
838}
839
840fn render_percent_bar(percent: f32, width: usize) -> String {
841 let pct = percent.clamp(0.0, 100.0);
842 let filled = ((pct / 100.0) * width as f32).round() as usize;
843 let filled = filled.min(width);
844 let empty = width.saturating_sub(filled);
845 format!("{}{}", "â–ˆ".repeat(filled), "â–‘".repeat(empty))
846}
847
848async fn get_os_info() -> Option<OsInfo> {
849 Some(OsInfo {
850 name: System::name(),
851 version: System::os_version(),
852 kernel_version: System::kernel_version(),
853 arch: Some(std::env::consts::ARCH.to_string()),
854 })
855}
856
857async fn get_cpu_info() -> Option<CpuInfo> {
858 let mut sys = System::new_all();
859 sys.refresh_cpu_all();
860 let cores = sys.cpus().len();
861 let usage = if cores > 0 {
862 sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / cores as f32
863 } else {
864 0.0
865 };
866 let brand = sys.cpus().first().map(|c| c.brand().to_string());
867 Some(CpuInfo {
868 brand,
869 cores,
870 usage,
871 })
872}
873
874async fn run_command_capture(program: &str, args: &[&str]) -> Option<String> {
875 let output = run_command_with_timeout(program, args, COMMAND_CAPTURE_TIMEOUT).await?;
876 let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
877 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
878 let combined: String = if !stdout.is_empty() { stdout } else { stderr };
879 if combined.is_empty() {
880 None
881 } else {
882 Some(combined)
883 }
884}
885
886async fn get_gpu_candidates() -> Vec<String> {
887 let mut out: Vec<String> = Vec::new();
888
889 if cfg!(target_os = "windows") {
890 if let Some(s) = run_command_capture(
891 "powershell",
892 &[
893 "-Command",
894 "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
895 ],
896 )
897 .await
898 {
899 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
900 out.push(line.to_string());
901 }
902 }
903 return out;
904 }
905
906 if let Some(s) = run_command_capture("nvidia-smi", &["-L"]).await {
907 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
908 out.push(line.to_string());
909 }
910 }
911
912 if out.is_empty() {
913 if let Some(s) =
914 run_command_capture("sh", &["-c", "lspci | grep -Ei 'vga|3d|display'"]).await
915 {
916 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
917 out.push(line.to_string());
918 }
919 }
920 }
921
922 if out.is_empty() {
923 if let Some(s) =
924 run_command_capture("sh", &["-c", "lshw -C display 2>/dev/null | head"]).await
925 {
926 for line in s.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
927 out.push(line.to_string());
928 }
929 }
930 }
931
932 out
933}
934
935async fn get_disk_infos() -> Vec<DiskInfo> {
936 let disks = Disks::new_with_refreshed_list();
937 let cwd = env::current_dir().ok();
938
939 let mut candidates: Vec<(usize, usize)> = Vec::new();
940 if let Some(cwd) = &cwd {
941 for (i, d) in disks.iter().enumerate() {
942 let mp = d.mount_point();
943 if cwd.starts_with(mp) {
944 candidates.push((mp.as_os_str().len(), i));
945 }
946 }
947 }
948 candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.0));
949 let current_idx = candidates.first().map(|(_, idx)| *idx);
950
951 disks
952 .iter()
953 .enumerate()
954 .map(|(i, d)| {
955 let total = d.total_space();
956 let used = total.saturating_sub(d.available_space());
957 let percent = if total > 0 {
958 (used as f32 / total as f32) * 100.0
959 } else {
960 0.0
961 };
962 let mount = d.mount_point().to_string_lossy().to_string();
963 let fs = if d.file_system().is_empty() {
964 None
965 } else {
966 Some(d.file_system().to_string_lossy().to_string())
967 };
968 DiskInfo {
969 mount,
970 fs,
971 total,
972 used,
973 percent,
974 bar: render_percent_bar(percent, 20),
975 is_current: current_idx == Some(i),
976 }
977 })
978 .collect()
979}
980
981fn get_shell_info() -> Option<ShellInfo> {
982 let shell = env::var("SHELL")
983 .ok()
984 .or_else(|| env::var("ComSpec").ok())
985 .or_else(|| env::var("0").ok());
986 let term = env::var("TERM").ok();
987 if shell.is_none() && term.is_none() {
988 None
989 } else {
990 Some(ShellInfo { shell, term })
991 }
992}
993
994async fn systemctl_active(service: &str) -> Option<bool> {
995 if !cfg!(target_os = "linux") || !command_exists("systemctl") {
996 return None;
997 }
998 let output = Command::new("systemctl")
999 .arg("is-active")
1000 .arg(service)
1001 .output()
1002 .await
1003 .ok()?;
1004 Some(output.status.success())
1005}
1006
1007async fn get_service_statuses() -> HashMap<String, String> {
1008 let mut out = HashMap::new();
1009 let services = [
1010 "nginx",
1011 "traefik",
1012 "apache2",
1013 "httpd",
1014 "postgresql",
1015 "postgrest",
1016 "prometheus",
1017 "grafana-server",
1018 "kafka",
1019 "xbp",
1020 ];
1021
1022 for s in services {
1023 let v = systemctl_active(s).await;
1024 let status = match v {
1025 Some(true) => "active",
1026 Some(false) => "inactive",
1027 None => "unknown",
1028 };
1029 out.insert(s.to_string(), status.to_string());
1030 }
1031
1032 out
1033}
1034
1035async fn get_proxy_detection() -> Option<ProxyDetection> {
1036 let nginx_sites_available = if cfg!(target_os = "linux") {
1037 let p = PathBuf::from("/etc/nginx/sites-available");
1038 if p.exists() {
1039 fs::read_dir(&p)
1040 .ok()
1041 .map(|rd| rd.filter_map(|e| e.ok()).count())
1042 } else {
1043 None
1044 }
1045 } else {
1046 None
1047 };
1048
1049 let nginx_active = systemctl_active("nginx").await;
1050 let traefik_active = systemctl_active("traefik").await;
1051 let apache_active = match systemctl_active("apache2").await {
1052 Some(v) => Some(v),
1053 None => systemctl_active("httpd").await,
1054 };
1055
1056 if nginx_sites_available.is_none()
1057 && nginx_active.is_none()
1058 && traefik_active.is_none()
1059 && apache_active.is_none()
1060 {
1061 None
1062 } else {
1063 Some(ProxyDetection {
1064 nginx_sites_available,
1065 nginx_active,
1066 traefik_active,
1067 apache_active,
1068 })
1069 }
1070}
1071
1072async fn get_clipboard_tools() -> HashMap<String, bool> {
1073 let mut tools = vec!["xclip", "xsel", "wl-copy", "pbcopy"];
1074 if cfg!(target_os = "windows") {
1075 tools.push("clip");
1076 }
1077 let mut out = HashMap::new();
1078 for t in tools {
1079 out.insert(t.to_string(), check_program_installed(t).await);
1080 }
1081 out
1082}
1083
1084fn get_listeners_on_all_interfaces() -> Vec<String> {
1085 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
1086 let proto_flags = ProtocolFlags::TCP;
1087 let sockets = get_sockets_info(af_flags, proto_flags).unwrap_or_default();
1088
1089 let mut out = Vec::new();
1090 for socket in sockets {
1091 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
1092 let state = format!("{:?}", tcp.state);
1093 if state != "Listen" && state != "LISTEN" {
1094 continue;
1095 }
1096 let addr = tcp.local_addr.to_string();
1097 if addr == "0.0.0.0" || addr == "::" {
1098 out.push(format!("{}:{}", addr, tcp.local_port));
1099 }
1100 }
1101 }
1102 out.sort();
1103 out.dedup();
1104 out
1105}
1106
1107async fn get_firewall_hint() -> Option<String> {
1108 if !cfg!(target_os = "linux") {
1109 return None;
1110 }
1111
1112 if let Some(s) = run_command_capture("sh", &["-c", "ufw status 2>/dev/null | head -n 1"]).await
1113 {
1114 if !s.trim().is_empty() {
1115 return Some(s.trim().to_string());
1116 }
1117 }
1118
1119 if let Some(s) =
1120 run_command_capture("sh", &["-c", "nft list ruleset 2>/dev/null | head -n 1"]).await
1121 {
1122 if !s.trim().is_empty() {
1123 return Some("nftables detected".to_string());
1124 }
1125 }
1126
1127 if let Some(s) = run_command_capture("sh", &["-c", "iptables -S 2>/dev/null | head -n 1"]).await
1128 {
1129 if !s.trim().is_empty() {
1130 return Some("iptables detected".to_string());
1131 }
1132 }
1133
1134 None
1135}
1136
1137async fn get_public_ipv4(connectivity: bool) -> Option<String> {
1138 if !connectivity {
1139 return None;
1140 }
1141 let client = reqwest::Client::builder()
1142 .timeout(Duration::from_secs(3))
1143 .build()
1144 .ok()?;
1145 let text = client
1146 .get("https://api.ipify.org")
1147 .send()
1148 .await
1149 .ok()?
1150 .text()
1151 .await
1152 .ok()?;
1153 let ip = text.trim().to_string();
1154 if ip.is_empty() {
1155 None
1156 } else {
1157 Some(ip)
1158 }
1159}
1160
1161async fn get_exposure_info(connectivity: bool) -> Option<ExposureInfo> {
1162 let public_ipv4 = get_public_ipv4(connectivity).await;
1163 let listening_on_all_interfaces = get_listeners_on_all_interfaces();
1164 let firewall_hint = get_firewall_hint().await;
1165 let summary = Some(if listening_on_all_interfaces.is_empty() {
1166 "No listeners on 0.0.0.0/:: detected".to_string()
1167 } else {
1168 "Listeners on 0.0.0.0/:: detected (may be publicly reachable)".to_string()
1169 });
1170 Some(ExposureInfo {
1171 public_ipv4,
1172 listening_on_all_interfaces,
1173 firewall_hint,
1174 summary,
1175 })
1176}
1177
1178async fn get_pm2_process_count() -> Option<usize> {
1179 let mut cmd = if cfg!(target_os = "windows") {
1180 let mut cmd = Command::new("powershell");
1181 cmd.arg("-NoProfile").arg("-Command").arg("pm2 jlist");
1182 cmd
1183 } else {
1184 let mut cmd = Command::new("pm2");
1185 cmd.arg("jlist");
1186 cmd
1187 };
1188 cmd.kill_on_drop(true);
1189
1190 let output = match tokio::time::timeout(PM2_DIAG_TIMEOUT, cmd.output()).await {
1191 Ok(Ok(output)) => output,
1192 Ok(Err(_)) | Err(_) => return None,
1193 };
1194
1195 if !output.status.success() {
1196 return None;
1197 }
1198 let stdout = String::from_utf8_lossy(&output.stdout);
1199 let value: serde_json::Value = serde_json::from_str(&stdout).ok()?;
1200 value.as_array().map(|arr| arr.len())
1201}
1202
1203async fn get_version(cmd: &str, args: &[&str]) -> ToolVersion {
1204 match run_command_with_timeout(cmd, args, TOOL_VERSION_TIMEOUT).await {
1205 Some(o) => {
1206 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
1207 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
1208 let line = stdout
1209 .lines()
1210 .next()
1211 .filter(|l| !l.trim().is_empty())
1212 .map(|l| l.trim().to_string())
1213 .or_else(|| {
1214 stderr
1215 .lines()
1216 .next()
1217 .filter(|l| !l.trim().is_empty())
1218 .map(|l| l.trim().to_string())
1219 });
1220 ToolVersion {
1221 present: o.status.success() || line.is_some(),
1222 version: line,
1223 }
1224 }
1225 None => ToolVersion {
1226 present: false,
1227 version: None,
1228 },
1229 }
1230}
1231
1232async fn get_tool_versions() -> HashMap<String, ToolVersion> {
1233 let mut out = HashMap::new();
1234
1235 out.insert(
1236 "python3".to_string(),
1237 get_version("python3", &["--version"]).await,
1238 );
1239 out.insert(
1240 "python".to_string(),
1241 get_version("python", &["--version"]).await,
1242 );
1243 out.insert(
1244 "node".to_string(),
1245 get_version("node", &["--version"]).await,
1246 );
1247 out.insert("npm".to_string(), get_version("npm", &["--version"]).await);
1248 out.insert(
1249 "pnpm".to_string(),
1250 get_version("pnpm", &["--version"]).await,
1251 );
1252 out.insert("pm2".to_string(), get_version("pm2", &["--version"]).await);
1253 out.insert(
1254 "docker".to_string(),
1255 get_version("docker", &["--version"]).await,
1256 );
1257 out.insert(
1258 "docker-compose".to_string(),
1259 get_version("docker-compose", &["--version"]).await,
1260 );
1261 out.insert(
1262 "kubectl".to_string(),
1263 get_version("kubectl", &["version", "--client"]).await,
1264 );
1265 out.insert(
1266 "microk8s".to_string(),
1267 get_version("microk8s", &["version"]).await,
1268 );
1269 out.insert(
1270 "rustc".to_string(),
1271 get_version("rustc", &["--version"]).await,
1272 );
1273 out.insert(
1274 "cargo".to_string(),
1275 get_version("cargo", &["--version"]).await,
1276 );
1277 out.insert(
1278 "rustup".to_string(),
1279 get_version("rustup", &["--version"]).await,
1280 );
1281
1282 out
1283}
1284
1285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286enum SectionState {
1287 Healthy,
1288 Warn,
1289 Fail,
1290 Info,
1291}
1292
1293fn section_badge(state: SectionState) -> colored::ColoredString {
1294 match state {
1295 SectionState::Healthy => "OK".green().bold(),
1296 SectionState::Warn => "WARN".yellow().bold(),
1297 SectionState::Fail => "FAIL".red().bold(),
1298 SectionState::Info => "INFO".cyan().bold(),
1299 }
1300}
1301
1302fn print_section_header(title: &str, state: SectionState, summary: &str) {
1303 println!(
1304 "\n{} {} {}",
1305 section_badge(state),
1306 title.bright_white().bold(),
1307 summary.bright_black()
1308 );
1309}
1310
1311fn diag_bool(value: bool) -> colored::ColoredString {
1312 if value {
1313 "true".green()
1314 } else {
1315 "false".red()
1316 }
1317}
1318
1319fn diag_bool_fragment(label: &str, value: bool) -> String {
1320 format!("{}={}", label, diag_bool(value))
1321}
1322
1323fn diag_ratio(passing: usize, total: usize) -> String {
1324 format!(
1325 "{}{}",
1326 passing.to_string().green(),
1327 format!("/{}", total).bright_white()
1328 )
1329}
1330
1331fn color_service_status(status: &str) -> colored::ColoredString {
1332 let lower = status.to_ascii_lowercase();
1333 if lower.contains("running") || lower.contains("active") || lower.contains("online") {
1334 status.green()
1335 } else if lower.contains("stopped") || lower.contains("failed") || lower.contains("error") {
1336 status.red()
1337 } else if lower.contains("degraded") || lower.contains("warn") {
1338 status.yellow()
1339 } else {
1340 status.bright_white()
1341 }
1342}
1343
1344fn render_feature_flags(flags: &HashMap<String, bool>) -> String {
1345 let mut items = flags
1346 .iter()
1347 .map(|(name, value)| format!("{}={}", name, diag_bool(*value)))
1348 .collect::<Vec<_>>();
1349 items.sort();
1350 items.join(" ")
1351}
1352
1353fn summarize_path_permissions(report: &DiagnosticReport) -> (usize, usize) {
1354 let total = report.path_permission_checks.len();
1355 let passing = report
1356 .path_permission_checks
1357 .iter()
1358 .filter(|item| item.ok)
1359 .count();
1360 (passing, total)
1361}
1362
1363fn summarize_port_checks(report: &DiagnosticReport) -> (usize, usize, usize) {
1364 let total = report.port_checks.len();
1365 let open = report
1366 .port_checks
1367 .iter()
1368 .filter(|item| item.is_open)
1369 .count();
1370 let blocked = report
1371 .port_checks
1372 .iter()
1373 .filter(|item| item.is_blocked)
1374 .count();
1375 (open, blocked, total)
1376}
1377
1378pub async fn print_diagnostic_report(report: &DiagnosticReport) {
1379 let feature_flags = render_feature_flags(&report.feature_flags);
1380 let env_summary = format!(
1381 "v{} | {} | connectivity {}",
1382 report.xbp_cli_version,
1383 feature_flags,
1384 if report.connectivity {
1385 "online"
1386 } else {
1387 "offline"
1388 }
1389 );
1390 print_section_header(
1391 "Environment",
1392 if report.connectivity {
1393 SectionState::Healthy
1394 } else {
1395 SectionState::Warn
1396 },
1397 &env_summary,
1398 );
1399
1400 println!(
1401 " {} {}",
1402 "XBP version:".bright_white(),
1403 report.xbp_cli_version.bright_cyan()
1404 );
1405 println!(" {} {}", "Feature flags:".bright_white(), feature_flags);
1406
1407 if let Some(os) = &report.os {
1408 let os_line = [
1409 os.name.clone().unwrap_or_else(|| "Unknown OS".to_string()),
1410 os.version.clone().unwrap_or_default(),
1411 ]
1412 .into_iter()
1413 .filter(|s| !s.is_empty())
1414 .collect::<Vec<_>>()
1415 .join(" ");
1416 println!(" {} {}", "OS:".bright_white(), os_line);
1417 if let Some(kernel) = &os.kernel_version {
1418 println!(" {} {}", "Kernel:".bright_white(), kernel);
1419 }
1420 if let Some(arch) = &os.arch {
1421 println!(" {} {}", "Arch:".bright_white(), arch);
1422 }
1423 }
1424
1425 if let Some(cpu) = &report.cpu {
1426 println!(
1427 " {} {} ({} cores, {:.1}% avg)",
1428 "CPU:".bright_white(),
1429 cpu.brand.clone().unwrap_or_else(|| "Unknown".into()),
1430 cpu.cores,
1431 cpu.usage
1432 );
1433 }
1434
1435 if let Some(shell) = &report.shell {
1436 if let Some(s) = &shell.shell {
1437 println!(" {} {}", "Shell:".bright_white(), s);
1438 }
1439 if let Some(t) = &shell.term {
1440 println!(" {} {}", "TERM:".bright_white(), t);
1441 }
1442 }
1443
1444 println!(
1445 " {} {}",
1446 "Connectivity:".bright_white(),
1447 if report.connectivity {
1448 "online".green()
1449 } else {
1450 "offline".red()
1451 }
1452 );
1453
1454 if let Some(speed) = &report.internet_speed {
1455 println!(" {} {:.2} ms", "Ping:".bright_white(), speed.ping_ms);
1456 println!(
1457 " {} {:.2} Mbps",
1458 "Download:".bright_white(),
1459 speed.download_mbps
1460 );
1461 if speed.upload_mbps > 0.0 {
1462 println!(
1463 " {} {:.2} Mbps",
1464 "Upload:".bright_white(),
1465 speed.upload_mbps
1466 );
1467 }
1468 }
1469
1470 if !report.gpu_candidates.is_empty() {
1471 print_section_header(
1472 "GPU candidates",
1473 SectionState::Info,
1474 &format!("{} candidate(s)", report.gpu_candidates.len()),
1475 );
1476 for line in &report.gpu_candidates {
1477 println!(" {}", line);
1478 }
1479 }
1480
1481 if !report.disks.is_empty() {
1482 print_section_header(
1483 "Disks",
1484 SectionState::Info,
1485 &format!("{} mount(s)", report.disks.len()),
1486 );
1487 for disk in &report.disks {
1488 let current = if disk.is_current { " *" } else { "" };
1489 let fs = disk.fs.clone().unwrap_or_else(|| "-".to_string());
1490 println!(
1491 " {}{} {} {:>5.1}% {} ({} / {} GB)",
1492 disk.mount,
1493 current,
1494 fs,
1495 disk.percent,
1496 disk.bar,
1497 disk.used / 1024 / 1024 / 1024,
1498 disk.total / 1024 / 1024 / 1024
1499 );
1500 }
1501 println!(" {} current mount", "*".bright_cyan());
1502 }
1503
1504 print_section_header(
1505 "System metrics",
1506 SectionState::Info,
1507 &format!("{} processes", report.system_metrics.process_count),
1508 );
1509 let metrics = &report.system_metrics;
1510
1511 let cpu_color = if metrics.cpu_usage > 80.0 {
1512 "red"
1513 } else if metrics.cpu_usage > 50.0 {
1514 "yellow"
1515 } else {
1516 "green"
1517 };
1518 println!(
1519 " {} {:.1}%",
1520 "CPU usage:".bright_white(),
1521 format!("{}", metrics.cpu_usage).color(cpu_color)
1522 );
1523
1524 let mem_color = if metrics.memory_percent > 80.0 {
1525 "red"
1526 } else if metrics.memory_percent > 50.0 {
1527 "yellow"
1528 } else {
1529 "green"
1530 };
1531 println!(
1532 " {} {:.1}% ({} MB / {} MB)",
1533 "Memory:".bright_white(),
1534 format!("{}", metrics.memory_percent).color(mem_color),
1535 metrics.memory_used / 1024 / 1024,
1536 metrics.memory_total / 1024 / 1024
1537 );
1538
1539 let disk_color = if metrics.disk_percent > 80.0 {
1540 "red"
1541 } else if metrics.disk_percent > 50.0 {
1542 "yellow"
1543 } else {
1544 "green"
1545 };
1546 println!(
1547 " {} {:.1}% ({} GB / {} GB)",
1548 "Disk:".bright_white(),
1549 format!("{}", metrics.disk_percent).color(disk_color),
1550 metrics.disk_used / 1024 / 1024 / 1024,
1551 metrics.disk_total / 1024 / 1024 / 1024
1552 );
1553
1554 println!(
1555 " {} {} MB down / {} MB up",
1556 "Network:".bright_white(),
1557 metrics.network_rx / 1024 / 1024,
1558 metrics.network_tx / 1024 / 1024
1559 );
1560
1561 let uptime_hours = metrics.uptime / 3600;
1562 let uptime_minutes = (metrics.uptime % 3600) / 60;
1563 println!(
1564 " {} {}h {}m",
1565 "Uptime:".bright_white(),
1566 uptime_hours,
1567 uptime_minutes
1568 );
1569 println!(
1570 " {} {}",
1571 "Processes:".bright_white(),
1572 metrics.process_count
1573 );
1574
1575 let (open_ports, blocked_ports, _) = summarize_port_checks(report);
1576 let runtime_state = if blocked_ports == 0 {
1577 SectionState::Healthy
1578 } else {
1579 SectionState::Fail
1580 };
1581 print_section_header(
1582 "Runtime",
1583 runtime_state,
1584 &format!(
1585 "services={} | pm2={} | ports={} open / {} blocked",
1586 report.service_statuses.len(),
1587 report.pm2_process_count.unwrap_or(0),
1588 open_ports,
1589 blocked_ports
1590 ),
1591 );
1592
1593 if let Some(proxy) = &report.proxy_detection {
1594 println!(" {}", "Proxy stack:".bright_white());
1595 if let Some(n) = proxy.nginx_sites_available {
1596 println!(" {} {}", "sites-available:".bright_white(), n);
1597 }
1598 if let Some(v) = proxy.nginx_active {
1599 println!(" {} {}", "nginx active:".bright_white(), diag_bool(v));
1600 }
1601 if let Some(v) = proxy.traefik_active {
1602 println!(" {} {}", "traefik active:".bright_white(), diag_bool(v));
1603 }
1604 if let Some(v) = proxy.apache_active {
1605 println!(" {} {}", "apache active:".bright_white(), diag_bool(v));
1606 }
1607 }
1608
1609 if let Some(count) = report.pm2_process_count {
1610 println!(" {} {}", "PM2 process count:".bright_white(), count);
1611 }
1612
1613 if !report.service_statuses.is_empty() {
1614 println!(" {}", "Services:".bright_white());
1615 let mut items: Vec<_> = report.service_statuses.iter().collect();
1616 items.sort_by_key(|(k, _)| *k);
1617 for (name, status) in items {
1618 println!(" {:15} {}", name, color_service_status(status));
1619 }
1620 }
1621
1622 if let Some(nginx) = &report.nginx_status {
1623 println!(" {}", "Nginx:".bright_white());
1624 println!(
1625 " {} {}",
1626 if nginx.is_running {
1627 "OK".green()
1628 } else {
1629 "NO".red()
1630 },
1631 if nginx.is_running {
1632 "Running".green()
1633 } else {
1634 "Stopped".red()
1635 }
1636 );
1637 println!(
1638 " {} {}",
1639 if nginx.is_enabled {
1640 "OK".green()
1641 } else {
1642 "NO".red()
1643 },
1644 if nginx.is_enabled {
1645 "Enabled".green()
1646 } else {
1647 "Disabled".red()
1648 }
1649 );
1650 println!(
1651 " {} {}",
1652 if nginx.config_valid {
1653 "OK".green()
1654 } else {
1655 "NO".red()
1656 },
1657 if nginx.config_valid {
1658 "Config valid".green()
1659 } else {
1660 "Config invalid".red()
1661 }
1662 );
1663 }
1664
1665 if !report.port_checks.is_empty() {
1666 println!(" {}", "Port coverage:".bright_white());
1667 let (xbp_ports, other_ports): (Vec<_>, Vec<_>) = report
1668 .port_checks
1669 .iter()
1670 .partition(|port_check| !port_check.xbp_projects.is_empty());
1671
1672 for port_check in xbp_ports.iter().chain(other_ports.iter()) {
1673 let status_icon = if port_check.is_open {
1674 "OK".green()
1675 } else {
1676 "NO".red()
1677 };
1678 let blocked_text = if port_check.is_blocked {
1679 " (BLOCKED)".red().to_string()
1680 } else {
1681 String::new()
1682 };
1683 let xbp_suffix = if port_check.xbp_projects.is_empty() {
1684 String::new()
1685 } else {
1686 format!(" [XBP: {}]", port_check.xbp_projects.join(", "))
1687 };
1688 let line = format!(
1689 " {} Port {}: {}{}{}",
1690 status_icon,
1691 port_check.port,
1692 if port_check.is_open {
1693 "Available".green().to_string()
1694 } else {
1695 "In use".red().to_string()
1696 },
1697 blocked_text,
1698 xbp_suffix
1699 );
1700
1701 if port_check.xbp_projects.is_empty() {
1702 println!("{}", line);
1703 } else {
1704 println!("{}", line.bright_magenta());
1705 }
1706 }
1707 }
1708
1709 let (path_passing, path_total) = summarize_path_permissions(report);
1710 let project_state = if report.project_config_present || !report.provider_manifests.is_empty() {
1711 if path_passing == path_total {
1712 SectionState::Healthy
1713 } else {
1714 SectionState::Warn
1715 }
1716 } else {
1717 SectionState::Info
1718 };
1719 print_section_header(
1720 "Project",
1721 project_state,
1722 &format!(
1723 "config={} | services={} | manifests={} | paths={}",
1724 diag_bool(report.project_config_present),
1725 report.configured_services.len(),
1726 report.provider_manifests.len(),
1727 diag_ratio(path_passing, path_total)
1728 ),
1729 );
1730
1731 if !report.configured_services.is_empty() {
1732 println!(
1733 " {} {}",
1734 "Configured services:".bright_white(),
1735 report.configured_services.join(", ").bright_white()
1736 );
1737 }
1738
1739 if !report.provider_manifests.is_empty() {
1740 println!(
1741 " {} {}",
1742 "Provider manifests:".bright_white(),
1743 report.provider_manifests.join(", ").bright_white()
1744 );
1745 }
1746
1747 if !report.path_permission_checks.is_empty() {
1748 println!(
1749 " {} {}",
1750 "Checks passing:".bright_white(),
1751 diag_ratio(path_passing, path_total)
1752 );
1753
1754 for item in &report.path_permission_checks {
1755 let icon = if item.ok { "OK".green() } else { "NO".red() };
1756 println!(
1757 " {} {} [{}]",
1758 icon,
1759 item.path.bright_white(),
1760 item.required
1761 );
1762 println!(" purpose: {}", item.purpose);
1763 println!(
1764 " status: {} {} {} {}",
1765 diag_bool_fragment("exists", item.exists),
1766 diag_bool_fragment("read", item.readable),
1767 diag_bool_fragment("write", item.writable),
1768 diag_bool_fragment("create", item.creatable)
1769 );
1770 if let Some(msg) = &item.message {
1771 println!(" note: {}", msg);
1772 }
1773 }
1774 }
1775
1776 print_section_header(
1777 "XBP readiness",
1778 if report.project_config_present {
1779 SectionState::Healthy
1780 } else {
1781 SectionState::Warn
1782 },
1783 &format!(
1784 "version={} | feature flags={} | manifests={}",
1785 report.xbp_cli_version,
1786 report.feature_flags.len(),
1787 report.provider_manifests.len()
1788 ),
1789 );
1790 println!(
1791 " {} {}",
1792 "CLI version:".bright_white(),
1793 report.xbp_cli_version.bright_cyan()
1794 );
1795 println!(" {} {}", "Feature flags:".bright_white(), feature_flags);
1796 println!(
1797 " {} {}",
1798 "Project config:".bright_white(),
1799 if report.project_config_present {
1800 "detected".green()
1801 } else {
1802 "missing".red()
1803 }
1804 );
1805 println!(
1806 " {} {}",
1807 "Provider manifests:".bright_white(),
1808 report.provider_manifests.len()
1809 );
1810
1811 if let Some(watch) = &report.worktree_watch {
1812 let watch_state = if watch.branch_spool_exists || watch.watcher_running {
1813 SectionState::Healthy
1814 } else if watch.notes.iter().any(|note| note.contains("Failed")) {
1815 SectionState::Warn
1816 } else {
1817 SectionState::Info
1818 };
1819 let summary = match (
1820 watch.repo_owner.as_deref(),
1821 watch.repo_name.as_deref(),
1822 watch.branch.as_deref(),
1823 ) {
1824 (Some(owner), Some(name), Some(branch)) => format!(
1825 "runtime={} | {}/{} @ {} | events={} commits={}",
1826 watch.runtime_key, owner, name, branch, watch.event_jsonl_files, watch.commit_jsonl_files
1827 ),
1828 _ => format!(
1829 "runtime={} | global home only | wsl={}",
1830 watch.runtime_key,
1831 diag_bool(watch.is_wsl)
1832 ),
1833 };
1834 print_section_header("Worktree watch", watch_state, &summary);
1835
1836 println!(
1837 " {} {}",
1838 "Global XBP home:".bright_white(),
1839 watch.global_xbp_root.bright_cyan()
1840 );
1841 println!(
1842 " {} {}",
1843 "Mutations root:".bright_white(),
1844 watch.mutations_root
1845 );
1846 println!(
1847 " {} {} | {} {}",
1848 "Platform:".bright_white(),
1849 watch.platform,
1850 "runtime:".bright_white(),
1851 watch.runtime_key.bright_cyan()
1852 );
1853 println!(
1854 " {} {}",
1855 "WSL:".bright_white(),
1856 diag_bool(watch.is_wsl)
1857 );
1858
1859 if let (Some(owner), Some(name), Some(branch)) = (
1860 watch.repo_owner.as_deref(),
1861 watch.repo_name.as_deref(),
1862 watch.branch.as_deref(),
1863 ) {
1864 println!(
1865 " {} {}/{} ({})",
1866 "Repository:".bright_white(),
1867 owner,
1868 name,
1869 branch
1870 );
1871 }
1872
1873 if let Some(spool) = &watch.branch_spool {
1874 println!(
1875 " {} {} {}",
1876 "Branch spool:".bright_white(),
1877 spool,
1878 if watch.branch_spool_exists {
1879 "(exists)".green()
1880 } else {
1881 "(missing)".yellow()
1882 }
1883 );
1884 }
1885
1886 println!(
1887 " {} events={} commits={}",
1888 "Spool files:".bright_white(),
1889 watch.event_jsonl_files,
1890 watch.commit_jsonl_files
1891 );
1892
1893 if let Some(state_path) = &watch.watcher_state_path {
1894 println!(
1895 " {} {} | present={} running={}",
1896 "Watcher state:".bright_white(),
1897 state_path,
1898 diag_bool(watch.watcher_state_present),
1899 diag_bool(watch.watcher_running)
1900 );
1901 }
1902
1903 if !watch.alternate_spool_roots.is_empty() {
1904 println!(" {}", "Alternate spools (legacy / shared):".bright_white());
1905 for root in &watch.alternate_spool_roots {
1906 println!(" {}", root);
1907 }
1908 }
1909
1910 if !watch.notes.is_empty() {
1911 println!(" {}", "Notes:".bright_white());
1912 for note in &watch.notes {
1913 println!(" - {}", note);
1914 }
1915 }
1916 }
1917
1918 if let Some(codetime) = &report.codetime {
1919 print_section_header(
1920 "CodeTime inventory",
1921 if codetime.refreshed {
1922 SectionState::Healthy
1923 } else {
1924 SectionState::Info
1925 },
1926 &format!(
1927 "saved={} | refreshed={}",
1928 codetime.saved_config_path,
1929 diag_bool(codetime.refreshed)
1930 ),
1931 );
1932 println!(
1933 " {} {}",
1934 "Saved in:".bright_white(),
1935 codetime.saved_config_path
1936 );
1937 println!(
1938 " {} {}",
1939 "Refreshed:".bright_white(),
1940 if codetime.refreshed {
1941 "cached"
1942 } else {
1943 "fresh"
1944 }
1945 );
1946 if let Some(user) = &codetime.inventory.system_user {
1947 println!(" {} {}", "User:".bright_white(), user);
1948 }
1949 if let Some(host) = &codetime.inventory.host_name {
1950 println!(" {} {}", "Host:".bright_white(), host);
1951 }
1952 println!(
1953 " {} {} / {}",
1954 "Platform:".bright_white(),
1955 codetime.inventory.platform,
1956 codetime.inventory.architecture
1957 );
1958 if let Some(os) = &codetime.inventory.operating_system {
1959 let os_label = os
1960 .long_version
1961 .clone()
1962 .or_else(|| match (&os.name, &os.version) {
1963 (Some(name), Some(version)) => Some(format!("{} {}", name, version)),
1964 (Some(name), None) => Some(name.clone()),
1965 (None, Some(version)) => Some(version.clone()),
1966 (None, None) => None,
1967 });
1968 if let Some(os_label) = os_label {
1969 println!(" {} {}", "Operating system:".bright_white(), os_label);
1970 }
1971 if let Some(kernel_version) = &os.kernel_version {
1972 println!(" {} {}", "Kernel:".bright_white(), kernel_version);
1973 }
1974 }
1975 if !codetime.inventory.drives.is_empty() {
1976 let drives = codetime
1977 .inventory
1978 .drives
1979 .iter()
1980 .map(|drive| match &drive.file_system {
1981 Some(file_system) => format!("{} ({})", drive.mount, file_system),
1982 None => drive.mount.clone(),
1983 })
1984 .collect::<Vec<_>>()
1985 .join(", ");
1986 println!(" {} {}", "Drives:".bright_white(), drives);
1987 }
1988 if let Some(home) = codetime.inventory.standard_paths.get("home") {
1989 println!(" {} {}", "Home:".bright_white(), home);
1990 }
1991 if let Some(xbp_root) = codetime.inventory.standard_paths.get("xbp_global_root") {
1992 println!(" {} {}", "XBP root:".bright_white(), xbp_root);
1993 }
1994 if let Some(cursor_root) = codetime.inventory.standard_paths.get("cursor_roaming") {
1995 println!(" {} {}", "Cursor root:".bright_white(), cursor_root);
1996 }
1997
1998 let installed_tool_labels = codetime
1999 .inventory
2000 .installed_tools
2001 .iter()
2002 .filter(|tool| tool.present)
2003 .map(|tool| match &tool.version {
2004 Some(version) => format!("{} ({})", tool.name, version),
2005 None => tool.name.clone(),
2006 })
2007 .collect::<Vec<_>>();
2008 println!(
2009 " {} {}",
2010 "Installed tools:".bright_white(),
2011 if installed_tool_labels.is_empty() {
2012 "0".to_string()
2013 } else {
2014 format!(
2015 "{} ({})",
2016 installed_tool_labels.len(),
2017 format_sample_list(&installed_tool_labels, 8)
2018 )
2019 }
2020 );
2021
2022 let identity_labels = codetime
2023 .inventory
2024 .git_identities
2025 .iter()
2026 .map(
2027 |identity| match (&identity.user_name, &identity.user_email) {
2028 (Some(name), Some(email)) => format!("{} <{}>", name, email),
2029 (Some(name), None) => name.clone(),
2030 (None, Some(email)) => email.clone(),
2031 (None, None) => identity.scope.clone(),
2032 },
2033 )
2034 .collect::<Vec<_>>();
2035 if !identity_labels.is_empty() {
2036 println!(
2037 " {} {}",
2038 "Git identities:".bright_white(),
2039 format_sample_list(&identity_labels, 4)
2040 );
2041 }
2042
2043 let repo_names = codetime
2044 .inventory
2045 .github_repos
2046 .iter()
2047 .map(|repo| repo.full_name.clone())
2048 .collect::<Vec<_>>();
2049 println!(
2050 " {} {}",
2051 "Seen GitHub repos:".bright_white(),
2052 if repo_names.is_empty() {
2053 "0".to_string()
2054 } else {
2055 format!(
2056 "{} ({})",
2057 repo_names.len(),
2058 format_sample_list(&repo_names, 5)
2059 )
2060 }
2061 );
2062
2063 let project_names = codetime
2064 .inventory
2065 .xbp_projects
2066 .iter()
2067 .map(|project| project.name.clone())
2068 .collect::<Vec<_>>();
2069 println!(
2070 " {} {}",
2071 "Seen XBP projects:".bright_white(),
2072 if project_names.is_empty() {
2073 "0".to_string()
2074 } else {
2075 format!(
2076 "{} ({})",
2077 project_names.len(),
2078 format_sample_list(&project_names, 5)
2079 )
2080 }
2081 );
2082
2083 if let Some(cursor) = &codetime.inventory.cursor {
2084 println!(
2085 " {} {}",
2086 "Cursor present:".bright_white(),
2087 diag_bool(cursor.exists)
2088 );
2089 println!(
2090 " {} workspaceStorage={} extensions={} logs={}",
2091 "Cursor details:".bright_white(),
2092 cursor.workspace_storage_entries,
2093 cursor.extensions_entries,
2094 diag_bool(cursor.logs_exists)
2095 );
2096 if let Some(note) = &cursor.note {
2097 println!(" {} {}", "Cursor note:".bright_white(), note);
2098 }
2099 }
2100 }
2101
2102 if !report.clipboard_tools.is_empty() {
2103 println!("\n{}", "Clipboard tools".bright_yellow().bold());
2104 let mut items: Vec<_> = report.clipboard_tools.iter().collect();
2105 items.sort_by_key(|(k, _)| *k);
2106 for (name, value) in items {
2107 println!(" {:10} {}", name, diag_bool(*value));
2108 }
2109 }
2110
2111 if !report.installed_programs.is_empty() {
2112 print_section_header(
2113 "Installed programs",
2114 SectionState::Info,
2115 &format!("{} program(s)", report.installed_programs.len()),
2116 );
2117
2118 let mut programs: Vec<_> = report.installed_programs.iter().collect();
2119 programs.sort_by_key(|(name, _)| *name);
2120 for (program, installed) in programs {
2121 let status_icon = if *installed { "OK".green() } else { "NO".red() };
2122 let status_text = if *installed {
2123 "Installed".green()
2124 } else {
2125 "Missing".red()
2126 };
2127 println!(" {} {:15} {}", status_icon, program, status_text);
2128 }
2129 }
2130
2131 if !report.tool_versions.is_empty() {
2132 print_section_header(
2133 "Tool versions",
2134 SectionState::Info,
2135 &format!("{} tool(s)", report.tool_versions.len()),
2136 );
2137 let mut items: Vec<_> = report.tool_versions.iter().collect();
2138 items.sort_by_key(|(k, _)| *k);
2139 for (name, v) in items {
2140 if v.present {
2141 println!(
2142 " {:12} {}",
2143 name,
2144 v.version
2145 .clone()
2146 .unwrap_or_else(|| "present".to_string())
2147 .green()
2148 );
2149 } else {
2150 println!(" {:12} {}", name, "not found".red());
2151 }
2152 }
2153 }
2154}
2155fn format_sample_list(items: &[String], limit: usize) -> String {
2156 if items.is_empty() {
2157 return String::new();
2158 }
2159
2160 let shown = items.iter().take(limit).cloned().collect::<Vec<_>>();
2161 let remaining = items.len().saturating_sub(shown.len());
2162 if remaining == 0 {
2163 shown.join(", ")
2164 } else {
2165 format!("{} +{} more", shown.join(", "), remaining)
2166 }
2167}
2168
2169fn format_eta(duration: Duration) -> String {
2170 let total_seconds = duration.as_secs();
2171 let minutes = total_seconds / 60;
2172 let seconds = total_seconds % 60;
2173 if minutes > 0 {
2174 format!("{}m {:02}s", minutes, seconds)
2175 } else {
2176 format!("{}s", seconds)
2177 }
2178}
2179
2180fn estimate_diag_timeout_budget(
2181 ports_count: usize,
2182 skip_speed_test: bool,
2183 include_codetime: bool,
2184) -> Duration {
2185 let fixed_seconds =
2187 2 + 2 + 2 + 2 + 3 + 3 + 10 + 4 + PM2_DIAG_TIMEOUT.as_secs() + NGINX_CHECK_TIMEOUT.as_secs();
2188 let port_seconds =
2189 PORT_OWNERSHIP_TIMEOUT.as_secs() + (ports_count as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
2190 let speed_seconds = if skip_speed_test {
2191 0
2192 } else {
2193 INTERNET_SPEED_TIMEOUT.as_secs()
2194 };
2195 let codetime_seconds = if include_codetime {
2196 SYSTEM_INVENTORY_TIMEOUT.as_secs()
2197 } else {
2198 0
2199 };
2200 Duration::from_secs(fixed_seconds + port_seconds + speed_seconds + codetime_seconds)
2201}
2202
2203fn set_step_message(
2204 pb: &ProgressBar,
2205 step_index: usize,
2206 step_total: usize,
2207 started_at: Instant,
2208 estimated_budget: Duration,
2209 label: &str,
2210 timeout: Option<Duration>,
2211) {
2212 let elapsed = started_at.elapsed();
2213 let remaining = estimated_budget.saturating_sub(elapsed);
2214 let timeout_suffix = timeout
2215 .map(|value| format!(" | timeout {}s", value.as_secs()))
2216 .unwrap_or_default();
2217 pb.set_message(format!(
2218 "[{}/{}] {} | ETA {}{}",
2219 step_index,
2220 step_total,
2221 label,
2222 format_eta(remaining),
2223 timeout_suffix
2224 ));
2225}
2226
2227async fn collect_port_ownership_with_timeout() -> BTreeMap<u16, ListeningPortOwnership> {
2228 let task = tokio::task::spawn_blocking(collect_listening_port_ownership);
2229 match tokio::time::timeout(PORT_OWNERSHIP_TIMEOUT, task).await {
2230 Ok(Ok(Ok(ports))) => ports,
2231 Ok(Ok(Err(err))) => {
2232 let _ = log_warn("diag", "Port ownership scan failed", Some(err.as_str())).await;
2233 BTreeMap::new()
2234 }
2235 Ok(Err(err)) => {
2236 let message = format!("Port ownership scan task failed: {}", err);
2237 let _ = log_warn("diag", "Port ownership scan failed", Some(message.as_str())).await;
2238 BTreeMap::new()
2239 }
2240 Err(_) => {
2241 let timeout_note = format!(
2242 "Port ownership scan timed out after {} seconds",
2243 PORT_OWNERSHIP_TIMEOUT.as_secs()
2244 );
2245 let _ = log_warn(
2246 "diag",
2247 "Port ownership scan timed out",
2248 Some(timeout_note.as_str()),
2249 )
2250 .await;
2251 BTreeMap::new()
2252 }
2253 }
2254}
2255
2256async fn sync_system_inventory_with_timeout(include_cursor: bool) -> Option<CodeTimeDiagnostic> {
2257 let current_dir = env::current_dir().ok();
2258 let task = tokio::task::spawn_blocking(move || {
2259 sync_system_inventory(true, include_cursor, current_dir.as_deref())
2260 });
2261
2262 match tokio::time::timeout(SYSTEM_INVENTORY_TIMEOUT, task).await {
2263 Ok(Ok(Ok(result))) => Some(CodeTimeDiagnostic {
2264 saved_config_path: result.config_path.display().to_string(),
2265 refreshed: result.refreshed,
2266 inventory: result.inventory,
2267 }),
2268 Ok(Ok(Err(err))) => {
2269 let _ = log_warn("diag", "System inventory sync failed", Some(err.as_str())).await;
2270 None
2271 }
2272 Ok(Err(err)) => {
2273 let message = format!("System inventory task failed: {}", err);
2274 let _ = log_warn(
2275 "diag",
2276 "System inventory sync failed",
2277 Some(message.as_str()),
2278 )
2279 .await;
2280 None
2281 }
2282 Err(_) => {
2283 let message = format!(
2284 "System inventory sync timed out after {} seconds",
2285 SYSTEM_INVENTORY_TIMEOUT.as_secs()
2286 );
2287 let _ = log_warn(
2288 "diag",
2289 "System inventory sync timed out",
2290 Some(message.as_str()),
2291 )
2292 .await;
2293 None
2294 }
2295 }
2296}
2297
2298pub async fn run_full_diagnostics(
2299 ports: Vec<u16>,
2300 options: DiagnosticRunOptions,
2301) -> Result<DiagnosticReport> {
2302 let _ = log_info("diag", "Running system diagnostics...", None).await;
2303
2304 let estimated_budget =
2305 estimate_diag_timeout_budget(ports.len(), options.skip_speed_test, options.codetime);
2306 let timeout_summary = format!(
2307 "Estimated max runtime {} (PM2 {}s, ports {}s + {}s/port{}{})",
2308 format_eta(estimated_budget),
2309 PM2_DIAG_TIMEOUT.as_secs(),
2310 PORT_OWNERSHIP_TIMEOUT.as_secs(),
2311 PER_PORT_CHECK_TIMEOUT.as_secs(),
2312 if options.skip_speed_test {
2313 ", speed test skipped"
2314 } else {
2315 ", speed test up to 20s"
2316 },
2317 if options.codetime {
2318 ", codetime inventory up to 25s"
2319 } else {
2320 ""
2321 }
2322 );
2323 let _ = log_info(
2324 "diag",
2325 "Diagnostics timeout budget",
2326 Some(timeout_summary.as_str()),
2327 )
2328 .await;
2329
2330 let pb: ProgressBar = ProgressBar::new_spinner();
2331 pb.enable_steady_tick(Duration::from_millis(80));
2332 pb.set_style(
2333 ProgressStyle::with_template("{spinner} {msg}")
2334 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
2335 );
2336 let started_at = Instant::now();
2337 let step_total = if options.codetime { 14 } else { 13 };
2338 let mut step_index = 1usize;
2339
2340 set_step_message(
2341 &pb,
2342 step_index,
2343 step_total,
2344 started_at,
2345 estimated_budget,
2346 "Collecting system metrics",
2347 None,
2348 );
2349 step_index += 1;
2350 let system_metrics = get_system_metrics().await?;
2351
2352 set_step_message(
2353 &pb,
2354 step_index,
2355 step_total,
2356 started_at,
2357 estimated_budget,
2358 "Collecting OS/CPU/GPU",
2359 None,
2360 );
2361 step_index += 1;
2362 let os = get_os_info().await;
2363 let cpu = get_cpu_info().await;
2364 let gpu_candidates = get_gpu_candidates().await;
2365
2366 set_step_message(
2367 &pb,
2368 step_index,
2369 step_total,
2370 started_at,
2371 estimated_budget,
2372 "Collecting disks",
2373 None,
2374 );
2375 step_index += 1;
2376 let disks = get_disk_infos().await;
2377
2378 let codetime = if options.codetime {
2379 set_step_message(
2380 &pb,
2381 step_index,
2382 step_total,
2383 started_at,
2384 estimated_budget,
2385 if options.cursor {
2386 "Refreshing CodeTime + Cursor inventory"
2387 } else {
2388 "Refreshing CodeTime inventory"
2389 },
2390 Some(SYSTEM_INVENTORY_TIMEOUT),
2391 );
2392 step_index += 1;
2393 sync_system_inventory_with_timeout(options.cursor).await
2394 } else {
2395 None
2396 };
2397
2398 set_step_message(
2399 &pb,
2400 step_index,
2401 step_total,
2402 started_at,
2403 estimated_budget,
2404 "Collecting shell + clipboard",
2405 None,
2406 );
2407 step_index += 1;
2408 let shell = get_shell_info();
2409 let clipboard_tools = get_clipboard_tools().await;
2410
2411 set_step_message(
2412 &pb,
2413 step_index,
2414 step_total,
2415 started_at,
2416 estimated_budget,
2417 "Detecting proxies",
2418 None,
2419 );
2420 step_index += 1;
2421 let proxy_detection = get_proxy_detection().await;
2422
2423 set_step_message(
2424 &pb,
2425 step_index,
2426 step_total,
2427 started_at,
2428 estimated_budget,
2429 "Checking network/public IP",
2430 None,
2431 );
2432 step_index += 1;
2433 let connectivity = check_internet_connectivity().await.unwrap_or(false);
2434 let exposure = get_exposure_info(connectivity).await;
2435
2436 set_step_message(
2437 &pb,
2438 step_index,
2439 step_total,
2440 started_at,
2441 estimated_budget,
2442 "Checking tools",
2443 Some(TOOL_VERSION_TIMEOUT),
2444 );
2445 step_index += 1;
2446 let installed_programs = check_installed_programs().await;
2447 let tool_versions = get_tool_versions().await;
2448
2449 set_step_message(
2450 &pb,
2451 step_index,
2452 step_total,
2453 started_at,
2454 estimated_budget,
2455 "Checking file permissions",
2456 None,
2457 );
2458 step_index += 1;
2459 let path_permission_checks = get_path_permission_checks().await;
2460
2461 set_step_message(
2462 &pb,
2463 step_index,
2464 step_total,
2465 started_at,
2466 estimated_budget,
2467 "Checking services",
2468 None,
2469 );
2470 step_index += 1;
2471 let service_statuses = get_service_statuses().await;
2472
2473 set_step_message(
2474 &pb,
2475 step_index,
2476 step_total,
2477 started_at,
2478 estimated_budget,
2479 "Checking PM2 process table",
2480 Some(PM2_DIAG_TIMEOUT),
2481 );
2482 step_index += 1;
2483 let pm2_process_count = get_pm2_process_count().await;
2484
2485 let ports_timeout_budget = PORT_OWNERSHIP_TIMEOUT
2486 + Duration::from_secs(ports.len() as u64 * PER_PORT_CHECK_TIMEOUT.as_secs());
2487 set_step_message(
2488 &pb,
2489 step_index,
2490 step_total,
2491 started_at,
2492 estimated_budget,
2493 "Checking ports",
2494 Some(ports_timeout_budget),
2495 );
2496 step_index += 1;
2497 let port_ownership = collect_port_ownership_with_timeout().await;
2498
2499 let mut port_checks = Vec::new();
2500 for port in ports {
2501 let timed_check = tokio::time::timeout(
2502 PER_PORT_CHECK_TIMEOUT,
2503 check_port_availability(port, port_ownership.get(&port)),
2504 )
2505 .await;
2506 match timed_check {
2507 Ok(Ok(check)) => port_checks.push(check),
2508 Ok(Err(err)) => {
2509 let details = format!("Port {} check failed: {}", port, err);
2510 let _ = log_warn("diag", "Port check failed", Some(details.as_str())).await;
2511 }
2512 Err(_) => {
2513 let details = format!(
2514 "Port {} check timed out after {} seconds",
2515 port,
2516 PER_PORT_CHECK_TIMEOUT.as_secs()
2517 );
2518 let _ = log_warn("diag", "Port check timed out", Some(details.as_str())).await;
2519 }
2520 }
2521 }
2522
2523 set_step_message(
2524 &pb,
2525 step_index,
2526 step_total,
2527 started_at,
2528 estimated_budget,
2529 "Measuring internet speed",
2530 Some(INTERNET_SPEED_TIMEOUT),
2531 );
2532 step_index += 1;
2533 let internet_speed = if options.skip_speed_test {
2534 let _ = log_info(
2535 "diag",
2536 "Skipping internet speed test (--no-speed-test)",
2537 None,
2538 )
2539 .await;
2540 None
2541 } else if connectivity {
2542 match tokio::time::timeout(INTERNET_SPEED_TIMEOUT, measure_internet_speed()).await {
2543 Ok(Ok(speed)) => Some(speed),
2544 Ok(Err(err)) => {
2545 let details = format!("Internet speed probe failed: {}", err);
2546 let _ = log_warn(
2547 "diag",
2548 "Internet speed probe failed",
2549 Some(details.as_str()),
2550 )
2551 .await;
2552 None
2553 }
2554 Err(_) => {
2555 let details = format!(
2556 "Internet speed probe timed out after {} seconds",
2557 INTERNET_SPEED_TIMEOUT.as_secs()
2558 );
2559 let _ = log_warn(
2560 "diag",
2561 "Internet speed probe timed out",
2562 Some(details.as_str()),
2563 )
2564 .await;
2565 None
2566 }
2567 }
2568 } else {
2569 None
2570 };
2571
2572 set_step_message(
2573 &pb,
2574 step_index,
2575 step_total,
2576 started_at,
2577 estimated_budget,
2578 "Checking nginx",
2579 Some(NGINX_CHECK_TIMEOUT),
2580 );
2581 let nginx_status = match tokio::time::timeout(NGINX_CHECK_TIMEOUT, check_nginx_status()).await {
2582 Ok(Ok(status)) => Some(status),
2583 Ok(Err(err)) => {
2584 let details = format!("Nginx status check failed: {}", err);
2585 let _ = log_warn("diag", "Nginx status check failed", Some(details.as_str())).await;
2586 None
2587 }
2588 Err(_) => {
2589 let details = format!(
2590 "Nginx status check timed out after {} seconds",
2591 NGINX_CHECK_TIMEOUT.as_secs()
2592 );
2593 let _ = log_warn(
2594 "diag",
2595 "Nginx status check timed out",
2596 Some(details.as_str()),
2597 )
2598 .await;
2599 None
2600 }
2601 };
2602
2603 let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2604 let config = load_xbp_config().await.ok();
2605 let project_config_present = config.is_some();
2606 let configured_services = config
2607 .as_ref()
2608 .map(|cfg| {
2609 get_all_services(cfg)
2610 .into_iter()
2611 .map(|service| format!("{}:{}", service.name, service.port))
2612 .collect::<Vec<_>>()
2613 })
2614 .unwrap_or_default();
2615 let provider_manifests: Vec<String> = ProjectDetector::detect_provider_manifests(¤t_dir);
2616
2617 let mut feature_flags: HashMap<String, bool> = HashMap::new();
2618 feature_flags.insert("monitoring".to_string(), cfg!(feature = "monitoring"));
2619 feature_flags.insert(
2620 "kafka".to_string(),
2621 cfg!(all(feature = "kafka", not(windows))),
2622 );
2623 feature_flags.insert("kubernetes".to_string(), cfg!(feature = "kubernetes"));
2624 feature_flags.insert("docker".to_string(), cfg!(feature = "docker"));
2625
2626 let xbp_cli_version = env!("CARGO_PKG_VERSION").to_string();
2627 let worktree_watch = Some(collect_worktree_watch_diagnostic());
2628
2629 pb.finish_and_clear();
2630
2631 Ok(DiagnosticReport {
2632 system_metrics,
2633 os,
2634 cpu,
2635 gpu_candidates,
2636 disks,
2637 shell,
2638 proxy_detection,
2639 clipboard_tools,
2640 exposure,
2641 pm2_process_count,
2642 tool_versions,
2643 service_statuses,
2644 feature_flags,
2645 xbp_cli_version,
2646 project_config_present,
2647 configured_services,
2648 provider_manifests,
2649 nginx_status,
2650 port_checks,
2651 internet_speed,
2652 connectivity,
2653 installed_programs,
2654 path_permission_checks,
2655 codetime,
2656 worktree_watch,
2657 })
2658}