rpi-http 0.1.1

HTTP and SSE protocol adapter for the shared rpi extension core
Documentation
//! Standalone API configuration. Frontend settings remain in rpi-web.
use crate::AppConfig;
use std::path::{Path, PathBuf};

pub const HELP: &str = "rpi-http [--config PATH] [--project PATH] [--rpi-bin PATH]
         [--bind IP] [--port N] [--registry PATH] [--session-dir PATH]
         [--add-project PATH]... [--projects-root PATH] [--help] [--version]

Serves /api/v1 over HTTP/SSE without frontend assets.
JSON paths are relative to the config file; CLI paths use the working directory.
CLI options override JSON. Bare executable names use PATH.
Authentication: RPI_HTTP_PASSWORD and RPI_HTTP_ALLOWED_HOSTS.
Legacy RPI_WEB_* variables remain accepted.";

#[derive(Debug)]
pub enum Launch {
    Run(Box<AppConfig>),
    Help,
    Version,
}

pub fn from_args(args: impl IntoIterator<Item = String>) -> Result<Launch, String> {
    let cwd = std::env::current_dir()
        .map_err(|error| format!("cannot read working directory: {error}"))?;
    from_args_at(args, &cwd)
}

fn from_args_at(args: impl IntoIterator<Item = String>, cwd: &Path) -> Result<Launch, String> {
    let args = args.into_iter().collect::<Vec<_>>();
    let mut config_file = None;
    let mut options = Vec::new();
    let mut index = 0;
    while index < args.len() {
        let argument = &args[index];
        let (name, inline) = argument
            .split_once('=')
            .map_or((argument.as_str(), None), |(name, value)| {
                (name, Some(value.to_owned()))
            });
        match name {
            "--help" | "-h" => return Ok(Launch::Help),
            "--version" | "-V" => return Ok(Launch::Version),
            "--config" | "--project" | "--rpi-bin" | "--bind" | "--port" | "--registry"
            | "--session-dir" | "--add-project" | "--projects-root" => {
                let value = if let Some(value) = inline {
                    value
                } else {
                    index += 1;
                    args.get(index)
                        .filter(|value| !value.starts_with("--"))
                        .cloned()
                        .ok_or_else(|| format!("{name} requires a value"))?
                };
                if value.is_empty() {
                    return Err(format!("{name} requires a value"));
                }
                if name == "--config" {
                    config_file = Some(PathBuf::from(value));
                } else {
                    options.push((name.to_owned(), value));
                }
            }
            _ => return Err(format!("unknown option: {argument}")),
        }
        index += 1;
    }
    let mut config = if let Some(path) = config_file {
        let path = resolve(cwd, &path);
        let bytes = std::fs::read(&path)
            .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
        let mut config: AppConfig = serde_json::from_slice(&bytes)
            .map_err(|error| format!("invalid {}: {error}", path.display()))?;
        let base = path.parent().unwrap_or(Path::new("."));
        config.project = resolve(base, &config.project);
        config.registry = resolve(base, &config.registry);
        for project in &mut config.projects {
            *project = resolve(base, project);
        }
        config.rpi_bin = resolve_executable(base, &config.rpi_bin);
        for path in [&mut config.session_dir, &mut config.projects_root]
            .into_iter()
            .flatten()
        {
            *path = resolve(base, path);
        }
        config
    } else {
        AppConfig {
            project: cwd.to_path_buf(),
            ..AppConfig::default()
        }
    };
    for (name, value) in options {
        match name.as_str() {
            "--project" => config.project = resolve(cwd, Path::new(&value)),
            "--rpi-bin" => config.rpi_bin = resolve_executable(cwd, Path::new(&value)),
            "--bind" => config.bind = value,
            "--port" => {
                config.port = value
                    .parse()
                    .map_err(|_| "--port must be between 0 and 65535")?
            }
            "--registry" => config.registry = resolve(cwd, Path::new(&value)),
            "--session-dir" => config.session_dir = Some(resolve(cwd, Path::new(&value))),
            "--add-project" => config.projects.push(resolve(cwd, Path::new(&value))),
            "--projects-root" => config.projects_root = Some(resolve(cwd, Path::new(&value))),
            _ => unreachable!(),
        }
    }
    Ok(Launch::Run(Box::new(config)))
}

