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 {
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();
}
}