use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum ModeArg {
Local,
Remote,
}
#[derive(Debug, clap::Args)]
pub struct StartArgs {
#[arg(long, value_enum, default_value_t = ModeArg::Local)]
pub mode: ModeArg,
#[arg(long, default_value_t = 7391)]
pub port: u16,
#[arg(long, default_value = "~/.aasm/config.yaml")]
pub config: PathBuf,
#[arg(long)]
pub foreground: bool,
#[arg(long)]
pub no_dashboard: bool,
}
pub fn resolve_listen_addr(mode: ModeArg, port: u16) -> SocketAddr {
let ip = match mode {
ModeArg::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
ModeArg::Remote => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
};
SocketAddr::new(ip, port)
}
fn display_address(mode: ModeArg, port: u16) -> String {
match mode {
ModeArg::Local => format!("http://localhost:{port}"),
ModeArg::Remote => format!("http://0.0.0.0:{port}"),
}
}
pub fn format_started_banner(mode: ModeArg, port: u16, pid: u32) -> String {
let mode_label = match mode {
ModeArg::Local => "local",
ModeArg::Remote => "remote",
};
format!(
"✓ Agent Assembly gateway started\n Mode: {mode}\n Address: {addr}\n PID: {pid}\n",
mode = mode_label,
addr = display_address(mode, port),
pid = pid,
)
}
pub fn format_already_running_message(mode: ModeArg, port: u16, pid: u32) -> String {
format!(
"Gateway already running at {addr} (PID {pid}). Use 'aasm stop' first.",
addr = display_address(mode, port),
pid = pid,
)
}
pub fn check_already_running(pid_file: &Path, addr: SocketAddr, probe_timeout: Duration) -> Option<u32> {
let pid = super::pidfile::read_pid(pid_file).ok().flatten()?;
if !super::pidfile::is_pid_alive(pid) {
return None;
}
if !super::gw_probe::probe_tcp(addr, probe_timeout) {
return None;
}
Some(pid)
}
pub trait GatewaySpawner {
fn spawn_background(&self, addr: SocketAddr) -> std::io::Result<u32>;
fn exec_foreground(&self, addr: SocketAddr) -> std::io::Result<std::process::ExitStatus>;
}
pub struct ProcessSpawner;
impl GatewaySpawner for ProcessSpawner {
fn spawn_background(&self, addr: SocketAddr) -> std::io::Result<u32> {
let child = Command::new("aa-gateway")
.arg("--listen")
.arg(addr.to_string())
.spawn()?;
Ok(child.id())
}
fn exec_foreground(&self, addr: SocketAddr) -> std::io::Result<std::process::ExitStatus> {
Command::new("aa-gateway")
.arg("--listen")
.arg(addr.to_string())
.status()
}
}
pub fn run(args: StartArgs) -> ExitCode {
let pid_file = match super::pidfile::pid_file_path() {
Ok(p) => p,
Err(e) => {
eprintln!("aasm start: {e}");
return ExitCode::FAILURE;
}
};
run_with_spawner(args, &ProcessSpawner, &pid_file)
}
pub fn run_with_spawner<S: GatewaySpawner>(args: StartArgs, spawner: &S, pid_file: &Path) -> ExitCode {
let addr = resolve_listen_addr(args.mode, args.port);
if let Some(pid) = check_already_running(pid_file, addr, Duration::from_millis(200)) {
println!("{}", format_already_running_message(args.mode, args.port, pid));
return ExitCode::SUCCESS;
}
if args.foreground {
return match spawner.exec_foreground(addr) {
Ok(status) if status.success() => ExitCode::SUCCESS,
Ok(_) => ExitCode::FAILURE,
Err(e) => {
eprintln!("aasm start: failed to exec aa-gateway: {e}");
ExitCode::FAILURE
}
};
}
let pid = match spawner.spawn_background(addr) {
Ok(p) => p,
Err(e) => {
eprintln!("aasm start: failed to spawn aa-gateway: {e}");
return ExitCode::FAILURE;
}
};
if let Err(e) = super::pidfile::write_pid(pid_file, pid) {
eprintln!("aasm start: failed to write pid file: {e}");
return ExitCode::FAILURE;
}
match super::gw_probe::wait_for_ready(addr, Duration::from_secs(5), Duration::from_millis(100)) {
Ok(()) => {
println!("{}", format_started_banner(args.mode, args.port, pid));
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("aasm start: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_listen_addr_local_binds_loopback() {
let addr = resolve_listen_addr(ModeArg::Local, 7391);
assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
assert_eq!(addr.port(), 7391);
}
#[test]
fn resolve_listen_addr_remote_binds_unspecified() {
let addr = resolve_listen_addr(ModeArg::Remote, 7391);
assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED));
assert_eq!(addr.port(), 7391);
}
#[test]
fn format_started_banner_contains_mode_address_and_pid() {
let banner = format_started_banner(ModeArg::Local, 7391, 12_345);
assert!(banner.contains("✓ Agent Assembly gateway started"));
assert!(banner.contains("Mode: local"));
assert!(banner.contains("Address: http://localhost:7391"));
assert!(banner.contains("PID: 12345"));
}
#[test]
fn format_already_running_message_matches_story_contract() {
let msg = format_already_running_message(ModeArg::Local, 7391, 12_345);
assert_eq!(
msg,
"Gateway already running at http://localhost:7391 (PID 12345). Use 'aasm stop' first."
);
}
#[test]
fn check_already_running_returns_none_when_pid_file_is_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let pid_file = tmp.path().join("gateway.pid");
assert!(check_already_running(
&pid_file,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9),
Duration::from_millis(50)
)
.is_none());
}
#[test]
fn check_already_running_returns_some_when_pid_is_self_and_port_listens() {
let _net = crate::test_support::net_guard();
let tmp = tempfile::TempDir::new().unwrap();
let pid_file = tmp.path().join("gateway.pid");
let self_pid = std::process::id();
super::super::pidfile::write_pid(&pid_file, self_pid).unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let pid = check_already_running(&pid_file, addr, Duration::from_millis(200))
.expect("should report running when both pid and listener are live");
assert_eq!(pid, self_pid);
}
struct MockSpawner {
pid: u32,
}
impl GatewaySpawner for MockSpawner {
fn spawn_background(&self, _: SocketAddr) -> std::io::Result<u32> {
Ok(self.pid)
}
fn exec_foreground(&self, _: SocketAddr) -> std::io::Result<std::process::ExitStatus> {
unimplemented!("MockSpawner::exec_foreground")
}
}
#[test]
fn run_background_writes_pid_file_via_injected_spawner() {
let _net = crate::test_support::net_guard();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let pid_file = tmp.path().join("gateway.pid");
let args = StartArgs {
mode: ModeArg::Local,
port: addr.port(),
config: std::path::PathBuf::from("/dev/null"),
foreground: false,
no_dashboard: false,
};
let mock = MockSpawner { pid: 424_242 };
let exit = run_with_spawner(args, &mock, &pid_file);
assert_eq!(
format!("{exit:?}"),
format!("{:?}", ExitCode::SUCCESS),
"run should succeed when spawner + listener cooperate",
);
assert_eq!(super::super::pidfile::read_pid(&pid_file).unwrap(), Some(424_242),);
}
}