feat: basic functionality #1

Merged
perl_d merged 1 commits from feat/basic_functionality into main 2026-07-07 15:18:16 +02:00
10 changed files with 2621 additions and 2 deletions
Generated
+1811
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -4,3 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.103"
clap = { version = "4.6.1", features = ["derive"] }
gethostname = "1.1.0"
ratatui = "0.30.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
serde_path_to_error = "0.1.20"
+20
View File
@@ -0,0 +1,20 @@
{
"apps_base": "/sls/mx/applications/",
"venvs_base": "/sls/mx/applications/venvs",
"default_gui_file": "src/aare/gui/gui.py",
"default_server_file": "src/aare/daq/server.py",
"map": {
"x10sa": {
"venv": "aaredaq-pxii",
"src": "aaredaq-pxii"
},
"x06da": {
"venv": "aaredaq-pxiii",
"src": "aaredaq-pxiii"
},
"x06sa": {
"venv": "aaredaq-pxi",
"src": "aaredaq-pxi"
}
}
}
+186
View File
@@ -0,0 +1,186 @@
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}"
);
}
}
+17
View File
@@ -0,0 +1,17 @@
use gethostname::gethostname;
/// Detect which console/beamline we're running on based on the short hostname.
pub fn detect_beamline() -> Result<String, String> {
let hostname = gethostname().to_string_lossy().into_owned();
if hostname.starts_with("x10sa") {
Ok("x10sa".to_string())
} else if hostname.starts_with("x06da") {
Ok("x06da".to_string())
} else if hostname.starts_with("x0sda") {
Ok("x06sa".to_string())
} else {
Err(format!(
"Error: unrecognized hostname '{hostname}' — cannot determine beamline"
))
}
}
+38
View File
@@ -0,0 +1,38 @@
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::config::LauncherEntry;
/// Run `python <entrypoint>` on the system, waiting for it to finish.
pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> {
let status = Command::new(python)
.arg(entrypoint)
.status()
.with_context(|| {
format!(
"failed to launch {} {}",
python.display(),
entrypoint.display()
)
})?;
if !status.success() {
bail!(
"{} {} exited with {status}",
python.display(),
entrypoint.display()
);
}
Ok(())
}
pub fn launch_server(entry: &LauncherEntry) -> Result<()> {
launch(&entry.python, &entry.server_file)
}
pub fn launch_gui(entry: &LauncherEntry) -> Result<()> {
launch(&entry.python, &entry.gui_file)
}
+90 -2
View File
@@ -1,3 +1,91 @@
fn main() {
println!("Hello, world!");
mod config;
mod detect;
mod launcher;
mod tui;
use std::path::{Path, PathBuf};
use anyhow::{Context, Error, Result, bail};
use clap::{Parser, Subcommand};
use config::{Config, LauncherEntry, load_config};
use detect::detect_beamline;
#[derive(Parser)]
#[command(name = "aare_launcher")]
struct Cli {
/// Path to the launcher config file.
#[arg(long, default_value = "data/defaults.json")]
config: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Enter the TUI to choose what to launch
#[command(alias = "p")]
Pick,
/// Launch the AareGUI with default settings
#[command(alias = "g")]
Gui {
/// Config entry (beamline) to launch. Defaults to the detected beamline.
#[arg(long)]
entry: Option<String>,
},
/// Launch the AareDAQ server with default settings
#[command(alias = "s")]
Server {
/// Config entry (beamline) to launch. Defaults to the detected beamline.
#[arg(long)]
entry: Option<String>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let json = std::fs::read_to_string(&cli.config)
.with_context(|| format!("failed to read config file {}", cli.config.display()))?;
let config = load_config(&json)?;
check_base_exists("apps_base", &config.apps_base, &cli.config)?;
check_base_exists("venvs_base", &config.venvs_base, &cli.config)?;
match cli.command {
Command::Pick => {
if let Some((python, entrypoint)) = tui::run(&config)? {
launcher::launch(&python, &entrypoint)?;
}
}
Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?,
Command::Server { entry } => launcher::launch_server(lookup_entry(&config, entry)?)?,
}
Ok(())
}
/// Fail with a helpful message if a configured base directory is missing.
fn check_base_exists(field: &str, base: &Path, config_file: &Path) -> Result<()> {
if !base.is_dir() {
bail!(
"{field} '{}' does not exist — edit '{field}' in {} to point at a valid directory",
base.display(),
config_file.display(),
);
}
Ok(())
}
/// Look up the launcher entry to use: the explicitly requested `entry`, or the
/// detected beamline when none was given.
fn lookup_entry(config: &Config, entry: Option<String>) -> Result<&LauncherEntry> {
let beamline = match entry {
Some(entry) => entry,
None => detect_beamline().map_err(Error::msg)?,
};
config
.entries
.get(&beamline)
.with_context(|| format!("no config entry for beamline '{beamline}'"))
}
+127
View File
@@ -0,0 +1,127 @@
use std::fs;
use std::path::Path;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
/// Which of the two lists currently has focus.
#[derive(Clone, Copy, PartialEq)]
enum Focus {
Source,
Venv,
}
/// The "Custom" tab: pick a source directory and a venv directory from the
/// contents of `apps_base` and `venvs_base`.
pub struct CustomTab {
sources: Vec<String>,
venvs: Vec<String>,
source_state: ListState,
venv_state: ListState,
focus: Focus,
}
impl CustomTab {
pub fn new(apps_base: &Path, venvs_base: &Path) -> Self {
let sources = list_dirs(apps_base);
let venvs = list_dirs(venvs_base);
CustomTab {
source_state: initial_state(&sources),
venv_state: initial_state(&venvs),
sources,
venvs,
focus: Focus::Source,
}
}
/// Move focus between the source and venv lists.
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Focus::Source => Focus::Venv,
Focus::Venv => Focus::Source,
};
}
pub fn next(&mut self) {
let (items, state) = self.focused_mut();
select_relative(items, state, 1);
}
pub fn previous(&mut self) {
let (items, state) = self.focused_mut();
select_relative(items, state, -1);
}
fn focused_mut(&mut self) -> (&[String], &mut ListState) {
match self.focus {
Focus::Source => (&self.sources, &mut self.source_state),
Focus::Venv => (&self.venvs, &mut self.venv_state),
}
}
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
let [source_area, venv_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(area);
let source_list = dir_list("Source", &self.sources, self.focus == Focus::Source);
frame.render_stateful_widget(source_list, source_area, &mut self.source_state);
let venv_list = dir_list("Venv", &self.venvs, self.focus == Focus::Venv);
frame.render_stateful_widget(venv_list, venv_area, &mut self.venv_state);
}
}
/// List the immediate subdirectories of `base`, sorted by name. Returns an
/// empty list if the directory can't be read.
fn list_dirs(base: &Path) -> Vec<String> {
let mut dirs: Vec<String> = fs::read_dir(base)
.into_iter()
.flatten()
.flatten()
.filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
dirs.sort();
dirs
}
fn initial_state(items: &[String]) -> ListState {
let mut state = ListState::default();
if !items.is_empty() {
state.select(Some(0));
}
state
}
fn select_relative(items: &[String], state: &mut ListState, delta: isize) {
if items.is_empty() {
return;
}
let len = items.len() as isize;
let current = state.selected().unwrap_or(0) as isize;
let next = (current + delta).rem_euclid(len) as usize;
state.select(Some(next));
}
fn dir_list<'a>(title: &'a str, items: &'a [String], focused: bool) -> List<'a> {
let border_style = if focused {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let list_items: Vec<ListItem> = items.iter().map(|d| ListItem::new(d.as_str())).collect();
List::new(list_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED))
.highlight_symbol("> ")
}
+179
View File
@@ -0,0 +1,179 @@
use std::collections::HashMap;
use std::path::PathBuf;
use ratatui::Frame;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use crate::config::LauncherEntry;
/// Which entry point to launch for the selected entry.
#[derive(Clone, Copy, PartialEq)]
enum LaunchChoice {
Gui,
Server,
}
/// The "Entries" tab: a list of config keys with a detail panel.
pub struct EntriesTab<'a> {
/// The available launcher entries.
config: &'a HashMap<String, LauncherEntry>,
/// Config keys, sorted for stable display.
keys: Vec<&'a String>,
/// Selection state of the entry list.
list_state: ListState,
/// When `Some`, the launch-choice dialog is open with this selection.
dialog: Option<LaunchChoice>,
}
impl<'a> EntriesTab<'a> {
pub fn new(config: &'a HashMap<String, LauncherEntry>) -> Self {
let mut keys: Vec<&String> = config.keys().collect();
keys.sort();
let mut list_state = ListState::default();
if !keys.is_empty() {
list_state.select(Some(0));
}
EntriesTab {
config,
keys,
list_state,
dialog: None,
}
}
pub fn next(&mut self) {
if self.keys.is_empty() {
return;
}
let next = self
.list_state
.selected()
.map_or(0, |i| (i + 1) % self.keys.len());
self.list_state.select(Some(next));
}
pub fn previous(&mut self) {
if self.keys.is_empty() {
return;
}
let previous = self
.list_state
.selected()
.map_or(0, |i| (i + self.keys.len() - 1) % self.keys.len());
self.list_state.select(Some(previous));
}
/// Whether the launch-choice dialog is currently open.
pub fn dialog_open(&self) -> bool {
self.dialog.is_some()
}
/// Open the launch-choice dialog for the current selection, defaulting to
/// the GUI. No-op when nothing is selected.
pub fn open_dialog(&mut self) {
if self.selected_entry().is_some() {
self.dialog = Some(LaunchChoice::Gui);
}
}
/// Close the dialog without launching.
pub fn cancel_dialog(&mut self) {
self.dialog = None;
}
/// Toggle the dialog selection between GUI and server.
pub fn toggle_dialog(&mut self) {
if let Some(choice) = &mut self.dialog {
*choice = match choice {
LaunchChoice::Gui => LaunchChoice::Server,
LaunchChoice::Server => LaunchChoice::Gui,
};
}
}
/// Confirm the dialog, returning the `(python, entrypoint)` to launch.
pub fn confirm(&self) -> Option<(PathBuf, PathBuf)> {
let choice = self.dialog?;
let entry = self.selected_entry()?;
let entrypoint = match choice {
LaunchChoice::Gui => entry.gui_file.clone(),
LaunchChoice::Server => entry.server_file.clone(),
};
Some((entry.python.clone(), entrypoint))
}
/// The currently selected entry, if any.
fn selected_entry(&self) -> Option<&'a LauncherEntry> {
let key = self.keys.get(self.list_state.selected()?)?;
self.config.get(*key)
}
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
let [list_area, detail_area] =
Layout::horizontal([Constraint::Percentage(30), Constraint::Min(0)]).areas(area);
let items: Vec<ListItem> = self.keys.iter().map(|k| ListItem::new(k.as_str())).collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Entries"))
.highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED))
.highlight_symbol("> ");
frame.render_stateful_widget(list, list_area, &mut self.list_state);
let detail = Block::default().borders(Borders::ALL).title("Details");
let text = match self.selected_entry() {
Some(entry) => Text::from(vec![
Line::from(format!("python: {}", entry.python.display())),
Line::from(format!("gui_file: {}", entry.gui_file.display())),
Line::from(format!("server_file: {}", entry.server_file.display())),
]),
None => Text::from("no entries"),
};
frame.render_widget(Paragraph::new(text).block(detail), detail_area);
if let Some(choice) = self.dialog {
self.draw_dialog(frame, area, choice);
}
}
fn draw_dialog(&self, frame: &mut Frame, area: Rect, choice: LaunchChoice) {
let popup = centered_rect(40, 30, area);
frame.render_widget(Clear, popup);
let selected = Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
let gui_style = if choice == LaunchChoice::Gui {
selected
} else {
Style::default()
};
let server_style = if choice == LaunchChoice::Server {
selected
} else {
Style::default()
};
let text = Text::from(vec![
Line::styled(" Launch GUI ", gui_style),
Line::styled(" Launch Server ", server_style),
Line::from(""),
Line::from("↑/↓ choose · Enter confirm · Esc cancel"),
]);
let block = Block::default().borders(Borders::ALL).title("Launch");
frame.render_widget(Paragraph::new(text).block(block), popup);
}
}
/// A rectangle centered within `area`, sized as a percentage of it.
fn centered_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect {
let [area] = Layout::vertical([Constraint::Percentage(height_pct)])
.flex(Flex::Center)
.areas(area);
let [area] = Layout::horizontal([Constraint::Percentage(width_pct)])
.flex(Flex::Center)
.areas(area);
area
}
+146
View File
@@ -0,0 +1,146 @@
mod custom;
mod entries;
use std::path::PathBuf;
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
use ratatui::{DefaultTerminal, Frame};
use crate::config::Config;
use custom::CustomTab;
use entries::EntriesTab;
const TAB_TITLES: [&str; 2] = ["Entries", "Custom"];
struct App<'a> {
/// Index of the currently selected tab.
selected_tab: usize,
entries: EntriesTab<'a>,
custom: CustomTab,
}
impl<'a> App<'a> {
fn new(config: &'a Config) -> Self {
App {
selected_tab: 0,
entries: EntriesTab::new(&config.entries),
custom: CustomTab::new(&config.apps_base, &config.venvs_base),
}
}
fn next_tab(&mut self) {
self.selected_tab = (self.selected_tab + 1) % TAB_TITLES.len();
}
fn previous_tab(&mut self) {
self.selected_tab = (self.selected_tab + TAB_TITLES.len() - 1) % TAB_TITLES.len();
}
}
/// Run the launcher TUI, returning the command the user chose (or `None` if
/// they quit without choosing).
pub fn run(config: &Config) -> Result<Option<(PathBuf, PathBuf)>> {
let mut terminal = ratatui::init();
let result = run_app(&mut terminal, config);
ratatui::restore();
result
}
fn run_app(
terminal: &mut DefaultTerminal,
config: &Config,
) -> Result<Option<(PathBuf, PathBuf)>> {
let mut app = App::new(config);
loop {
terminal.draw(|frame| draw(frame, &mut app))?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
// While the launch dialog is open it captures all input.
if app.entries.dialog_open() {
match key.code {
KeyCode::Enter => {
if let Some(command) = app.entries.confirm() {
return Ok(Some(command));
}
}
KeyCode::Esc => app.entries.cancel_dialog(),
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
app.entries.toggle_dialog()
}
_ => {}
}
continue;
}
// Global keys: quit and tab switching.
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
// Everything else is routed to the active tab.
_ => match app.selected_tab {
0 => match key.code {
KeyCode::Down => app.entries.next(),
KeyCode::Up => app.entries.previous(),
KeyCode::Enter => app.entries.open_dialog(),
_ => {}
},
_ => match key.code {
KeyCode::Left | KeyCode::Right => app.custom.toggle_focus(),
KeyCode::Down => app.custom.next(),
KeyCode::Up => app.custom.previous(),
_ => {}
},
},
}
}
}
}
fn draw(frame: &mut Frame, app: &mut App) {
let [tabs_area, content_area, footer_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(frame.area());
let tabs = Tabs::new(TAB_TITLES)
.block(
Block::default()
.borders(Borders::ALL)
.title("aare launcher"),
)
.select(app.selected_tab)
.highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED));
frame.render_widget(tabs, tabs_area);
match app.selected_tab {
0 => app.entries.draw(frame, content_area),
_ => app.custom.draw(frame, content_area),
}
let footer = Paragraph::new(help_text(app))
.style(Style::default().add_modifier(Modifier::DIM));
frame.render_widget(footer, footer_area);
}
/// Context-sensitive interaction hints shown in the footer.
fn help_text(app: &App) -> &'static str {
if app.entries.dialog_open() {
"↑/↓: choose Enter: launch Esc: cancel"
} else if app.selected_tab == 0 {
"Tab: switch tab ↑/↓: select Enter: launch q: quit"
} else {
"Tab: switch tab ←/→: switch list ↑/↓: select q: quit"
}
}