Adjust method of variables parsing

This commit changes the method in which variables are supplied and
parsed, requiring a separate file.

For the configuration file, this is first parsed into a RoughConfig
where variables might be inserted into any of the fields. Then,
variables are interpreted into a PartialConfig.
This commit is contained in:
Gijs Burghoorn 2023-09-20 17:23:53 +02:00
parent b42e028d44
commit a46b83ebe1
5 changed files with 417 additions and 146 deletions

View file

@ -13,6 +13,7 @@ USAGE: lemurs [OPTIONS] [SUBCOMMAND]
OPTIONS:
-c, --config <FILE> A file to replace the default configuration
-v, --variables <FILE> A file to replace the set variables
-h, --help Print help information
--no-log
--preview
@ -34,6 +35,7 @@ pub struct Cli {
pub no_log: bool,
pub tty: Option<u8>,
pub config: Option<PathBuf>,
pub variables: Option<PathBuf>,
pub command: Option<Commands>,
}
@ -76,6 +78,7 @@ impl Cli {
no_log: false,
tty: None,
config: None,
variables: None,
command: None,
};
@ -104,6 +107,11 @@ impl Cli {
let arg = PathBuf::from(arg);
cli.config = Some(arg);
}
(_, "--variables") | (_, "-v") => {
let (_, arg) = args.next().ok_or(CliError::MissingArgument("variables"))?;
let arg = PathBuf::from(arg);
cli.variables = Some(arg);
}
(_, arg) => return Err(CliError::InvalidArgument(arg.to_string())),
}
}

View file

