Files
aare_launcher/src/config.rs
T
perl_d 2c98dd1c80 feat: basic functionality
- load config from json
- cli commands to launch existing profiles
- tui to pick custom directories and venv
2026-07-07 14:53:28 +02:00

187 lines
5.7 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
/// A launcher entry as it appears in `defaults.json`, where `gui_file` and
/// `server_file` may be omitted (falling back to the top-level defaults).
#[derive(Debug, Deserialize)]
struct RawLauncherEntry {
/// Virtualenv directory, relative to `venvs_base`.
venv: PathBuf,
/// Source directory, relative to `apps_base`.
src: PathBuf,
/// GUI entry point, relative to the entry's `src` directory.
gui_file: Option<PathBuf>,
/// Server entry point, relative to the entry's `src` directory.
server_file: Option<PathBuf>,
}
/// The configuration as parsed directly from `defaults.json`.
#[derive(Debug, Deserialize)]
struct RawConfig {
apps_base: PathBuf,
venvs_base: PathBuf,
default_gui_file: PathBuf,
default_server_file: PathBuf,
map: HashMap<String, RawLauncherEntry>,
}
/// A fully-resolved launcher entry with all paths expanded to absolute paths.
#[derive(Debug, Clone)]
pub struct LauncherEntry {
/// Python interpreter inside the virtualenv.
pub python: PathBuf,
/// Absolute path to the GUI entry point.
pub gui_file: PathBuf,
/// Absolute path to the server entry point.
pub server_file: PathBuf,
}
/// The resolved launcher configuration.
#[derive(Debug, Clone)]
pub struct Config {
/// Base directory containing the source/app directories.
pub apps_base: PathBuf,
/// Base directory containing the virtualenvs.
pub venvs_base: PathBuf,
/// Resolved launcher entries, keyed by beamline.
pub entries: HashMap<String, LauncherEntry>,
}
/// Join `rel` onto `base`, treating `rel` as relative even if it starts with a
/// leading separator (as the entry points in `defaults.json` could).
fn join_relative(base: &Path, rel: &Path) -> PathBuf {
base.join(rel.strip_prefix("/").unwrap_or(rel))
}
impl RawLauncherEntry {
/// Resolve this raw entry into absolute paths using the top-level bases and
/// defaults.
fn resolve(
self,
apps_base: &Path,
venvs_base: &Path,
default_gui_file: &Path,
default_server_file: &Path,
) -> LauncherEntry {
let src_dir = apps_base.join(&self.src);
let gui_file = self.gui_file.as_deref().unwrap_or(default_gui_file);
let server_file = self.server_file.as_deref().unwrap_or(default_server_file);
LauncherEntry {
python: venvs_base.join(&self.venv).join("bin/python"),
gui_file: join_relative(&src_dir, gui_file),
server_file: join_relative(&src_dir, server_file),
}
}
}
/// Parse a `defaults.json` document into a resolved [`Config`].
pub fn load_config(json: &str) -> Result<Config> {
let deserializer = &mut serde_json::Deserializer::from_str(json);
let raw: RawConfig = serde_path_to_error::deserialize(deserializer)
.context("failed to parse config JSON")?;
let entries = raw
.map
.into_iter()
.map(|(key, entry)| {
let resolved = entry.resolve(
&raw.apps_base,
&raw.venvs_base,
&raw.default_gui_file,
&raw.default_server_file,
);
(key, resolved)
})
.collect();
Ok(Config {
apps_base: raw.apps_base,
venvs_base: raw.venvs_base,
entries,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"apps_base": "/apps",
"venvs_base": "/venvs",
"default_gui_file": "/src/gui.py",
"default_server_file": "/src/server.py",
"map": {
"x10sa": {
"venv": "pxii",
"src": "aaredaq-pxii",
"gui_file": "/custom/gui.py",
"server_file": "/custom/server.py"
},
"x06da": {
"venv": "pxiii",
"src": "aaredaq-pxiii"
}
}
}"#;
#[test]
fn expands_python_and_entry_points() {
let config = load_config(SAMPLE).unwrap();
let entry = &config.entries["x10sa"];
assert_eq!(entry.python, PathBuf::from("/venvs/pxii/bin/python"));
assert_eq!(entry.gui_file, PathBuf::from("/apps/aaredaq-pxii/custom/gui.py"));
assert_eq!(
entry.server_file,
PathBuf::from("/apps/aaredaq-pxii/custom/server.py")
);
}
#[test]
fn falls_back_to_defaults_when_entry_points_omitted() {
let config = load_config(SAMPLE).unwrap();
let entry = &config.entries["x06da"];
assert_eq!(entry.python, PathBuf::from("/venvs/pxiii/bin/python"));
assert_eq!(entry.gui_file, PathBuf::from("/apps/aaredaq-pxiii/src/gui.py"));
assert_eq!(
entry.server_file,
PathBuf::from("/apps/aaredaq-pxiii/src/server.py")
);
}
#[test]
fn parses_all_entries() {
let config = load_config(SAMPLE).unwrap();
assert_eq!(config.entries.len(), 2);
}
#[test]
fn invalid_json_is_an_error() {
assert!(load_config("not json").is_err());
}
#[test]
fn error_reports_field_path() {
// `venv` has the wrong type in the single entry.
let bad = r#"{
"apps_base": "/apps",
"venvs_base": "/venvs",
"default_gui_file": "/src/gui.py",
"default_server_file": "/src/server.py",
"map": { "x10sa": { "venv": 123, "src": "aaredaq-pxii" } }
}"#;
let err = load_config(bad).unwrap_err();
let message = format!("{err:#}");
assert!(
message.contains("map.x10sa.venv"),
"expected field path in error, got: {message}"
);
}
}