fn resolve_executable(base: &Path, path: &Path) -> PathBuf {
    // Preserve a bare command for PATH lookup, including custom executable names.
    if path.components().count() > 1 || path.to_string_lossy().contains(['/', '\\']) {
        resolve(base, path)
    } else {
        path.to_path_buf()
    }
}

fn resolve(base: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        base.join(path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn json_paths_and_cli_precedence_are_independent_of_argument_order() {
        let directory =
            std::env::temp_dir().join(format!("rpi-http-config-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir(&directory).unwrap();
        let file = directory.join("http.json");
        std::fs::write(&file,r#"{"project":"workspace","rpiBin":"bin/rpi","port":3030,"projects":["extra"],"sessionDir":"history"}"#).unwrap();
        let Launch::Run(config) = from_args([
            "--port".into(),
            "0".into(),
            "--config".into(),
            file.to_string_lossy().into(),
            "--add-project=other".into(),
        ])
        .unwrap() else {
            panic!("expected server config")
        };
        assert_eq!(config.port, 0);
        assert_eq!(config.service_name, "rpi-http");
        assert_eq!(config.project, directory.join("workspace"));
        assert_eq!(config.rpi_bin, directory.join("bin/rpi"));
        assert_eq!(
            config.projects,
            [
                directory.join("extra"),
                std::env::current_dir().unwrap().join("other")
            ]
        );
        assert_eq!(config.session_dir, Some(directory.join("history")));
        std::fs::remove_dir_all(&directory).unwrap();
    }

    #[test]
    fn relative_config_and_cli_paths_are_anchored_before_launching_the_child() {
        let cwd = std::env::temp_dir().join(format!("rpi-http-relative-{}", uuid::Uuid::new_v4()));
        let directory = cwd.join("config");
        std::fs::create_dir_all(&directory).unwrap();
        std::fs::write(directory.join("http.json"), r#"{"project":"../workspace","sessionDir":"history","registry":"state/sessions.json","rpiBin":"./rpi","projectsRoot":"managed"}"#).unwrap();
        let Launch::Run(config) = from_args_at(["--config=config/http.json".into()], &cwd).unwrap()
        else {
            panic!("expected server config")
        };
        assert_eq!(config.project, directory.join("../workspace"));
        assert_eq!(config.session_dir, Some(directory.join("history")));
        assert_eq!(config.registry, directory.join("state/sessions.json"));
        assert_eq!(config.rpi_bin, directory.join("./rpi"));
        assert_eq!(config.projects_root, Some(directory.join("managed")));
        let Launch::Run(config) = from_args_at(
            [
                "--config=config/http.json".into(),
                "--project=workspace".into(),
                "--session-dir=history".into(),
                "--registry=state/sessions.json".into(),
                "--rpi-bin=./rpi".into(),
                "--projects-root=managed".into(),
                "--add-project=extra".into(),
            ],
            &cwd,
        )
        .unwrap() else {
            panic!("expected server config")
        };
        assert_eq!(config.project, cwd.join("workspace"));
        assert_eq!(config.session_dir, Some(cwd.join("history")));
        assert_eq!(config.registry, cwd.join("state/sessions.json"));
        assert_eq!(config.rpi_bin, cwd.join("./rpi"));
        assert_eq!(config.projects_root, Some(cwd.join("managed")));
        assert_eq!(config.projects, vec![cwd.join("extra")]);
        for executable in ["rpi", "custom-host"] {
            assert_eq!(
                resolve_executable(&cwd, Path::new(executable)),
                PathBuf::from(executable)
            );
        }
        std::fs::remove_dir_all(cwd).unwrap();
    }
}