use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mecha10_core::enum_str_method;
use ratatui::{
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState},
Frame,
};
use std::path::{Path, PathBuf};
use super::tui_layout::{render_footer_hints, standard_tui_layout};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditableConfigCategory {
Node,
Behavior,
RlJob,
ProjectConfig,
SimulationAsset,
RlTask,
}
enum_str_method!(
fn tag(EditableConfigCategory) -> &str {
Node => "[node]",
Behavior => "[behavior]",
RlJob => "[rl-job]",
ProjectConfig => "[project]",
SimulationAsset => "[asset]",
RlTask => "[rl-task]",
}
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditableConfigEntry {
pub label: String,
pub category: EditableConfigCategory,
pub path: PathBuf,
}
const SIMULATION_ASSET_SUBDIRS: [(&str, &str); 2] = [("models", "model.json"), ("environments", "environment.json")];
pub fn discover_editable_configs(project_root: &Path) -> Vec<EditableConfigEntry> {
let mut entries = Vec::new();
let configs_dir = project_root.join(crate::paths::config::DIR);
entries.extend(discover_two_tier_category(
&configs_dir.join("nodes"),
EditableConfigCategory::Node,
));
entries.extend(discover_two_tier_category(
&configs_dir.join("behaviors"),
EditableConfigCategory::Behavior,
));
entries.extend(discover_rl_jobs(project_root));
let mecha10_json = project_root.join(crate::paths::PROJECT_CONFIG);
if mecha10_json.is_file() {
entries.push(EditableConfigEntry {
label: "mecha10.json (project config)".to_string(),
category: EditableConfigCategory::ProjectConfig,
path: mecha10_json,
});
}
entries.extend(discover_simulation_assets(project_root));
entries.extend(discover_rl_tasks(project_root));
entries
}
fn discover_two_tier_category(category_dir: &Path, category: EditableConfigCategory) -> Vec<EditableConfigEntry> {
let mut out = Vec::new();
let mut config_jsons = Vec::new();
collect_config_jsons(category_dir, &mut config_jsons);
config_jsons.sort();
for path in config_jsons {
let Ok(relative) = path.strip_prefix(category_dir) else {
continue;
};
let components: Vec<&str> = relative.components().filter_map(|c| c.as_os_str().to_str()).collect();
let label = match components.as_slice() {
[scope, name, "config.json"] if scope.starts_with('@') => format!("{scope}/{name}"),
[name, "config.json"] => name.to_string(),
_ => continue,
};
out.push(EditableConfigEntry { label, category, path });
}
out
}
fn collect_config_jsons(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(read_dir) = std::fs::read_dir(dir) else {
return;
};
for entry in read_dir.flatten() {
let path = entry.path();
if path.is_dir() {
collect_config_jsons(&path, out);
} else if path.file_name().and_then(|n| n.to_str()) == Some("config.json") {
out.push(path);
}
}
}
fn discover_rl_jobs(project_root: &Path) -> Vec<EditableConfigEntry> {
crate::services::train_jobs::load_job_catalog(project_root)
.unwrap_or_default()
.into_iter()
.map(|job| EditableConfigEntry {
label: job.name,
category: EditableConfigCategory::RlJob,
path: job.source_path,
})
.collect()
}
fn discover_simulation_assets(project_root: &Path) -> Vec<EditableConfigEntry> {
let mut out = Vec::new();
let simulation_dir = project_root.join(crate::paths::project::SIMULATION_DIR);
for (subdir, asset_file) in SIMULATION_ASSET_SUBDIRS {
let subdir_path = simulation_dir.join(subdir);
let Ok(read_dir) = std::fs::read_dir(&subdir_path) else {
continue;
};
let mut names: Vec<String> = read_dir
.flatten()
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| entry.file_name().to_str().map(|s| s.to_string()))
.collect();
names.sort();
for name in names {
let config_path = subdir_path.join(&name).join(asset_file);
if !config_path.is_file() {
continue;
}
out.push(EditableConfigEntry {
label: name,
category: EditableConfigCategory::SimulationAsset,
path: config_path,
});
}
}
out
}
fn discover_rl_tasks(project_root: &Path) -> Vec<EditableConfigEntry> {
let mut out = Vec::new();
let tasks_dir = project_root.join(crate::paths::project::RL_TASKS_DIR);
let Ok(read_dir) = std::fs::read_dir(&tasks_dir) else {
return out;
};
let mut names: Vec<String> = read_dir
.flatten()
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| entry.file_name().to_str().map(|s| s.to_string()))
.collect();
names.sort();
for name in names {
let config_path = tasks_dir.join(&name).join("task.json");
if !config_path.is_file() {
continue;
}
out.push(EditableConfigEntry {
label: name,
category: EditableConfigCategory::RlTask,
path: config_path,
});
}
out
}
pub enum ConfigEditOutcome {
Selected(PathBuf),
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConfigEditSortMode {
#[default]
Category,
Name,
}
enum_str_method!(
fn label(ConfigEditSortMode) -> &str {
Category => "category",
Name => "name",
}
);
impl ConfigEditSortMode {
fn next(self) -> Self {
match self {
Self::Category => Self::Name,
Self::Name => Self::Category,
}
}
}
const CATEGORY_CYCLE: [EditableConfigCategory; 6] = [
EditableConfigCategory::Node,
EditableConfigCategory::Behavior,
EditableConfigCategory::RlJob,
EditableConfigCategory::ProjectConfig,
EditableConfigCategory::SimulationAsset,
EditableConfigCategory::RlTask,
];
fn next_category_filter(current: Option<EditableConfigCategory>) -> Option<EditableConfigCategory> {
match current {
None => Some(CATEGORY_CYCLE[0]),
Some(category) => {
let index = CATEGORY_CYCLE.iter().position(|c| *c == category).unwrap();
CATEGORY_CYCLE.get(index + 1).copied()
}
}
}
pub struct ConfigEditTui {
entries: Vec<EditableConfigEntry>,
selected_index: usize,
should_quit: bool,
outcome: Option<ConfigEditOutcome>,
category_filter: Option<EditableConfigCategory>,
sort_mode: ConfigEditSortMode,
}
impl ConfigEditTui {
mecha10_core::new_with_defaults!(
pub fn new(entries: Vec<EditableConfigEntry>) -> Self {
selected_index: 0,
should_quit: false,
outcome: None,
category_filter: None,
sort_mode: ConfigEditSortMode::default()
}
);
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn take_outcome(&mut self) -> Option<ConfigEditOutcome> {
self.outcome.take()
}
fn visible_entries(&self) -> Vec<&EditableConfigEntry> {
let mut visible: Vec<&EditableConfigEntry> = self
.entries
.iter()
.filter(|entry| match self.category_filter {
None => true,
Some(category) => entry.category == category,
})
.collect();
if self.sort_mode == ConfigEditSortMode::Name {
visible.sort_by_key(|entry| entry.label.to_lowercase());
}
visible
}
fn clamp_selection(&mut self, visible_len: usize) {
self.selected_index = if visible_len == 0 {
0
} else {
self.selected_index.min(visible_len - 1)
};
}
pub fn draw(&mut self, f: &mut Frame) {
let (content_area, footer_area) = standard_tui_layout(f.area());
let visible = self.visible_entries();
let items: Vec<ListItem> = visible
.iter()
.enumerate()
.map(|(i, entry)| {
let is_selected = i == self.selected_index;
let style = if is_selected {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_selected { "â–¶ " } else { " " };
let formatted = format!("{}{} {}", prefix, entry.label, entry.category.tag());
ListItem::new(formatted).style(style)
})
.collect();
let title = format!(
" Editable configs ({}/{}) | sort: {}{} ",
visible.len(),
self.entries.len(),
self.sort_mode.label(),
match self.category_filter {
None => String::new(),
Some(category) => format!(" | filter: {}", category.tag()),
}
);
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Cyan)),
);
let mut state = ListState::default();
if !visible.is_empty() {
state.select(Some(self.selected_index));
}
f.render_stateful_widget(list, content_area, &mut state);
render_footer_hints(
f,
footer_area,
&[
("↑/↓", "Navigate", Color::Cyan),
("Enter", "Edit", Color::Green),
("/", "Filter: category", Color::Cyan),
("s", "Sort", Color::Cyan),
("Q/ESC", "Cancel", Color::Red),
],
);
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.outcome = Some(ConfigEditOutcome::Cancelled);
self.should_quit = true;
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.outcome = Some(ConfigEditOutcome::Cancelled);
self.should_quit = true;
}
KeyCode::Char('/') => {
self.category_filter = next_category_filter(self.category_filter);
self.clamp_selection(self.visible_entries().len());
}
KeyCode::Char('s') => {
self.sort_mode = self.sort_mode.next();
self.clamp_selection(self.visible_entries().len());
}
KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
KeyCode::Down => {
if self.selected_index < self.visible_entries().len().saturating_sub(1) {
self.selected_index += 1;
}
}
KeyCode::Enter => {
self.select_current();
}
_ => {}
}
}
fn select_current(&mut self) {
if let Some(entry) = self.visible_entries().get(self.selected_index) {
self.outcome = Some(ConfigEditOutcome::Selected(entry.path.clone()));
self.should_quit = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn discovers_framework_and_project_node_configs() {
let dir = tempdir().unwrap();
write_file(&dir.path().join("configs/nodes/@mecha10/motor/config.json"), "{}");
write_file(&dir.path().join("configs/nodes/camera/config.json"), "{}");
let entries = discover_editable_configs(dir.path());
let nodes: Vec<&EditableConfigEntry> = entries
.iter()
.filter(|e| e.category == EditableConfigCategory::Node)
.collect();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().any(|e| e.label == "@mecha10/motor"));
assert!(nodes.iter().any(|e| e.label == "camera"));
}
#[test]
fn discovers_framework_and_project_behavior_configs() {
let dir = tempdir().unwrap();
write_file(
&dir.path().join("configs/behaviors/@mecha10/idle_wander/config.json"),
"{}",
);
write_file(&dir.path().join("configs/behaviors/patrol/config.json"), "{}");
let entries = discover_editable_configs(dir.path());
let behaviors: Vec<&EditableConfigEntry> = entries
.iter()
.filter(|e| e.category == EditableConfigCategory::Behavior)
.collect();
assert_eq!(behaviors.len(), 2);
assert!(behaviors.iter().any(|e| e.label == "@mecha10/idle_wander"));
assert!(behaviors.iter().any(|e| e.label == "patrol"));
}
#[test]
fn discovers_rl_job_configs() {
let dir = tempdir().unwrap();
write_file(
&dir.path().join("configs/rl/jobs/train.yml"),
"name: train\nflow_type: train\nconfig: local\n",
);
let entries = discover_editable_configs(dir.path());
let jobs: Vec<&EditableConfigEntry> = entries
.iter()
.filter(|e| e.category == EditableConfigCategory::RlJob)
.collect();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].label, "train");
assert_eq!(jobs[0].path, dir.path().join("configs/rl/jobs/train.yml"));
}
#[test]
fn includes_mecha10_json_as_project_config() {
let dir = tempdir().unwrap();
write_file(
&dir.path().join("mecha10.json"),
r#"{"name": "proj", "version": "0.1.0", "robot_id": "r"}"#,
);
let entries = discover_editable_configs(dir.path());
let project: Vec<&EditableConfigEntry> = entries
.iter()
.filter(|e| e.category == EditableConfigCategory::ProjectConfig)
.collect();
assert_eq!(project.len(), 1);
assert_eq!(project[0].label, "mecha10.json (project config)");
assert_eq!(project[0].path, dir.path().join("mecha10.json"));
}
#[test]
fn discovers_simulation_assets_across_models_and_environments() {
let dir = tempdir().unwrap();
write_file(&dir.path().join("configs/simulation/models/rover/model.json"), "{}");
write_file(
&dir.path()
.join("configs/simulation/environments/basic_arena/environment.json"),
"{}",
);
let entries = discover_editable_configs(dir.path());
let assets: Vec<&EditableConfigEntry> = entries
.iter()
.filter(|e| e.category == EditableConfigCategory::SimulationAsset)
.collect();
assert_eq!(assets.len(), 2);
assert!(assets.iter().any(|e| e.label == "rover"));
assert!(assets.iter().any(|e| e.label == "basic_arena"));
}
#[test]
fn discovers_rl_tasks_under_configs_rl_tasks_not_configs_simulation_tasks() {
let dir = tempdir().unwrap();
write_file(&dir.path().join("configs/rl/tasks/task1/task.json"), "{}");
write_file(&dir.path().join("configs/simulation/tasks/task2/task.json"), "{}");
let entries = discover_editable_configs(dir.path());
let tasks: Vec<&EditableConfigEntry> = entries
.iter()
.filter(|e| e.category == EditableConfigCategory::RlTask)
.collect();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].label, "task1");
assert!(
entries
.iter()
.all(|e| e.category != EditableConfigCategory::SimulationAsset),
"configs/simulation/tasks/ is not a real asset tree and must not be discovered"
);
}
#[test]
fn simulation_asset_directory_without_canonical_file_is_skipped() {
let dir = tempdir().unwrap();
write_file(&dir.path().join("configs/simulation/models/broken/readme.txt"), "n/a");
let entries = discover_editable_configs(dir.path());
assert!(entries
.iter()
.all(|e| e.category != EditableConfigCategory::SimulationAsset));
}
#[test]
fn returns_empty_when_nothing_editable_exists() {
let dir = tempdir().unwrap();
let entries = discover_editable_configs(dir.path());
assert!(entries.is_empty());
}
fn entry(label: &str, category: EditableConfigCategory, path: &str) -> EditableConfigEntry {
EditableConfigEntry {
label: label.to_string(),
category,
path: PathBuf::from(path),
}
}
fn sample_entries() -> Vec<EditableConfigEntry> {
vec![
entry(
"camera",
EditableConfigCategory::Node,
"configs/nodes/camera/config.json",
),
entry(
"patrol",
EditableConfigCategory::Behavior,
"configs/behaviors/patrol/config.json",
),
entry("train", EditableConfigCategory::RlJob, "configs/rl/jobs/train.yml"),
]
}
#[test]
fn category_filter_shows_only_entries_in_that_category() {
let mut tui = ConfigEditTui::new(sample_entries());
tui.category_filter = Some(EditableConfigCategory::Behavior);
let visible = tui.visible_entries();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].label, "patrol");
}
#[test]
fn no_category_filter_shows_everything_in_discovery_order() {
let tui = ConfigEditTui::new(sample_entries());
let visible = tui.visible_entries();
assert_eq!(
visible.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(),
vec!["camera", "patrol", "train"]
);
}
#[test]
fn name_sort_orders_alphabetically_regardless_of_category() {
let mut tui = ConfigEditTui::new(sample_entries());
tui.sort_mode = ConfigEditSortMode::Name;
let visible = tui.visible_entries();
assert_eq!(
visible.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(),
vec!["camera", "patrol", "train"]
);
}
#[test]
fn sort_mode_cycles_between_category_and_name() {
assert_eq!(ConfigEditSortMode::Category.next(), ConfigEditSortMode::Name);
assert_eq!(ConfigEditSortMode::Name.next(), ConfigEditSortMode::Category);
}
#[test]
fn slash_cycles_through_categories_present_and_back_to_all() {
let mut tui = ConfigEditTui::new(sample_entries());
tui.handle_key(KeyEvent::from(KeyCode::Char('/')));
assert_eq!(tui.category_filter, Some(EditableConfigCategory::Node));
tui.handle_key(KeyEvent::from(KeyCode::Char('/')));
assert_eq!(tui.category_filter, Some(EditableConfigCategory::Behavior));
for _ in 0..(CATEGORY_CYCLE.len() - 2) {
tui.handle_key(KeyEvent::from(KeyCode::Char('/')));
}
assert_eq!(tui.category_filter, Some(*CATEGORY_CYCLE.last().unwrap()));
tui.handle_key(KeyEvent::from(KeyCode::Char('/')));
assert_eq!(tui.category_filter, None);
}
#[test]
fn q_is_always_cancel_now_that_there_is_no_text_filter_mode() {
let mut tui = ConfigEditTui::new(sample_entries());
tui.handle_key(KeyEvent::from(KeyCode::Char('q')));
assert!(tui.should_quit());
match tui.take_outcome() {
Some(ConfigEditOutcome::Cancelled) => {}
_ => panic!("expected a Cancelled outcome"),
}
}
#[test]
fn selecting_an_entry_while_category_filtered_returns_the_filtered_entrys_path_not_the_raw_index() {
let mut tui = ConfigEditTui::new(sample_entries());
tui.category_filter = Some(EditableConfigCategory::RlJob);
tui.handle_key(KeyEvent::from(KeyCode::Enter));
match tui.take_outcome() {
Some(ConfigEditOutcome::Selected(path)) => {
assert_eq!(path, PathBuf::from("configs/rl/jobs/train.yml"));
}
_ => panic!("expected a Selected outcome"),
}
}
}