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