oxo-flow-cli 0.22.0

CLI for the oxo-flow bioinformatics pipeline engine
//! Logic for the 'serve' command.

use crate::commands::print_banner;
use anyhow::Result;
use clap::ValueEnum;
use colored::Colorize;

/// Server operation mode. Mirrors the standalone web binary's `ServerMode`
/// (crates/oxo-flow-web/src/main.rs) so `oxo-flow serve` cannot start with a
/// misspelled mode: any value outside these three is rejected at parse time
/// instead of silently downgrading to a no-auth server (`build_router`
/// enables auth only for exact "team"/"hpc" matches).
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum ServeMode {
    /// Personal workstation mode (loopback bind, no auth).
    Personal,
    /// Team server mode (bind any host, auth required).
    Team,
    /// HPC cluster mode (bind any host, auth + scheduler routes).
    Hpc,
}

impl ServeMode {
    pub fn as_str(&self) -> &'static str {
        match self {
            ServeMode::Personal => "personal",
            ServeMode::Team => "team",
            ServeMode::Hpc => "hpc",
        }
    }
}

pub async fn handle_serve(
    mode: ServeMode,
    host: String,
    port: u16,
    base_path: String,
    open_browser: bool,
) -> Result<()> {
    print_banner();
    // Reuse, never retype: the router's mount-path contract lives in the
    // web crate (`normalize_base_path`); the previous inline copy here was
    // a hand-typed duplicate that had already drifted from it.
    let base = oxo_flow_web::server::normalize_base_path(&base_path);
    eprintln!(
        "{} Starting oxo-flow web server in {} mode on {}:{}{}",
        "Serve:".bold().cyan(),
        mode.as_str(),
        host,
        port,
        if base.is_empty() {
            String::new()
        } else {
            format!(" (base: {base})")
        }
    );

    // Desktop-app experience: open the interface in the default browser
    // once the server is listening. Fire-and-forget — a missing opener
    // (headless server) must not fail the serve command.
    if open_browser {
        let url = format!("http://{host}:{port}{base}");
        tokio::spawn(async move {
            // Small delay so the listener is up before the browser hits it.
            tokio::time::sleep(std::time::Duration::from_millis(800)).await;
            if let Err(e) = open_url_in_browser(&url) {
                eprintln!(
                    "{} Could not open a browser automatically: {e} — open {url} manually",
                    "Note:".yellow()
                );
            }
        });
    }

    // Pass the NORMALIZED path — axum's nest() panics on a mount path
    // without a leading slash (e.g. `--base-path oxoflow`), so the raw
    // argument must never reach the router.
    oxo_flow_web::start_server_with_mode(mode.as_str(), &host, port, &base).await?;

    Ok(())
}

/// Open a URL in the platform's default browser (best-effort).
fn open_url_in_browser(url: &str) -> std::io::Result<()> {
    #[cfg(target_os = "macos")]
    {
        std::process::Command::new("open")
            .arg(url)
            .spawn()
            .map(|_| ())
    }
    #[cfg(target_os = "linux")]
    {
        std::process::Command::new("xdg-open")
            .arg(url)
            .spawn()
            .map(|_| ())
    }
    #[cfg(windows)]
    {
        std::process::Command::new("cmd")
            .args(["/c", "start", "", url])
            .spawn()
            .map(|_| ())
    }
    #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
    {
        Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "no browser opener on this platform",
        ))
    }
}