1use crate::AppConfig;
3use std::path::{Path, PathBuf};
4
5pub const HELP: &str = "rpi-http [--config PATH] [--project PATH] [--rpi-bin PATH]
6 [--bind IP] [--port N] [--registry PATH] [--session-dir PATH]
7 [--add-project PATH]... [--projects-root PATH] [--help] [--version]
8
9Serves /api/v1 over HTTP/SSE without frontend assets.
10JSON paths are relative to the config file; CLI paths use the working directory.
11CLI options override JSON. Bare executable names use PATH.
12Authentication: RPI_HTTP_PASSWORD and RPI_HTTP_ALLOWED_HOSTS.
13Legacy RPI_WEB_* variables remain accepted.";
14
15#[derive(Debug)]
16pub enum Launch {
17 Run(Box<AppConfig>),
18 Help,
19 Version,
20}
21
22pub fn from_args(args: impl IntoIterator<Item = String>) -> Result<Launch, String> {
23 let cwd = std::env::current_dir()
24 .map_err(|error| format!("cannot read working directory: {error}"))?;
25 from_args_at(args, &cwd)
26}
27
28fn from_args_at(args: impl IntoIterator<Item = String>, cwd: &Path) -> Result<Launch, String> {
29 let args = args.into_iter().collect::<Vec<_>>();
30 let mut config_file = None;
31 let mut options = Vec::new();
32 let mut index = 0;
33 while index < args.len() {
34 let argument = &args[index];
35 let (name, inline) = argument
36 .split_once('=')
37 .map_or((argument.as_str(), None), |(name, value)| {
38 (name, Some(value.to_owned()))
39 });
40 match name {
41 "--help" | "-h" => return Ok(Launch::Help),
42 "--version" | "-V" => return Ok(Launch::Version),
43 "--config" | "--project" | "--rpi-bin" | "--bind" | "--port" | "--registry"
44 | "--session-dir" | "--add-project" | "--projects-root" => {
45 let value = if let Some(value) = inline {
46 value
47 } else {
48 index += 1;
49 args.get(index)
50 .filter(|value| !value.starts_with("--"))
51 .cloned()
52 .ok_or_else(|| format!("{name} requires a value"))?
53 };
54 if value.is_empty() {
55 return Err(format!("{name} requires a value"));
56 }
57 if name == "--config" {
58 config_file = Some(PathBuf::from(value));
59 } else {
60 options.push((name.to_owned(), value));
61 }
62 }
63 _ => return Err(format!("unknown option: {argument}")),
64 }
65 index += 1;
66 }
67 let mut config = if let Some(path) = config_file {
68 let path = resolve(cwd, &path);
69 let bytes = std::fs::read(&path)
70 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
71 let mut config: AppConfig = serde_json::from_slice(&bytes)
72 .map_err(|error| format!("invalid {}: {error}", path.display()))?;
73 let base = path.parent().unwrap_or(Path::new("."));
74 config.project = resolve(base, &config.project);
75 config.registry = resolve(base, &config.registry);
76 for project in &mut config.projects {
77 *project = resolve(base, project);
78 }
79 config.rpi_bin = resolve_executable(base, &config.rpi_bin);
80 for path in [&mut config.session_dir, &mut config.projects_root]
81 .into_iter()
82 .flatten()
83 {
84 *path = resolve(base, path);
85 }
86 config
87 } else {
88 AppConfig {
89 project: cwd.to_path_buf(),
90 ..AppConfig::default()
91 }
92 };
93 for (name, value) in options {
94 match name.as_str() {
95 "--project" => config.project = resolve(cwd, Path::new(&value)),
96 "--rpi-bin" => config.rpi_bin = resolve_executable(cwd, Path::new(&value)),
97 "--bind" => config.bind = value,
98 "--port" => {
99 config.port = value
100 .parse()
101 .map_err(|_| "--port must be between 0 and 65535")?
102 }
103 "--registry" => config.registry = resolve(cwd, Path::new(&value)),
104 "--session-dir" => config.session_dir = Some(resolve(cwd, Path::new(&value))),
105 "--add-project" => config.projects.push(resolve(cwd, Path::new(&value))),
106 "--projects-root" => config.projects_root = Some(resolve(cwd, Path::new(&value))),
107 _ => unreachable!(),
108 }
109 }
110 Ok(Launch::Run(Box::new(config)))
111}
112
113fn resolve_executable(base: &Path, path: &Path) -> PathBuf {
114 if path.components().count() > 1 || path.to_string_lossy().contains(['/', '\\']) {
116 resolve(base, path)
117 } else {
118 path.to_path_buf()
119 }
120}
121
122fn resolve(base: &Path, path: &Path) -> PathBuf {
123 if path.is_absolute() {
124 path.to_path_buf()
125 } else {
126 base.join(path)
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 #[test]
134 fn json_paths_and_cli_precedence_are_independent_of_argument_order() {
135 let directory =
136 std::env::temp_dir().join(format!("rpi-http-config-{}", uuid::Uuid::new_v4()));
137 std::fs::create_dir(&directory).unwrap();
138 let file = directory.join("http.json");
139 std::fs::write(&file,r#"{"project":"workspace","rpiBin":"bin/rpi","port":3030,"projects":["extra"],"sessionDir":"history"}"#).unwrap();
140 let Launch::Run(config) = from_args([
141 "--port".into(),
142 "0".into(),
143 "--config".into(),
144 file.to_string_lossy().into(),
145 "--add-project=other".into(),
146 ])
147 .unwrap() else {
148 panic!("expected server config")
149 };
150 assert_eq!(config.port, 0);
151 assert_eq!(config.service_name, "rpi-http");
152 assert_eq!(config.project, directory.join("workspace"));
153 assert_eq!(config.rpi_bin, directory.join("bin/rpi"));
154 assert_eq!(
155 config.projects,
156 [
157 directory.join("extra"),
158 std::env::current_dir().unwrap().join("other")
159 ]
160 );
161 assert_eq!(config.session_dir, Some(directory.join("history")));
162 std::fs::remove_dir_all(&directory).unwrap();
163 }
164
165 #[test]
166 fn relative_config_and_cli_paths_are_anchored_before_launching_the_child() {
167 let cwd = std::env::temp_dir().join(format!("rpi-http-relative-{}", uuid::Uuid::new_v4()));
168 let directory = cwd.join("config");
169 std::fs::create_dir_all(&directory).unwrap();
170 std::fs::write(directory.join("http.json"), r#"{"project":"../workspace","sessionDir":"history","registry":"state/sessions.json","rpiBin":"./rpi","projectsRoot":"managed"}"#).unwrap();
171 let Launch::Run(config) = from_args_at(["--config=config/http.json".into()], &cwd).unwrap()
172 else {
173 panic!("expected server config")
174 };
175 assert_eq!(config.project, directory.join("../workspace"));
176 assert_eq!(config.session_dir, Some(directory.join("history")));
177 assert_eq!(config.registry, directory.join("state/sessions.json"));
178 assert_eq!(config.rpi_bin, directory.join("./rpi"));
179 assert_eq!(config.projects_root, Some(directory.join("managed")));
180 let Launch::Run(config) = from_args_at(
181 [
182 "--config=config/http.json".into(),
183 "--project=workspace".into(),
184 "--session-dir=history".into(),
185 "--registry=state/sessions.json".into(),
186 "--rpi-bin=./rpi".into(),
187 "--projects-root=managed".into(),
188 "--add-project=extra".into(),
189 ],
190 &cwd,
191 )
192 .unwrap() else {
193 panic!("expected server config")
194 };
195 assert_eq!(config.project, cwd.join("workspace"));
196 assert_eq!(config.session_dir, Some(cwd.join("history")));
197 assert_eq!(config.registry, cwd.join("state/sessions.json"));
198 assert_eq!(config.rpi_bin, cwd.join("./rpi"));
199 assert_eq!(config.projects_root, Some(cwd.join("managed")));
200 assert_eq!(config.projects, vec![cwd.join("extra")]);
201 for executable in ["rpi", "custom-host"] {
202 assert_eq!(
203 resolve_executable(&cwd, Path::new(executable)),
204 PathBuf::from(executable)
205 );
206 }
207 std::fs::remove_dir_all(cwd).unwrap();
208 }
209}