xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use crate::cli::commands::{DiagCmd, NginxSubCommand};
use crate::commands::load_xbp_config;
use crate::commands::nginx::run_nginx;
use crate::commands::system_diag::{
    print_diagnostic_report, print_diagnostic_report_json, run_full_diagnostics,
    DiagnosticRunOptions,
};
use crate::commands::system_inventory_refresh::run_system_inventory_refresh;
#[cfg(feature = "docker")]
use crate::commands::{docker::validate_compose, docker::validate_docker_env};
use crate::logging::{log_error, log_info, log_success, log_warn};
use crate::strategies::get_all_services;
use anyhow::Result;
use regex::Regex;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;

pub(crate) fn parse_diag_ports(input: &str) -> (Vec<u16>, usize) {
    let mut ports = Vec::new();
    let mut invalid = 0usize;

    for token in input.split(',') {
        let trimmed = token.trim();
        if trimmed.is_empty() {
            continue;
        }

        match trimmed.parse::<u16>() {
            Ok(port) => {
                if !ports.contains(&port) {
                    ports.push(port);
                }
            }
            Err(_) => invalid += 1,
        }
    }

    (ports, invalid)
}

pub async fn run_diag(cmd: DiagCmd, debug: bool) -> Result<()> {
    if cmd.refresh_system_inventory {
        run_system_inventory_refresh(false, cmd.cursor).map_err(anyhow::Error::msg)?;
        return Ok(());
    }

    if cmd.nginx {
        if cmd.fix_nginx {
            check_nginx_interactive(debug).await?;
        } else {
            check_nginx_report_only().await?;
        }
        return Ok(());
    }

    let run_codetime = cmd.codetime || cmd.cursor;
    let config = load_xbp_config().await.ok();
    let mut ports_to_check = Vec::new();
    if let Some(raw_ports) = cmd.ports.as_deref() {
        let (parsed_ports, invalid_count) = parse_diag_ports(raw_ports);
        if invalid_count > 0 {
            let details = format!(
                "Ignored {} invalid port token(s) from --ports={}",
                invalid_count, raw_ports
            );
            let _ = log_warn(
                "diag",
                "Invalid --ports entries ignored",
                Some(details.as_str()),
            )
            .await;
        }
        if !parsed_ports.is_empty() {
            ports_to_check = parsed_ports;
        }
    }

    if ports_to_check.is_empty() {
        if let Some(ref cfg) = config {
            let services = get_all_services(cfg);
            for service in services {
                ports_to_check.push(service.port);
            }
        }
    }

    if ports_to_check.is_empty() {
        // Expanded default host port sample (dev + common infra).
        ports_to_check = vec![
            22, 80, 443, 3000, 3001, 4000, 5000, 5173, 5432, 6379, 8000, 8080, 8443, 9090, 9100,
            1113,
        ];
    }

    // --full implies broader probes; --quick trims them.
    let full = cmd.full && !cmd.quick;
    let http_probe = (cmd.http_probe || full) && !cmd.quick;

    let run_options = DiagnosticRunOptions {
        skip_speed_test: cmd.no_speed_test || cmd.quick,
        codetime: run_codetime,
        cursor: cmd.cursor,
        quick: cmd.quick,
        full,
        http_probe,
    };

    match run_full_diagnostics(ports_to_check, run_options).await {
        Ok(report) => {
            if cmd.json {
                print_diagnostic_report_json(&report)?;
            } else {
                print_diagnostic_report(&report).await;
            }
        }
        Err(e) => {
            let _ = log_error("diag", "Failed to run diagnostics", Some(&e.to_string())).await;
        }
    }

    #[cfg(feature = "docker")]
    {
        if !cmd.quick {
            if let Err(e) = validate_docker_env(debug).await {
                let _ = log_warn("docker", "Docker not healthy", Some(&e)).await;
            }
            if let Err(e) = validate_compose(cmd.compose_file.as_deref(), debug).await {
                let _ = log_warn("docker", "Compose file invalid", Some(&e)).await;
            }
        }
    }

    // Nginx: report mismatches always; only prompt when --fix-nginx.
    if cmd.fix_nginx {
        check_nginx_interactive(debug).await?;
    } else if !cmd.quick {
        check_nginx_report_only().await?;
    }
    Ok(())
}

