esp-config TUI (#3649)

* Introduce esp-config TUI

* Validate more

* Improve chip-selection, support alternate config.toml files

* Minor improvements

* Clippy --fix

* Do crate-level checks on every change

* de-dup

* Address review comments

* Address review comments

* Remove the safety-belt
This commit is contained in:
Björn Quentin 2025-07-01 17:14:26 +02:00 committed by GitHub
parent 8c4878cb03
commit 8fffa6ccec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1076 additions and 7 deletions

View File

@ -169,7 +169,7 @@ jobs:
- run: cargo xtask fmt-packages --check
# Run tests in esp-config
- run: cd esp-config && cargo test --features build
- run: cd esp-config && cargo test --features build,tui
# Run tests in esp-bootloader-esp-idf
- run: cd esp-bootloader-esp-idf && cargo test --features=std

View File

@ -12,13 +12,29 @@ license = "MIT OR Apache-2.0"
bench = false
test = true
[[bin]]
name = "esp-config"
required-features = ["tui"]
[dependencies]
document-features = "0.2.11"
# used by the `build` and `tui` feature
serde = { version = "1.0.197", default-features = false, features = ["derive"], optional = true }
serde_yaml = { version = "0.9", optional = true }
evalexpr = { version = "12.0.2", optional = true }
esp-metadata = { version = "0.7.0", path = "../esp-metadata", default-features = true, optional = true }
# used by the `tui` feature
clap = { version = "4.5.32", features = ["derive"], optional = true }
crossterm = { version = "0.28.1", optional = true }
env_logger = { version = "0.11.7", optional = true }
log = { version = "0.4.26", optional = true }
ratatui = { version = "0.29.0", features = ["crossterm", "unstable"], optional = true }
toml_edit = { version = "0.22.26", optional = true }
tui-textarea = { version = "0.7.0", optional = true }
cargo_metadata = { version = "0.19.2", optional = true }
[dev-dependencies]
temp-env = "0.3.6"
pretty_assertions = "1.4.1"
@ -26,3 +42,17 @@ pretty_assertions = "1.4.1"
[features]
## Enable the generation and parsing of a config
build = ["dep:serde", "dep:serde_yaml", "dep:evalexpr", "dep:esp-metadata"]
## The TUI
tui = [
"dep:clap",
"dep:crossterm",
"dep:env_logger",
"dep:log",
"dep:ratatui",
"dep:toml_edit",
"dep:tui-textarea",
"dep:cargo_metadata",
"esp-metadata?/clap",
"build",
]

View File

@ -0,0 +1,268 @@
use std::{
collections::HashMap,
error::Error,
path::{Path, PathBuf},
str::FromStr,
};
use clap::Parser;
use env_logger::{Builder, Env};
use esp_config::{ConfigOption, Value};
use serde::Deserialize;
use toml_edit::{DocumentMut, Formatted, Item, Table};
mod tui;
const DEFAULT_CONFIG_PATH: &str = ".cargo/config.toml";
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Root of the project
#[arg(short = 'P', long)]
path: Option<PathBuf>,
/// Chip
#[arg(short = 'C', long)]
chip: Option<esp_metadata::Chip>,
/// Config file - using `.cargo/config.toml` by default
#[arg(short = 'c', long)]
config_file: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrateConfig {
name: String,
options: Vec<ConfigItem>,
checks: Option<Vec<String>>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
struct ConfigItem {
option: ConfigOption,
actual_value: Value,
}
fn main() -> Result<(), Box<dyn Error>> {
Builder::from_env(Env::default().default_filter_or(log::LevelFilter::Info.as_str()))
.format_target(false)
.init();
let args = Args::parse();
let work_dir = args.path.clone().unwrap_or(".".into());
let config_file_path =
work_dir.join(args.config_file.as_deref().unwrap_or(DEFAULT_CONFIG_PATH));
if !config_file_path.exists() {
return Err(format!(
"Config file {} does not exist or is not readable.",
config_file_path.display()
)
.into());
}
let configs = parse_configs(&work_dir, args.chip, args.config_file.as_deref())?;
let initial_configs = configs.clone();
let previous_config = initial_configs.clone();
let repository = tui::Repository::new(configs.clone());
// TUI stuff ahead
let terminal = tui::init_terminal()?;
// create app and run it
let updated_cfg = tui::App::new(None, repository).run(terminal)?;
tui::restore_terminal()?;
// done with the TUI
if let Some(updated_cfg) = updated_cfg {
apply_config(
&work_dir,
updated_cfg,
previous_config,
args.config_file.clone(),
)?;
}
Ok(())
}
fn apply_config(
path: &Path,
updated_cfg: Vec<CrateConfig>,
previous_cfg: Vec<CrateConfig>,
cfg_file: Option<String>,
) -> Result<(), Box<dyn Error>> {
let config_toml_path = path.join(cfg_file.as_deref().unwrap_or(DEFAULT_CONFIG_PATH));
let mut config = std::fs::read_to_string(&config_toml_path)?
.as_str()
.parse::<DocumentMut>()?;
if !config.contains_key("env") {
config.insert("env", Item::Table(Table::new()));
}
let envs = config.get_mut("env").unwrap().as_table_mut().unwrap();
for cfg in updated_cfg {
let previous_crate_cfg = previous_cfg.iter().find(|c| c.name == cfg.name);
for option in cfg.options {
let previous_option = previous_crate_cfg.and_then(|c| {
c.options
.iter()
.find(|o| o.option.name == option.option.name)
});
let key = option.option.full_env_var(&cfg.name);
// avoid updating unchanged options to keep the comments (if any)
if Some(&option.actual_value) != previous_option.map(|option| &option.actual_value) {
if option.actual_value != option.option.default_value {
let value = toml_edit::Value::String(Formatted::new(format!(
"{}",
option.actual_value
)));
envs.insert(&key, Item::Value(value));
} else {
envs.remove(&key);
}
}
}
}
std::fs::write(&config_toml_path, config.to_string().as_bytes())?;
Ok(())
}
fn parse_configs(
path: &Path,
chip_from_args: Option<esp_metadata::Chip>,
config_file: Option<&str>,
) -> Result<Vec<CrateConfig>, Box<dyn Error>> {
let config_toml_path = path.join(config_file.as_deref().unwrap_or(DEFAULT_CONFIG_PATH));
let config_toml_content = std::fs::read_to_string(config_toml_path)?;
let config_toml = config_toml_content.as_str().parse::<DocumentMut>()?;
let envs: HashMap<String, String> = config_toml
.get("env")
.and_then(Item::as_table)
.map(|table| {
table
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string())))
.collect()
})
.unwrap_or_default();
// Get the metadata of the project to
// - discover configurable crates
// - get the active features on crates (e.g. to guess the chip the project is
// targeting)
// this might fetch the dependencies from registries and/or git repositories
// so this
// - might take a few seconds (while it's usually very quick)
// - might need an internet connection to succeed
let meta = cargo_metadata::MetadataCommand::new()
.current_dir(path)
.verbose(true) // entertain the user by showing what exactly is going on
.exec()
// with verbose output the error we get doesn't contain anything useful or interesting
.map_err(|_| "`cargo metadata` failed for your project. Make sure it's buildable.")?;
// try to guess the chip from the metadata by looking at an active chip feature
// for esp-hal
let chip_from_meta = || {
let mut chip = None;
for pkg in &meta.root_package().unwrap().dependencies {
if pkg.name == "esp-hal" {
let possible_chip_feature_matches: Vec<esp_metadata::Chip> = pkg
.features
.iter()
.flat_map(|f| esp_metadata::Chip::from_str(f))
.collect::<Vec<esp_metadata::Chip>>();
chip = possible_chip_feature_matches.first().cloned();
}
}
chip
};
// the "ESP_CONFIG_CHIP" hint env-var if present
let chip_from_config = || {
envs.get("ESP_CONFIG_CHIP")
.and_then(|chip_str| clap::ValueEnum::from_str(chip_str, true).ok())
};
// - if given as a parameter, use it
// - if there is a hint in the config.toml, use it
// - if we can infer it from metadata, use it
// otherwise, fail
let chip = chip_from_args
.or_else(chip_from_config)
.or_else(chip_from_meta);
if chip.is_none() {
return Err("No chip given or inferred. Try using the `--chip` argument.".into());
}
let mut configs = Vec::new();
let chip = esp_metadata::Config::for_chip(chip.as_ref().unwrap());
let features = vec![];
for krate in meta.packages {
let maybe_cfg = krate.manifest_path.parent().unwrap().join("esp_config.yml");
if maybe_cfg.exists() {
let yaml = std::fs::read_to_string(&maybe_cfg)?;
let (cfg, options) =
esp_config::evaluate_yaml_config(&yaml, Some(chip.clone()), features.clone(), true)
.map_err(|e| {
format!(
"Error evaluating YAML config for crate {}: {}",
krate.name, e
)
})?;
let crate_name = cfg.krate.clone();
let options: Vec<ConfigItem> = options
.iter()
.map(|cfg| {
Ok::<ConfigItem, Box<dyn Error>>(ConfigItem {
option: cfg.clone(),
actual_value: {
let key = cfg.full_env_var(&crate_name);
let def_val = &cfg.default_value.to_string();
let val = envs.get(&key).unwrap_or(def_val);
let mut parsed_val = cfg.default_value.clone();
parsed_val.parse_in_place(val).map_err(|_| {
<std::string::String as Into<Box<dyn Error>>>::into(format!(
"Unable to parse '{val}' for option '{}'",
&cfg.name
))
})?;
parsed_val
},
})
})
.collect::<Result<Vec<ConfigItem>, Box<dyn Error>>>()?;
configs.push(CrateConfig {
name: crate_name.clone(),
checks: cfg.checks.clone(),
options,
});
}
}
if configs.is_empty() {
return Err("No config files found.".into());
}
Ok(configs)
}