@ -1,11 +1,9 @@
use crossterm::event::KeyCode;
use log::error;
use serde::de::DeserializeOwned;
use serde::{de::Error, Deserialize};
use std::collections::HashMap;
use std::fmt::Display;
use std::fs::File;
use std::io::{self, BufReader, Read};
use std::io::Read;
use std::path::Path;
use std::process;
use toml::Value;
@ -156,8 +154,21 @@ macro_rules! merge_strategy {
};
}
macro_rules! var_replacement_strategy {
($vars:ident, $value:ident, $field_type:ty) => {
<$field_type as VariableInsertable>::insert($value, $vars)?
};
($vars:ident, $value:ident, $field_type:ty, $_:ty) => {
$value.into_partial($vars)?
};
}
macro_rules! toml_config_struct {
($struct_name:ident, $partial_struct_name:ident, $($field_name:ident => $field_type:ty $([$par_field_type:ty])?),+ $(,)?) => {
($struct_name:ident, $partial_struct_name:ident, $rough_name:ident, $($field_name:ident => $field_type:ty $([$par_field_type:ty, $rough_field_type:ty])?),+ $(,)?) => {
#[derive(Debug, Clone, Deserialize)]
struct $rough_name {
$($field_name: Option<partial_struct_field!(PossibleVariable<$field_type>$(, $rough_field_type)?)>,)+
}
#[derive(Debug, Clone, Deserialize)]
pub struct $struct_name {
$(pub $field_name: $field_type,)+
@ -175,16 +186,29 @@ macro_rules! toml_config_struct {
)+
}
}
impl $rough_name {
pub fn into_partial(self, variables: &Variables) -> Result<$partial_struct_name, VariableInsertionError> {
Ok($partial_struct_name {
$(
$field_name: match self.$field_name {
Some(value) => Some(
var_replacement_strategy!(variables, value, $field_type$(, $par_field_type)?)
),
None => None,
},
)+
})
}
}
}
}
#[derive(Debug, Deserialize)]
pub struct VariablesConfig {
#[serde(default)]
pub variables: HashMap<String, toml::Value>,
}
#[serde(transparent)]
pub struct Variables(toml::value::Table);
toml_config_struct! { Config, PartialConfig,
toml_config_struct! { Config, PartialConfig, RoughConfig,
tty => u8,
x11_display => String,
@ -202,26 +226,26 @@ toml_config_struct! { Config, PartialConfig,
focus_behaviour => FocusBehaviour,
background => BackgroundConfig [PartialBackgroundConfig],
background => BackgroundConfig [PartialBackgroundConfig, RoughBackgroundConfig],
power_controls => PowerControlConfig [PartialPowerControlConfig],
environment_switcher => SwitcherConfig [PartialSwitcherConfig],
username_field => UsernameFieldConfig [PartialUsernameFieldConfig],
password_field => PasswordFieldConfig [PartialPasswordFieldConfig],
power_controls => PowerControlConfig [PartialPowerControlConfig, RoughPowerControlConfig],
environment_switcher => SwitcherConfig [PartialSwitcherConfig, RoughSwitcherConfig],
username_field => UsernameFieldConfig [PartialUsernameFieldConfig, RoughUsernameFieldConfig],
password_field => PasswordFieldConfig [PartialPasswordFieldConfig, RoughPasswordFieldConfig],
}
toml_config_struct! { BackgroundStyleConfig, PartialBackgroundStyleConfig,
toml_config_struct! { BackgroundStyleConfig, PartialBackgroundStyleConfig, RoughBackgroundStyleConfig,
color => String,
show_border => bool,
border_color => String,
}
toml_config_struct! { BackgroundConfig, PartialBackgroundConfig,
toml_config_struct! { BackgroundConfig, PartialBackgroundConfig, RoughBackgroundConfig,
show_background => bool,
style => BackgroundStyleConfig [PartialBackgroundStyleConfig],
style => BackgroundStyleConfig [PartialBackgroundStyleConfig, RoughBackgroundStyleConfig],
}
toml_config_struct! { PowerControlConfig, PartialPowerControlConfig,
toml_config_struct! { PowerControlConfig, PartialPowerControlConfig, RoughPowerControlConfig,
allow_shutdown => bool,
shutdown_hint => String,
shutdown_hint_color => String,
@ -239,7 +263,7 @@ toml_config_struct! { PowerControlConfig, PartialPowerControlConfig,
hint_margin => u16,
}
toml_config_struct! { SwitcherConfig, PartialSwitcherConfig,
toml_config_struct! { SwitcherConfig, PartialSwitcherConfig, RoughSwitcherConfig,
switcher_visibility => SwitcherVisibility,
toggle_hint => String,
toggle_hint_color => String,
@ -287,7 +311,7 @@ toml_config_struct! { SwitcherConfig, PartialSwitcherConfig,
no_envs_modifiers_focused => String,
}
toml_config_struct! { InputFieldStyle, PartialInputFieldStyle,
toml_config_struct! { InputFieldStyle, PartialInputFieldStyle, RoughInputFieldStyle,
show_title => bool,
title => String,
@ -306,14 +330,14 @@ toml_config_struct! { InputFieldStyle, PartialInputFieldStyle,
max_width => u16,
}
toml_config_struct! { UsernameFieldConfig, PartialUsernameFieldConfig,
toml_config_struct! { UsernameFieldConfig, PartialUsernameFieldConfig, RoughUsernameFieldConfig,
remember => bool,
style => InputFieldStyle [PartialInputFieldStyle],
style => InputFieldStyle [PartialInputFieldStyle, RoughInputFieldStyle],
}
toml_config_struct! { PasswordFieldConfig, PartialPasswordFieldConfig,
toml_config_struct! { PasswordFieldConfig, PartialPasswordFieldConfig, RoughPasswordFieldConfig,
content_replacement_character => char,
style => InputFieldStyle [PartialInputFieldStyle],
style => InputFieldStyle [PartialInputFieldStyle, RoughInputFieldStyle],
}
#[derive(Debug, Clone, Deserialize)]
@ -363,7 +387,6 @@ impl<'de> Deserialize<'de> for SwitcherVisibility {
return Err(D::Error::custom(
"Invalid key provided to toggle switcher visibility. Only F1-F12 are allowed"
));
};
Self::Keybind(keycode)
@ -381,112 +404,290 @@ impl Default for Config {
}
}
impl Config {
impl PartialConfig {
/// Facilitates the loading of the entire configuration
pub fn load(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
let mut config = Config::default();
pub fn from_file(
path: &Path,
variables: Option<&Variables>,
) -> Result<PartialConfig, Box<dyn std::error::Error>> {
let mut file = File::open(path)?;
let mut contents = String::new();
let (config_str, var_config) = load_as_parts(path)?;
let processed_config = apply_variables(config_str, &var_config)?;
let partial = from_string::<PartialConfig, _>(&processed_config);
config.merge_in_partial(partial);
Ok(config)
file.read_to_string(&mut contents)?;
match variables {
Some(variables) => {
let rough = toml::from_str::<RoughConfig>(&contents)?;
Ok(rough.into_partial(variables)?)
}
None => Ok(toml::from_str::<PartialConfig>(&contents)?),
}
}
}
impl Variables {
/// Facilitates the loading of the entire configuration
pub fn from_file(path: &Path) -> Result<Variables, Box<dyn std::error::Error>> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(toml::from_str(&contents)?)
}
}
trait VariableInsertable: Sized {
const DEPTH_LIMIT: u32 = 10;
fn insert(
possible: PossibleVariable<Self>,
variables: &Variables,
) -> Result<Self, VariableInsertionError> {
Self::insert_with_depth(possible, variables, 0)
}
fn insert_with_depth(
value: PossibleVariable<Self>,
variables: &Variables,
depth: u32,
) -> Result<Self, VariableInsertionError>;
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum PossibleVariable<T> {
Value(T),
Variable(String),
}
impl<'de, T: Deserialize<'de>> TryFrom<toml::Value> for PossibleVariable<T> {
type Error = &'static str;
fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
if let Ok(i) = value.clone().try_into() {
return Ok(Self::Value(i));
}
match value {
Value::String(s) => Ok(PossibleVariable::Variable(s)),
v => Err(v.type_str()),
}
}
}
#[derive(Debug)]
enum VariableInsertionError {
ImpossibleVariableCast {
var_ident: String,
expected_type: &'static str,
},
UnsetVariable {
var_ident: String,
},
DepthLimitReached,
InvalidType {
expected: &'static str,
gotten: &'static str,
},
UnexpectedVariableType {
var_ident: String,
expected_type: &'static str,
},
}
impl Display for VariableInsertionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VariableInsertionError::ImpossibleVariableCast {
var_ident,
expected_type,
} => write!(
f,
"Impossible to use variable '{var_ident}' in string to cast to '{expected_type}'"
),
VariableInsertionError::UnsetVariable { var_ident } => {
write!(f, "Variable '{var_ident}' is not set")
},
VariableInsertionError::DepthLimitReached => {
write!(f, "Variable evaluation reached the depth limit")
},
VariableInsertionError::InvalidType { expected, gotten } => write!(f, "Expected type '{expected}'. Got type '{gotten}'."),
VariableInsertionError::UnexpectedVariableType { var_ident, expected_type } => write!(f, "Needed to use variable '{var_ident}' as a '{expected_type}', but was unable to cast it as such."),
}
}
}
impl std::error::Error for VariableInsertionError {}
macro_rules! non_string_var_insert {
($($type:ty [$type_str:literal]),+ $(,)?) => {
$(
impl VariableInsertable for $type {
fn insert_with_depth(
value: PossibleVariable<Self>,
variables: &Variables,
depth: u32,
) -> Result<Self, VariableInsertionError> {
use VariableInsertionError as E;
if depth == Self::DEPTH_LIMIT {
return Err(E::DepthLimitReached);
}
match value {
PossibleVariable::Variable(s) => {
// Ignore surrounding spaces
let s = s.trim();
let mut variter = VariableIterator::new(&s);
// No variable in string
let var = variter.next().ok_or(E::InvalidType {
expected: $type_str,
gotten: "string",
})?;
// Not whole string is variable
if var.span() != (0..s.len()) {
return Err(E::ImpossibleVariableCast {
var_ident: var.ident().to_string(),
expected_type: $type_str,
});
}
let value = <PossibleVariable<$type>>::try_from(
variables
.0
.get(var.ident())
.ok_or(E::UnsetVariable {
var_ident: var.ident().to_string(),
})?
.clone(),
)
.map_err(|_| E::UnexpectedVariableType {
var_ident: var.ident().to_string(),
expected_type: $type_str,
})?;
Self::insert_with_depth(value, variables, depth + 1)
}
PossibleVariable::Value(b) => Ok(b),
}
}
}
)+
};
}
non_string_var_insert! {
bool ["boolean"],
u8 ["unsigned 8-bit integer"],
u16 ["unsigned 16-bit integer"],
char ["character"],
ShellLoginFlag ["shell login flag"],
FocusBehaviour ["focus behavior"],
SwitcherVisibility ["switcher visibility"],
}
impl VariableInsertable for String {
fn insert_with_depth(
value: PossibleVariable<Self>,
variables: &Variables,
depth: u32,
) -> Result<Self, VariableInsertionError> {
use VariableInsertionError as E;
if depth == Self::DEPTH_LIMIT {
return Err(E::DepthLimitReached);
}
let mut s = match value {
PossibleVariable::Value(s) | PossibleVariable::Variable(s) => s,
};
loop {
let Some(var) = VariableIterator::new(&s).next() else {
break;
};
let value = <PossibleVariable<String>>::try_from(
variables
.0
.get(var.ident())
.ok_or(E::UnsetVariable {
var_ident: var.ident().to_string(),
})?
.clone(),
)
.map_err(|_| E::UnexpectedVariableType {
var_ident: var.ident().to_string(),
expected_type: "string",
})?;
let insertion = Self::insert_with_depth(value.clone(), variables, depth + 1)?;
s.replace_range(var.span(), &insertion);
}
Ok(s)
}
}
/// Iterator over variables in a given string
/// Assumes the presence of quotes
struct VariableIterator<'a> {
inner: &'a str,
last: usize,
offset: usize,
}
struct Variable<'a> {
start: usize,
ident: &'a str,
}
impl<'a> Variable<'a> {
const START_SYMBOL: &str = "$";
fn span(&self) -> std::ops::Range<usize> {
self.start..self.start + Self::START_SYMBOL.len() + self.ident.len()
}
fn ident(&self) -> &str {
self.ident
}
}
impl<'a> VariableIterator<'a> {
pub fn new(text: &'a str) -> Self {
Self {
inner: text,
last: 0,
offset: 0,
}
}
}
impl<'a> Iterator for VariableIterator<'a> {
type Item = (usize, usize);
type Item = Variable<'a>;
fn next(&mut self) -> Option<Self::Item> {
const START_PAT: &str = "\"$";
const START_PAT_LEN: usize = START_PAT.len();
let s = &self.inner[self.offset..];
let start = match self.inner[self.last..].find(START_PAT) {
Some(pos) => self.last + pos,
let start = match s.find(Variable::START_SYMBOL) {
Some(position) => position,
None => return None,
};
let end = self.inner[start + START_PAT_LEN..] // skip the "$ pattern
// skip the "$ pattern
let s = &s[start + Variable::START_SYMBOL.len()..];
// Find the first not variable token.
let end = s
.find(|c: char| !c.is_alphanumeric() && c != '_')
.map(|index| start + index + START_PAT_LEN + 1)
.unwrap_or_else(|| self.inner.len());
self.last = end;
.unwrap_or(s.len());
Some((start, end))
}
}
let start = self.offset + start;
self.offset = start + Variable::START_SYMBOL.len() + end;
/// Substitutes variables present in the configuration string
fn apply_variables(config_str: String, var_config: &VariablesConfig) -> Result<String, VarError> {
let mut output = String::new();
let mut last = 0;
let config_len = config_str.len();
for (start, end) in VariableIterator::new(&config_str) {
let var_ident = &config_str[start + 2..end - 1];
let var_val = var_config.variables.get(var_ident).ok_or(VarError {
variable: var_ident.to_owned(),
pos: start,
})?;
output.push_str(&config_str[last..start]);
let ident = &s[..end];
// any case that is not a string, will not be wrapped in brackets
match var_val {
Value::String(val) => output.push_str(&format!("\"{}\"", val)),
_ => output.push_str(&var_val.to_string()),
};
last = end + 1;
}
if last != config_len {
output.push_str(&config_str[last..config_len]);
}
Ok(output)
}
/// Helper function to facilitate immediate deserialization of variables along passing the original file content
fn load_as_parts(path: &Path) -> io::Result<(String, VariablesConfig)> {
match read_to_string(path) {
Ok(contents) => {
let variables = from_string::<VariablesConfig, _>(&contents);
Ok((contents, variables))
}
Err(err) => Err(err),
}
}
fn read_to_string(path: &Path) -> io::Result<String> {
let file = File::open(path)?;
let mut buf_reader = BufReader::new(file);
let mut contents = String::new();
buf_reader.read_to_string(&mut contents)?;
Ok(contents)
}
/// Generic configuration file loading
fn from_string<T: DeserializeOwned, S: AsRef<str>>(contents: S) -> T {
match toml::from_str::<T>(contents.as_ref()) {
Ok(config) => config,
Err(err) => {
eprintln!("Given configuration file contains errors:");
eprintln!("{err}");
std::process::exit(1);
}
Some(Variable { start, ident })
}
}
@ -496,23 +697,31 @@ mod tests {
#[test]
fn test_variable_iterator() {
let test_cases = [
macro_rules! assert_var_iter {
(
"TESTMEPLS \"$test5\" ME and \"$another\"",
2,
vec!["\"$test5\"", "\"$another\""],
),
(
"\"$var1\" \"$var2\" $5var",
2,
vec!["\"$var1\"", "\"$var2\""],
),
];
for (text, count, variables) in test_cases {
let iter = VariableIterator::new(text);
let collected_vars: Vec<_> = iter.map(|(start, end)| &text[start..end]).collect();
assert_eq!(variables, collected_vars);
assert_eq!(count, collected_vars.len());
$s:literal,
($($ident:literal),*)
) => {
let variables: Vec<String> = VariableIterator::new($s).map(|v| v.ident().to_string()).collect();
let idents: &[&str] = &[$($ident),*];
eprintln!("variables = {variables:?}");
eprintln!("ident = {idents:?}");
assert_eq!(
&variables,
idents,
);
};
}
assert_var_iter!("", ());
assert_var_iter!("abcdef", ());
assert_var_iter!("$a", ("a"));
assert_var_iter!("$a$b", ("a", "b"));
assert_var_iter!("$a_c$b", ("a_c", "b"));
assert_var_iter!("$a()$b", ("a", "b"));
assert_var_iter!("$0 $1", ("0", "1"));
assert_var_iter!("$var1 $var2 $var3 ", ("var1", "var2", "var3"));
}
}

View file

@ -6,7 +6,7 @@ use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use log::{error, info};
use log::{error, info, warn};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
@ -37,9 +37,77 @@ use self::{
},
};
const DEFAULT_VARIABLES_PATH: &str = "/etc/lemurs/variables.toml";
const DEFAULT_CONFIG_PATH: &str = "/etc/lemurs/config.toml";
const PREVIEW_LOG_PATH: &str = "lemurs.log";
fn merge_in_configuration(
config: &mut Config,
config_path: Option<&Path>,
variables_path: Option<&Path>,
) {
let load_variables_path = variables_path.unwrap_or_else(|| Path::new(DEFAULT_VARIABLES_PATH));
let variables = match config::Variables::from_file(load_variables_path) {
Ok(variables) => {
info!(
"Successfully loaded variables file from '{}'",
load_variables_path.display()
);
Some(variables)
}
Err(err) => {
// If we have given it a specific config path, it should crash if this file cannot be
// loaded. If it is the default config location just put a warning in the logs.
if let Some(variables_path) = variables_path {
eprintln!(
"The variables file '{}' cannot be loaded.\nReason: {}",
variables_path.display(),
err
);
std::process::exit(1);
} else {
info!(
"No variables file loaded from the default location ({}). Reason: {}",
DEFAULT_CONFIG_PATH, err
);
}
None
}
};
let load_config_path = config_path.unwrap_or_else(|| Path::new(DEFAULT_CONFIG_PATH));
match config::PartialConfig::from_file(load_config_path, variables.as_ref()) {
Ok(partial_config) => {
info!(
"Successfully loaded configuration file from '{}'",
load_config_path.display()
);
config.merge_in_partial(partial_config)
}
Err(err) => {
// If we have given it a specific config path, it should crash if this file cannot be
// loaded. If it is the default config location just put a warning in the logs.
if let Some(config_path) = config_path {
eprintln!(
"The config file '{}' cannot be loaded.\nReason: {}",
config_path.display(),
err
);
std::process::exit(1);
} else {
warn!(
"No configuration file loaded from the expected location ({}). Reason: {}",
DEFAULT_CONFIG_PATH, err
);
}
}
}
}
fn setup_logger(log_path: &str) {
let log_file = Box::new(File::create(log_path).unwrap_or_else(|_| {
eprintln!("Failed to open log file: '{log_path}'");
@ -60,28 +128,8 @@ fn main() -> Result<(), Box<dyn Error>> {
std::process::exit(2);
});
// Load and setup configuration
let mut should_crash = true;
let cli_config_path = cli.config.as_deref();
let load_config_path = cli_config_path.unwrap_or_else(|| {
should_crash = false;
Path::new(DEFAULT_CONFIG_PATH)
});
let mut config = match Config::load(load_config_path) {
Ok(config) => config,
Err(err) => {
eprintln!(
"The config file '{}' cannot be loaded.\nReason: {}",
load_config_path.display(),
err
);
if should_crash {
std::process::exit(1);
}
Config::default()
}
};
let mut config = Config::default();
merge_in_configuration(&mut config, cli.config.as_deref(), cli.variables.as_deref());
if let Some(cmd) = cli.command {
match cmd {

View file

@ -146,11 +146,17 @@ impl LimitedOutputChild {
let file = file_options.open(log_path)?;
let Some(stdout) = process.stdout.take() else {
return Err(io::Error::new(io::ErrorKind::Other, "Failed to grab stdout"));
return Err(io::Error::new(
io::ErrorKind::Other,
"Failed to grab stdout",
));
};
let Some(stderr) = process.stderr.take() else {
return Err(io::Error::new(io::ErrorKind::Other, "Failed to grab stderr"));
return Err(io::Error::new(
io::ErrorKind::Other,
"Failed to grab stderr",
));
};
let mut stdout_receiver = Receiver::from(stdout);

View file

@ -450,7 +450,7 @@ impl LoginForm {
let Some(post_login_env) = environment else {
status_message.set(ErrorStatusMessage::NoGraphicalEnvironment);
send_ui_request(UIThreadRequest::Redraw);
continue
continue;
};
match start_session(