Skip to main content

llmposter/
cli.rs

1use clap::Parser;
2use std::io::Write;
3use std::path::PathBuf;
4
5/// Default port the mock server listens on when `--port` is not specified.
6const DEFAULT_PORT: u16 = 2112;
7
8#[derive(Parser, Debug)]
9#[command(
10    name = "llmposter",
11    about = "Mock LLM API server — fixture-driven, deterministic responses for testing"
12)]
13pub struct Cli {
14    /// Path to fixtures directory or YAML file
15    #[arg(short, long)]
16    pub fixtures: PathBuf,
17
18    /// Validate fixtures without starting server
19    #[arg(long)]
20    pub validate: bool,
21
22    /// Port to listen on
23    #[arg(short, long, default_value_t = DEFAULT_PORT)]
24    pub port: u16,
25
26    /// Bind address (supports IPv4 and IPv6)
27    #[arg(short, long, default_value = "127.0.0.1")]
28    pub bind: String,
29
30    /// Verbose logging to stderr
31    #[arg(short, long)]
32    pub verbose: bool,
33}
34
35/// Run the CLI with the given options, writing status output to stderr.
36/// Returns `Ok(None)` for `--validate`, or `Ok(Some(MockServer))` after startup.
37/// The server runs until the returned `MockServer` is dropped.
38pub async fn run(cli: &Cli) -> Result<Option<crate::MockServer>, Box<dyn std::error::Error>> {
39    run_with_output(cli, &mut std::io::stderr()).await
40}
41
42/// Run the CLI with the given options, writing status output to the provided writer.
43/// This variant enables tests to capture output.
44pub async fn run_with_output(
45    cli: &Cli,
46    out: &mut (dyn Write + Send),
47) -> Result<Option<crate::MockServer>, Box<dyn std::error::Error>> {
48    let fixtures = if cli.fixtures.is_dir() {
49        crate::fixture::load_yaml_dir(&cli.fixtures)?
50    } else {
51        crate::fixture::load_yaml_file(&cli.fixtures)?
52    };
53
54    if cli.validate {
55        if fixtures.is_empty() {
56            return Err("No fixtures found — nothing to validate".into());
57        }
58        // validate() is already called by load_yaml_dir/load_yaml_file during loading.
59        // If we got here without error, all fixtures passed validation.
60        writeln!(out, "Validated {} fixtures successfully", fixtures.len())?;
61        return Ok(None);
62    }
63
64    if fixtures.is_empty() {
65        writeln!(
66            out,
67            "Warning: no fixtures loaded from {}",
68            cli.fixtures.display()
69        )?;
70    }
71
72    let warn_port_ignored = |out: &mut dyn Write,
73                             bind_port: &dyn std::fmt::Display,
74                             cli_port: u16|
75     -> std::io::Result<()> {
76        writeln!(
77            out,
78            "Warning: --port {} ignored because --bind already includes port {}",
79            cli_port, bind_port
80        )
81    };
82
83    let bind_addr = if let Ok(sa) = cli.bind.parse::<std::net::SocketAddr>() {
84        if cli.port != DEFAULT_PORT {
85            warn_port_ignored(out, &sa.port(), cli.port)?;
86        }
87        cli.bind.clone()
88    } else if let Ok(ip) = cli.bind.parse::<std::net::IpAddr>() {
89        match ip {
90            std::net::IpAddr::V6(_) => format!("[{}]:{}", cli.bind, cli.port),
91            std::net::IpAddr::V4(_) => format!("{}:{}", cli.bind, cli.port),
92        }
93    } else if let Some((host, port_str)) = cli.bind.rsplit_once(':') {
94        if !host.is_empty() && port_str.parse::<u16>().is_ok() {
95            if cli.port != DEFAULT_PORT {
96                warn_port_ignored(out, &port_str, cli.port)?;
97            }
98            cli.bind.clone()
99        } else {
100            format!("{}:{}", cli.bind, cli.port)
101        }
102    } else {
103        // Bare hostname (e.g. "localhost")
104        format!("{}:{}", cli.bind, cli.port)
105    };
106
107    let server = crate::ServerBuilder::new()
108        .fixtures(fixtures)
109        .bind(&bind_addr)
110        .verbose(cli.verbose)
111        .build()
112        .await?;
113
114    writeln!(out, "llmposter listening on {}", server.url())?;
115    writeln!(out, "Press Ctrl+C to stop")?;
116    Ok(Some(server))
117}