Skip to main content

rpi_http/
config.rs

1//! Standalone API configuration. Frontend settings remain in rpi-web.
2use 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    // Preserve a bare command for PATH lookup, including custom executable names.
115    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}