a3s 0.10.8

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;

const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 29653;

#[derive(Clone, Debug)]
pub(crate) struct ServeOptions {
    pub(crate) addr: SocketAddr,
    pub(crate) workspace: PathBuf,
    pub(crate) config_path: Option<PathBuf>,
    pub(crate) web_dir: Option<PathBuf>,
    pub(crate) api_only: bool,
    pub(crate) background: bool,
    pub(crate) replace: bool,
    pub(crate) offline: bool,
    pub(crate) allow_asset_download: bool,
    pub(crate) help: bool,
}

impl ServeOptions {
    pub(super) fn parse(args: &[String]) -> anyhow::Result<Self> {
        let mut host = std::env::var("A3S_CODE_WEB_HOST").unwrap_or_else(|_| DEFAULT_HOST.into());
        let mut port = std::env::var("A3S_CODE_WEB_PORT")
            .ok()
            .and_then(|value| value.parse::<u16>().ok())
            .unwrap_or(DEFAULT_PORT);
        let mut workspace = std::env::current_dir()?;
        let mut config_path = None;
        let mut web_dir = std::env::var_os("A3S_CODE_WEB_DIR").map(PathBuf::from);
        let mut api_only = false;
        let mut background = false;
        let mut replace = false;
        let offline = environment_flag("A3S_OFFLINE");
        let allow_asset_download = !environment_flag("A3S_NO_AUTO_INSTALL");
        let mut help = false;

        let mut index = 0;
        while index < args.len() {
            match args[index].as_str() {
                "-h" | "--help" | "help" => {
                    help = true;
                    index += 1;
                }
                "--host" => {
                    host = take_value(args, &mut index, "--host")?;
                }
                "--port" => {
                    let value = take_value(args, &mut index, "--port")?;
                    port = value
                        .parse::<u16>()
                        .map_err(|_| anyhow::anyhow!("--port must be a number from 0 to 65535"))?;
                }
                "--workspace" | "-w" => {
                    workspace = PathBuf::from(take_value(args, &mut index, "--workspace")?);
                }
                "--config" => {
                    config_path = Some(PathBuf::from(take_value(args, &mut index, "--config")?));
                }
                "--web-dir" => {
                    web_dir = Some(PathBuf::from(take_value(args, &mut index, "--web-dir")?));
                }
                "--api-only" => {
                    api_only = true;
                    index += 1;
                }
                "-d" | "--detach" => {
                    background = true;
                    index += 1;
                }
                "--replace" => {
                    replace = true;
                    index += 1;
                }
                other => anyhow::bail!("unknown a3s web option `{other}`"),
            }
        }

        let addr = resolve_addr(&host, port)?;
        Ok(Self {
            addr,
            workspace,
            config_path,
            web_dir,
            api_only,
            background,
            replace,
            offline,
            allow_asset_download,
            help,
        })
    }
}

fn environment_flag(name: &str) -> bool {
    std::env::var(name)
        .ok()
        .is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
}

fn take_value(args: &[String], index: &mut usize, flag: &str) -> anyhow::Result<String> {
    let value_index = *index + 1;
    let value = args
        .get(value_index)
        .filter(|value| !value.starts_with('-'))
        .ok_or_else(|| anyhow::anyhow!("{flag} requires a value"))?;
    *index += 2;
    Ok(value.clone())
}

fn resolve_addr(host: &str, port: u16) -> anyhow::Result<SocketAddr> {
    let mut addrs = format!("{host}:{port}")
        .to_socket_addrs()
        .map_err(|e| anyhow::anyhow!("invalid host/port {host}:{port}: {e}"))?;
    addrs
        .next()
        .ok_or_else(|| anyhow::anyhow!("could not resolve {host}:{port}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_background_mode_with_explicit_network_options() {
        let options = ServeOptions::parse(&[
            "-d".into(),
            "--host".into(),
            "127.0.0.1".into(),
            "--port".into(),
            "0".into(),
        ])
        .expect("valid web options");

        assert!(options.background);
        assert!(!options.replace);
        assert_eq!(options.addr.ip().to_string(), "127.0.0.1");
        assert_eq!(options.addr.port(), 0);
    }

    #[test]
    fn parses_explicit_safe_replacement() {
        let options = ServeOptions::parse(&["--replace".into()]).expect("valid replacement option");

        assert!(options.replace);
    }

    #[test]
    fn reports_unknown_options_for_the_top_level_web_command() {
        let error = ServeOptions::parse(&["--unknown".into()]).expect_err("unknown option");
        assert!(error.to_string().contains("unknown a3s web option"));
    }
}