rypsok 0.10.0

Headless meta-search service for AI agents, served over the Model Context Protocol
//! The command line: `serve`, `check` and `--version`, with the exit codes
//! of REQ-OPS-001: 0 on success, 1 on a configuration error, 2 on a usage
//! error (RFC 004). Nothing is ever written to standard output by the
//! command line (REQ-MCP-002); diagnostics go to standard error.

use std::ffi::OsString;
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;

use clap::{Parser, Subcommand, ValueEnum};
use rypsok_core::config::{Config, ConfigError, ConfigSource};
use rypsok_core::{CancellationToken, Rypsok, telemetry};

use crate::mcp::{Adapter, http, stdio};

mod check;

/// Exit code of a successful run.
pub const EXIT_SUCCESS: u8 = 0;
/// Exit code of a configuration error.
pub const EXIT_CONFIGURATION: u8 = 1;
/// Exit code of a usage error.
pub const EXIT_USAGE: u8 = 2;

/// Top-level command line.
#[derive(Debug, Parser)]
#[command(
    name = "rypsok",
    version,
    about = "Headless meta-search service for AI agents"
)]
struct Cli {
    /// Path of the configuration file; `RYPSOK_CONFIG` is used when absent.
    #[arg(long, global = true, value_name = "PATH")]
    config: Option<PathBuf>,

    #[command(subcommand)]
    command: Command,
}

/// The subcommands.
#[derive(Debug, Subcommand)]
enum Command {
    /// Start the service on a transport.
    Serve {
        /// The transport to serve on.
        #[arg(long, value_enum, default_value_t = Transport::Stdio)]
        transport: Transport,
    },
    /// Validate the configuration and exit.
    Check,
}

/// Transports that `serve` accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Transport {
    /// The Model Context Protocol over standard input and output.
    Stdio,
    /// The Model Context Protocol over Streamable HTTP.
    Http,
}

/// Parses the arguments and runs the chosen command.
///
/// Returns the exit code instead of exiting, so that the function is
/// testable and the process ends through `main` alone.
pub fn run<I, T>(args: I) -> ExitCode
where
    I: IntoIterator<Item = T>,
    T: Into<OsString> + Clone,
{
    let cli = match Cli::try_parse_from(args) {
        Ok(cli) => cli,
        Err(error) => {
            // `--version` and `--help` are clap "errors" that exit with 0.
            let code = if error.use_stderr() {
                EXIT_USAGE
            } else {
                EXIT_SUCCESS
            };
            // clap writes help, version and usage errors itself.
            let _ = error.print();
            return ExitCode::from(code);
        }
    };
    match cli.command {
        Command::Serve { transport } => serve(cli.config.as_deref(), transport),
        Command::Check => check(cli.config.as_deref()),
    }
}

/// Writes one line to standard error. A failed write changes nothing.
fn diagnostic(line: &str) {
    let mut stderr = std::io::stderr().lock();
    let _ = writeln!(stderr, "{line}");
}

fn report(error: &ConfigError) -> ExitCode {
    for problem in &error.problems {
        diagnostic(&format!("rypsok: configuration: {problem}"));
    }
    ExitCode::from(EXIT_CONFIGURATION)
}

fn describe(source: &ConfigSource) -> String {
    match source {
        ConfigSource::Flag(p) => format!("--config {}", p.display()),
        ConfigSource::Env(p) => format!("RYPSOK_CONFIG {}", p.display()),
        ConfigSource::Defaults => "defaults (no file)".to_owned(),
    }
}

/// `rypsok check`: validate the configuration, resolve what `serve` would
/// resolve, and report the effective settings and the warnings on standard
/// error (REQ-CFG-005; RFC 020, DAT-1, DAT-2, D-6).
fn check(flag: Option<&std::path::Path>) -> ExitCode {
    let (config, source) = match Config::locate(flag) {
        Ok(found) => found,
        Err(error) => return report(&error),
    };
    if let Err(error) = config.validate() {
        return report(&error);
    }
    let report = match check::build(&config, &source) {
        Ok(report) => report,
        Err(error) => return self::report(&error),
    };
    for line in report.lines.iter().chain(&report.warnings) {
        diagnostic(line);
    }
    ExitCode::from(EXIT_SUCCESS)
}

/// `rypsok serve --transport stdio|http`: validate, build the service,
/// serve until end of input (stdio) or a signal, exit within LIM-O1
/// (REQ-OPS-002).
fn serve(flag: Option<&std::path::Path>, transport: Transport) -> ExitCode {
    let (config, source) = match Config::locate(flag) {
        Ok(found) => found,
        Err(error) => return report(&error),
    };
    if let Err(error) = config.validate() {
        return report(&error);
    }
    telemetry::init(config.logging.level);
    tracing::info!(event = "start", version = env!("CARGO_PKG_VERSION"), configuration = %describe(&source), "rypsok starting");
    if config.logging.log_queries {
        tracing::warn!(
            event = "log_queries",
            "logging.log_queries is on: query text appears in debug events"
        );
    }
    let service = match Rypsok::new(config) {
        Ok(service) => Arc::new(service),
        Err(error) => return report(&error),
    };
    let http_settings = match transport {
        Transport::Http => match http::HttpSettings::from_config(service.config()) {
            Ok(settings) => Some(settings),
            Err(error) => return report(&error),
        },
        Transport::Stdio => None,
    };
    let shutdown_ms = service.config().service.shutdown_ms;
    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    {
        Ok(runtime) => runtime,
        Err(error) => {
            diagnostic(&format!("rypsok: cannot start the runtime: {error}"));
            return ExitCode::from(EXIT_CONFIGURATION);
        }
    };
    let shutdown = CancellationToken::new();
    let outcome = runtime.block_on(async {
        let signals = shutdown.clone();
        tokio::spawn(async move {
            wait_for_signal().await;
            tracing::info!(event = "signal", "shutdown requested");
            signals.cancel();
        });
        let adapter = Adapter::new(service);
        match http_settings {
            Some(settings) => http::serve(adapter, settings, shutdown)
                .await
                .map_err(|e| e.to_string()),
            None => stdio::serve(adapter, shutdown)
                .await
                .map_err(|e| e.to_string()),
        }
    });
    // Everything still running was cancelled through its token; the runtime
    // is dropped without waiting for the blocking stdin reader, so that the
    // process exits well within LIM-O1.
    runtime.shutdown_timeout(Duration::from_millis(u64::from(shutdown_ms) / 10));
    match outcome {
        Ok(()) => ExitCode::from(EXIT_SUCCESS),
        Err(error) => {
            tracing::error!(event = "serve_failed", error = %error, "serve ended with an error");
            ExitCode::from(EXIT_CONFIGURATION)
        }
    }
}

#[cfg(unix)]
async fn wait_for_signal() {
    use tokio::signal::unix::{SignalKind, signal};
    let mut term = match signal(SignalKind::terminate()) {
        Ok(term) => term,
        Err(_) => {
            let _ = tokio::signal::ctrl_c().await;
            return;
        }
    };
    tokio::select! {
        _ = term.recv() => {}
        _ = tokio::signal::ctrl_c() => {}
    }
}

#[cfg(not(unix))]
async fn wait_for_signal() {
    let _ = tokio::signal::ctrl_c().await;
}

#[cfg(test)]
mod tests;