View File

@ -0,0 +1,754 @@
use std::{collections::HashMap, error::Error, io};
use crossterm::{
ExecutableCommand,
event::{self, Event, KeyCode, KeyEventKind},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use esp_config::{DisplayHint, Stability, Validator, Value};
use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
use tui_textarea::{CursorMove, TextArea};
use crate::CrateConfig;
type AppResult<T> = Result<T, Box<dyn Error>>;
pub struct Repository {
configs: Vec<crate::CrateConfig>,
current_crate: Option<usize>,
}
enum Item {
TopLevel(String),
CrateLevel(crate::ConfigItem),
}
impl Item {
fn title(&self, _width: u16, ui_elements: &UiElements) -> String {
match self {
Item::TopLevel(crate_name) => crate_name.clone(),
Item::CrateLevel(config_option) => {
let display_value = format_using_display_hint(
&config_option.actual_value,
&config_option.option.display_hint,
);
let default_indicator =
if config_option.actual_value == config_option.option.default_value {
ui_elements.default_value
} else {
""
};
let unstable_indicator = if config_option.option.stability == Stability::Unstable {
ui_elements.unstable
} else {
""
};
format!(
"{} ({}{}){}",
config_option.option.name, display_value, default_indicator, unstable_indicator
)
}
}
}
fn help_text(&self) -> String {
match self {
Item::TopLevel(crate_name) => format!("The `{crate_name}` crate"),
Item::CrateLevel(config_option) => config_option.option.description.clone(),
}
.replace("<p>", "")
.replace("</p>", "\n")
.to_string()
}
fn value(&self) -> Value {
match self {
Item::TopLevel(_) => unreachable!(),
Item::CrateLevel(config_option) => config_option.actual_value.clone(),
}
}
fn constraint(&self) -> Option<Validator> {
match self {
Item::TopLevel(_) => unreachable!(),
Item::CrateLevel(config_option) => config_option.option.constraint.clone(),
}
}
fn display_hint(&self) -> DisplayHint {
match self {
Item::TopLevel(_) => unreachable!(),
Item::CrateLevel(config_option) => config_option.option.display_hint.clone(),
}
}
}
fn format_using_display_hint(value: &Value, hint: &DisplayHint) -> String {
match value {
Value::Bool(b) => b.to_string(),
Value::Integer(i) => match hint {
DisplayHint::None => format!("{}", i),
DisplayHint::Binary => format!("0b{:0b}", i),
DisplayHint::Hex => format!("0x{:x}", i),
DisplayHint::Octal => format!("0o{:o}", i),
},
Value::String(s) => s.clone(),
}
}
impl Repository {
pub fn new(options: Vec<crate::CrateConfig>) -> Self {
Self {
configs: options,
current_crate: None,
}
}
fn current_level(&self) -> Vec<Item> {
if self.current_crate.is_none() {
Vec::from_iter(
self.configs
.iter()
.map(|config| Item::TopLevel(config.name.clone())),
)
} else {
Vec::from_iter(
self.configs[self.current_crate.unwrap()]
.options
.iter()
.map(|option| Item::CrateLevel(option.clone())),
)
}
}
fn enter_group(&mut self, index: usize) {
if self.current_crate.is_none() {
self.current_crate = Some(index);
}
}
fn up(&mut self) {
if self.current_crate.is_some() {
self.current_crate = None;
}
}
fn set_current(&mut self, index: usize, new_value: Value) -> Result<(), String> {
if self.current_crate.is_none() {
return Ok(());
}
let crate_config = &mut self.configs[self.current_crate.unwrap()];
let previous = crate_config.options[index].actual_value.clone();
crate_config.options[index].actual_value = new_value;
let res = validate_config(crate_config);
if let Err(error) = res {
crate_config.options[index].actual_value = previous;
return Err(error.to_string());
}
Ok(())
}
// true if this is a configurable option
fn is_option(&self, _index: usize) -> bool {
self.current_crate.is_some()
}
// What to show in the list
fn current_level_desc(&self, width: u16, ui_elements: &UiElements) -> Vec<String> {
let level = self.current_level();
level.iter().map(|v| v.title(width, ui_elements)).collect()
}
}
pub fn init_terminal() -> AppResult<Terminal<impl Backend>> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn restore_terminal() -> AppResult<()> {
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
struct UiElements {
default_value: &'static str,
unstable: &'static str,
popup_highlight_symbol: &'static str,
}
struct Colors {
header_bg: Color,
normal_row_color: Color,
help_row_color: Color,
text_color: Color,
selected_active_style: Style,
edit_invalid_style: Style,
edit_valid_style: Style,
border_style: Style,
border_error_style: Style,
}
impl Colors {
const RGB: Self = Self {
header_bg: tailwind::BLUE.c950,
normal_row_color: tailwind::SLATE.c950,
help_row_color: tailwind::SLATE.c800,
text_color: tailwind::SLATE.c200,
selected_active_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(tailwind::SLATE.c200)
.bg(tailwind::BLUE.c950),
edit_invalid_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red),
edit_valid_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(tailwind::SLATE.c200),
border_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(Color::LightBlue),
border_error_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red),
};
const ANSI: Self = Self {
header_bg: Color::DarkGray,
normal_row_color: Color::Black,
help_row_color: Color::DarkGray,
text_color: Color::Gray,
selected_active_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(Color::White)
.bg(Color::Blue),
edit_invalid_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red),
edit_valid_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Gray),
border_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(Color::LightBlue),
border_error_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red),
};
}
impl UiElements {
const FANCY: Self = Self {
default_value: "",
unstable: " 🚧",
popup_highlight_symbol: "▶️ ",
};
const FALLBACK: Self = Self {
default_value: " *",
unstable: " !",
popup_highlight_symbol: "> ",
};
}
pub struct App<'a> {
repository: Repository,
state: Vec<ListState>,
confirm_quit: bool,
editing: bool,
textarea: TextArea<'a>,
editing_constraints: Option<Validator>,
input_valid: bool,
showing_selection_popup: bool,
list_popup: List<'a>,
list_popup_state: ListState,
show_error_message: bool,
initial_message: Option<String>,
ui_elements: UiElements,
colors: Colors,
}
impl App<'_> {
pub fn new(errors_to_show: Option<String>, repository: Repository) -> Self {
let (ui_elements, colors) = match std::env::var("TERM_PROGRAM").as_deref() {
Ok("vscode") => (UiElements::FALLBACK, Colors::RGB),
Ok("Apple_Terminal") => (UiElements::FALLBACK, Colors::ANSI),
_ => (UiElements::FANCY, Colors::RGB),
};
let mut initial_state = ListState::default();
initial_state.select(Some(0));
Self {
repository,
state: vec![initial_state],
confirm_quit: false,
editing: false,
textarea: TextArea::default(),
editing_constraints: None,
input_valid: true,
showing_selection_popup: false,
list_popup: List::default(),
list_popup_state: ListState::default(),
show_error_message: errors_to_show.is_some(),
initial_message: errors_to_show,
ui_elements,
colors,
}
}
pub fn selected(&self) -> usize {
if let Some(current) = self.state.last() {
current.selected().unwrap_or_default()
} else {
0
}
}
pub fn select_next(&mut self) {
if let Some(current) = self.state.last_mut() {
current.select_next();
}
}
pub fn select_previous(&mut self) {
if let Some(current) = self.state.last_mut() {
current.select_previous();
}
}
pub fn enter_menu(&mut self) {
let mut new_state = ListState::default();
new_state.select(Some(0));
self.state.push(new_state);
}
pub fn exit_menu(&mut self) {
if self.state.len() > 1 {
self.state.pop();
}
}
}
impl App<'_> {
pub fn run(
&mut self,
mut terminal: Terminal<impl Backend>,
) -> AppResult<Option<Vec<crate::CrateConfig>>> {
loop {
self.draw(&mut terminal)?;
if let Event::Key(key) = event::read()? {
if self.editing {
match key.code {
KeyCode::Enter if key.kind == KeyEventKind::Press => {
if !self.input_valid {
continue;
}
let selected = self.selected();
if self.repository.is_option(selected) {
let current = self.repository.current_level()[selected].value();
let text = self.textarea.lines().join("").to_string();
let mut value = current.clone();
if value.parse_in_place(&text).is_ok() {
let set_res = self.repository.set_current(selected, value);
self.handle_error(set_res);
} else {
self.handle_error(Err("Invalid value".to_string()));
}
}
self.editing = false;
}
KeyCode::Esc => {
self.editing = false;
}
_ => {
if self.textarea.input(key) {
let selected = self.selected();
if self.repository.is_option(selected) {
let current = self.repository.current_level()[selected].value();
let text = self.textarea.lines().join("").to_string();
let mut parsed_value = current.clone();
let parse_res = parsed_value.parse_in_place(&text);
let validator_failed = if let Some(constraint) =
&self.editing_constraints
{
match parse_res {
Ok(()) => constraint.validate(&parsed_value).is_err(),
_ => false,
}
} else {
false
};
let invalid = parse_res.is_err() || validator_failed;
self.textarea.set_style(if invalid {
self.colors.edit_invalid_style
} else {
self.colors.edit_valid_style
});
self.input_valid = !invalid;
}
}
}
}
} else if self.showing_selection_popup && key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.showing_selection_popup = false;
}
KeyCode::Down | KeyCode::Char('j') => self.list_popup_state.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.list_popup_state.select_previous(),
KeyCode::Enter if key.kind == KeyEventKind::Press => {
let selected = self.selected();
if let Some(Validator::Enumeration(items)) = &self.repository.configs
[self.repository.current_crate.unwrap()]
.options[selected]
.option
.constraint
{
let set_res = self.repository.set_current(
selected,
Value::String(
items[self.list_popup_state.selected().unwrap()].clone(),
),
);
self.handle_error(set_res);
}
self.showing_selection_popup = false;
}
_ => (),
}
} else if self.show_error_message {
match key.code {
KeyCode::Enter if key.kind == KeyEventKind::Press => {
self.show_error_message = false;
}
_ => (),
}
} else if key.kind == KeyEventKind::Press {
use KeyCode::*;
if self.confirm_quit {
match key.code {
Char('y') | Char('Y') => return Ok(None),
_ => self.confirm_quit = false,
}
continue;
}
match key.code {
Char('q') => self.confirm_quit = true,
Char('s') | Char('S') => return Ok(Some(self.repository.configs.clone())),
Esc => {
if self.state.len() == 1 {
self.confirm_quit = true;
} else {
self.repository.up();
self.exit_menu();
}
}
Char('h') | Left => {
self.repository.up();
self.exit_menu();
}
Char('l') | Char(' ') | Right | Enter => {
let selected = self.selected();
if self.repository.is_option(selected) {
let current = self.repository.current_level()[selected].value();
let constraint =
self.repository.current_level()[selected].constraint();
match current {
Value::Bool(value) => {
let set_res = self
.repository
.set_current(selected, Value::Bool(!value));
self.handle_error(set_res);
}
Value::Integer(_) => {
let display_value = format_using_display_hint(
&current,
&self.repository.current_level()[selected]
.display_hint(),
);
self.textarea =
make_text_area(&display_value, &self.colors);
self.editing_constraints = constraint;
self.editing = true;
}
Value::String(s) => match constraint {
Some(Validator::Enumeration(items)) => {
let selected_option =
items.iter().position(|v| *v == s);
self.list_popup =
make_popup(items, &self.ui_elements, &self.colors);
self.list_popup_state = ListState::default();
self.list_popup_state.select(selected_option);
self.showing_selection_popup = true;
}
_ => {
self.textarea = make_text_area(&s, &self.colors);
self.editing_constraints = None;
self.editing = true;
}
},
}
} else {
self.repository.enter_group(self.selected());
self.enter_menu();
}
}
Char('j') | Down => {
self.select_next();
}
Char('k') | Up => {
self.select_previous();
}
_ => {}
}
}
}
}
}
fn draw(&mut self, terminal: &mut Terminal<impl Backend>) -> AppResult<()> {
terminal.draw(|f| {
f.render_widget(self, f.area());
})?;
Ok(())
}
fn handle_error(&mut self, result: Result<(), String>) {
if let Err(error) = result {
self.show_error_message = true;
self.initial_message = Some(error);
}
}
}
fn make_text_area<'a>(s: &str, colors: &Colors) -> TextArea<'a> {
let mut text_area = TextArea::new(vec![s.to_string()]);
text_area.set_block(
Block::default()
.borders(Borders::ALL)
.border_style(colors.border_style)
.title("Input"),
);
text_area.set_style(colors.edit_valid_style);
text_area.set_cursor_line_style(Style::default());
text_area.move_cursor(CursorMove::End);
text_area
}
fn make_popup<'a>(items: Vec<String>, ui_elements: &UiElements, colors: &Colors) -> List<'a> {
List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(colors.border_style)
.title("Choose"),
)
.highlight_style(colors.selected_active_style)
.highlight_symbol(ui_elements.popup_highlight_symbol)
.repeat_highlight_symbol(true)
}
impl Widget for &mut App<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let vertical = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(self.help_lines(area)),
Constraint::Length(self.footer_lines(area)),
]);
let [header_area, rest_area, help_area, footer_area] = vertical.areas(area);
self.render_title(header_area, buf);
self.render_item(rest_area, buf);
self.render_help(help_area, buf);
self.render_footer(footer_area, buf);
if self.editing {
let area = Rect {
x: 5,
y: area.height / 2 - 2,
width: area.width - 10,
height: 3,
};
ratatui::widgets::Clear.render(area, buf);
self.textarea.render(area, buf);
}
if self.showing_selection_popup {
let area = Rect {
x: 5,
y: area.height / 2 - 3,
width: area.width - 10,
height: 6,
};
ratatui::widgets::Clear.render(area, buf);
StatefulWidget::render(&self.list_popup, area, buf, &mut self.list_popup_state);
}
if self.show_error_message {
let area = Rect {
x: 5,
y: area.height / 2 - 5,
width: area.width - 10,
height: 5,
};
let block = Paragraph::new(self.initial_message.as_ref().unwrap().clone())
.style(self.colors.edit_invalid_style)
.block(
Block::bordered()
.title("Validation Error")
.style(self.colors.border_error_style)
.padding(Padding::uniform(1)),
);
ratatui::widgets::Clear.render(area, buf);
block.render(area, buf);
}
}
}
impl App<'_> {
fn render_title(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new("esp-config")
.bold()
.centered()
.render(area, buf);
}
fn render_item(&mut self, area: Rect, buf: &mut Buffer) {
// We create two blocks, one is for the header (outer) and the other is for the
// list (inner).
let outer_block = Block::default()
.borders(Borders::NONE)
.fg(self.colors.text_color)
.bg(self.colors.header_bg)
.title_alignment(Alignment::Center);
let inner_block = Block::default()
.borders(Borders::NONE)
.fg(self.colors.text_color)
.bg(self.colors.normal_row_color);
// We get the inner area from outer_block. We'll use this area later to render
// the table.
let outer_area = area;
let inner_area = outer_block.inner(outer_area);
// We can render the header in outer_area.
outer_block.render(outer_area, buf);
// Iterate through all elements in the `items` and stylize them.
let items: Vec<ListItem> = self
.repository
.current_level_desc(area.width, &self.ui_elements)
.into_iter()
.map(|value| ListItem::new(value).style(Style::default()))
.collect();
// We can now render the item list
// (look carefully, we are using StatefulWidget's render.)
// ratatui::widgets::StatefulWidget::render as stateful_render
let current_state = self
.state
.last_mut()
.expect("State should always have at least one element");
let list_widget = List::new(items)
.block(inner_block)
.highlight_style(self.colors.selected_active_style)
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(list_widget, inner_area, buf, current_state);
}
fn help_paragraph(&self) -> Option<Paragraph<'_>> {
let selected = self
.selected()
.min(self.repository.current_level().len() - 1);
let option = &self.repository.current_level()[selected];
let help_text = option.help_text();
if help_text.is_empty() {
return None;
}
let help_block = Block::default()
.borders(Borders::NONE)
.fg(self.colors.text_color)
.bg(self.colors.help_row_color);
Some(
Paragraph::new(help_text)
.centered()
.wrap(Wrap { trim: false })
.block(help_block),
)
}
fn help_lines(&self, area: Rect) -> u16 {
if let Some(paragraph) = self.help_paragraph() {
paragraph.line_count(area.width) as u16
} else {
0
}
}
fn render_help(&self, area: Rect, buf: &mut Buffer) {
if let Some(paragraph) = self.help_paragraph() {
paragraph.render(area, buf);
}
}
fn footer_paragraph(&self) -> Paragraph<'_> {
let text = if self.confirm_quit {
"Are you sure you want to quit? (y/N)"
} else if self.editing {
"ENTER to confirm, ESC to cancel"
} else if self.showing_selection_popup {
"Use ↓↑ to move, ENTER to confirm, ESC to cancel"
} else if self.show_error_message {
"ENTER to confirm"
} else {
"Use ↓↑ to move, ESC/← to go up, → to go deeper or change the value, s/S to save and generate, ESC/q to cancel"
};
Paragraph::new(text).centered().wrap(Wrap { trim: false })
}
fn footer_lines(&self, area: Rect) -> u16 {
self.footer_paragraph().line_count(area.width) as u16
}
fn render_footer(&self, area: Rect, buf: &mut Buffer) {
self.footer_paragraph().render(area, buf);
}
}
pub(super) fn validate_config(config: &CrateConfig) -> Result<(), String> {
let cfg: HashMap<String, Value> = config
.options
.iter()
.map(|option| {
(
option.option.full_env_var(&config.name),
option.actual_value.clone(),
)
})
.collect();
if let Err(error) = esp_config::do_checks(config.checks.as_ref(), &cfg) {
return Err(error.to_string());
}
Ok(())
}