/// Check Nginx status and return a list of mismatches: (service_name/domain, expected_port, actual_port)
pub async fn check_nginx_mismatches() -> Result<Vec<(String, u16, u16)>> {
    let config = match load_xbp_config().await {
        Ok(c) => c,
        Err(_) => return Ok(vec![]), // Not in a project or invalid config
    };

    let services = get_all_services(&config);
    let mut mismatches = Vec::new();

    let sites_enabled = PathBuf::from("/etc/nginx/sites-enabled");
    if !sites_enabled.exists() {
        return Ok(vec![]);
    }

    let proxy_pass_regex = Regex::new(r"proxy_pass\s+http://127\.0\.0\.1:(\d+);").unwrap();

    for service in services {
        let expected_port = service.port;
        let mut potential_names = vec![service.name.clone()];
        if let Some(url) = &service.url {
            let domain = url
                .trim_start_matches("http://")
                .trim_start_matches("https://")
                .split('/')
                .next()
                .unwrap_or(url);
            potential_names.push(domain.to_string());
        }

        for name in potential_names {
            let config_path = sites_enabled.join(&name);
            if config_path.exists() {
                if let Ok(content) = fs::read_to_string(&config_path) {
                    if let Some(cap) = proxy_pass_regex.captures(&content) {
                        if let Ok(port) = cap[1].parse::<u16>() {
                            if port != expected_port {
                                mismatches.push((name.clone(), expected_port, port));
                                break;
                            }
                        }
                    }
                }
            }
        }
    }

    Ok(mismatches)
}

async fn check_nginx_report_only() -> Result<()> {
    let _ = log_info("diag", "Checking Nginx configuration...", None).await;

    let mismatches = check_nginx_mismatches().await?;

    if mismatches.is_empty() {
        let _ = log_success("diag", "Nginx configuration looks correct.", None).await;
        return Ok(());
    }

    for (name, expected, actual) in &mismatches {
        let _ = log_warn(
            "diag",
            &format!(
                "Port mismatch for {name}: Nginx uses {actual}, XBP config uses {expected}"
            ),
            None,
        )
        .await;
    }
    let _ = log_info(
        "diag",
        "Re-run with `xbp diag --fix-nginx` to interactively update proxy_pass targets.",
        None,
    )
    .await;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::parse_diag_ports;

    #[test]
    fn parse_diag_ports_accepts_valid_and_counts_invalid() {
        let (ports, invalid) = parse_diag_ports("80, 443, no, 8080, 80");
        assert_eq!(ports, vec![80, 443, 8080]);
        assert_eq!(invalid, 1);
    }

    #[test]
    fn parse_diag_ports_empty_tokens() {
        let (ports, invalid) = parse_diag_ports(" , , ");
        assert!(ports.is_empty());
        assert_eq!(invalid, 0);
    }

    #[test]
    fn parse_diag_ports_preserves_order_and_dedups() {
        let (ports, invalid) = parse_diag_ports("3000,80,3000,443,80");
        assert_eq!(ports, vec![3000, 80, 443]);
        assert_eq!(invalid, 0);
    }

    #[test]
    fn parse_diag_ports_rejects_out_of_range() {
        let (ports, invalid) = parse_diag_ports("0,65535,65536,-1,abc");
        // 0 and 65535 are valid u16; 65536 and -1 and abc invalid
        assert!(ports.contains(&0));
        assert!(ports.contains(&65535));
        assert!(invalid >= 2);
    }

    #[test]
    fn parse_diag_ports_single() {
        let (ports, invalid) = parse_diag_ports("8080");
        assert_eq!(ports, vec![8080]);
        assert_eq!(invalid, 0);
    }

    #[test]
    fn parse_diag_ports_whitespace_only_around_numbers() {
        let (ports, invalid) = parse_diag_ports("  22  ,  443  ");
        assert_eq!(ports, vec![22, 443]);
        assert_eq!(invalid, 0);
    }

    #[test]
    fn parse_diag_ports_all_invalid() {
        let (ports, invalid) = parse_diag_ports("x,y,z");
        assert!(ports.is_empty());
        assert_eq!(invalid, 3);
    }
}

async fn check_nginx_interactive(debug: bool) -> Result<()> {
    let _ = log_info("diag", "Checking Nginx configuration...", None).await;

    let mismatches = check_nginx_mismatches().await?;

    if mismatches.is_empty() {
        let _ = log_success("diag", "Nginx configuration looks correct.", None).await;
        return Ok(());
    }

    for (name, expected, actual) in mismatches {
        let _ = log_warn(
            "diag",
            &format!(
                "Port mismatch for {}: Nginx uses {}, XBP config uses {}",
                name, actual, expected
            ),
            None,
        )
        .await;

        print!(
            "Do you want to update Nginx to use port {}? [y/N]: ",
            expected
        );
        io::stdout().flush()?;

        let mut input = String::new();
        io::stdin().read_line(&mut input)?;

        if input.trim().to_lowercase() == "y" {
            let cmd = NginxSubCommand::Update {
                domain: name.clone(),
                port: expected,
            };
            if let Err(e) = run_nginx(cmd, debug).await {
                let _ = log_error("diag", "Failed to update Nginx", Some(&e.to_string())).await;
            }
        }
    }
    Ok(())
}