1use clap::Parser;
2use std::io::Write;
3use std::path::PathBuf;
4
5const 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 #[arg(short, long)]
16 pub fixtures: PathBuf,
17
18 #[arg(long)]
20 pub validate: bool,
21
22 #[arg(short, long, default_value_t = DEFAULT_PORT)]
24 pub port: u16,
25
26 #[arg(short, long, default_value = "127.0.0.1")]
28 pub bind: String,
29
30 #[arg(short, long)]
32 pub verbose: bool,
33}
34
35pub 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
42pub 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 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 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}