View File

@ -150,7 +150,14 @@ pub fn generate_config_from_yaml_definition(
let cfg = generate_config(&config.krate, &options, enable_unstable, emit_md_tables);
if let Some(checks) = config.checks {
do_checks(config.checks.as_ref(), &cfg)?;
Ok(cfg)
}
/// Check the given actual values by applying checking the given checks
pub fn do_checks(checks: Option<&Vec<String>>, cfg: &HashMap<String, Value>) -> Result<(), Error> {
if let Some(checks) = checks {
let mut eval_ctx = evalexpr::HashMapContext::<I128NumericTypes>::new();
for (k, v) in cfg.iter() {
eval_ctx
@ -165,16 +172,15 @@ pub fn generate_config_from_yaml_definition(
.map_err(|err| Error::Parse(format!("Error setting value for {k} ({err})")))?;
}
for check in checks {
if !evalexpr::eval_with_context(&check, &eval_ctx)
if !evalexpr::eval_with_context(check, &eval_ctx)
.and_then(|v| v.as_boolean())
.map_err(|err| Error::Validation(format!("Validation error: '{check}' ({err})")))?
{
return Err(Error::Validation(format!("Validation error: '{check}'")));
}
}
}
Ok(cfg)
};
Ok(())
}
/// Evaluate the given YAML representation of a config definition.
@ -444,6 +450,9 @@ pub enum DisplayHint {
/// Use a hexadecimal representation
Hex,
/// Use a octal representation
Octal,
}
/// A configuration option.
@ -481,6 +490,11 @@ pub struct ConfigOption {
}
impl ConfigOption {
/// Get the corresponding ENV_VAR name given the crate-name
pub fn full_env_var(&self, crate_name: &str) -> String {
self.env_var(&format!("{}_CONFIG_", screaming_snake_case(crate_name)))
}
fn env_var(&self, prefix: &str) -> String {
format!("{}{}", prefix, screaming_snake_case(&self.name))
}

View File

@ -81,7 +81,8 @@ impl<'de> Deserialize<'de> for Value {
// TODO: Do we want to handle negative values for non-decimal values?
impl Value {
pub(crate) fn parse_in_place(&mut self, s: &str) -> Result<(), Error> {
/// Try to parse the given String
pub fn parse_in_place(&mut self, s: &str) -> Result<(), Error> {
*self = match self {
Value::Bool(_) => match s {
"true" => Value::Bool(true),

View File

@ -18,6 +18,8 @@ pub use generate::{
validator::Validator,
value::Value,
};
#[cfg(feature = "tui")]
pub use generate::{do_checks, evaluate_yaml_config};
/// Parse the value of an environment variable as a [bool] at compile time.
#[macro_export]