config_spec: various improvements

- more color, using colored
- better wording of options
- highlight default choices
- various other small fixes
This commit is contained in:
nobody 2024-08-07 03:16:34 +02:00 committed by murmeldin
parent 29e6285e3c
commit 62d12e314c

View file

@ -37,9 +37,10 @@
//! };
//! // open a config file somewhere (we use a dummy to not cause side-effects)
//! let config = KeyValueStore::new_dummy()?;
//! assert_eq!( config.get("modname-user").unwrap(), "xyzzy" );
//! // always:
//! CONFIG_SPEC.populate_defaults(&config);
//! // (and then defaults will be set:)
//! assert_eq!( config.get("modname-user").unwrap(), "xyzzy" );
//! ```
//!
//! ```ignore
@ -57,11 +58,27 @@ const EMPTY_VALUE: &str = "(empty)";
/// Text shown in place of a password.
const HIDDEN_PASSWORD: &str = "*****";
// highlight some of the info
const ANSI_GROUP: &str = "\x1b[1;31m";
const ANSI_FIELD: &str = "\x1b[1;33m";
const ANSI_NOTICE: &str = "\x1b[33m";
const ANSI_RESET: &str = "\x1b[0m";
/// semantic highlighting (in contrast to the pure formatting-based stuff you get from colored)
#[rustfmt::skip]
mod style {
use colored::{ColoredString, Colorize};
/// general emphasis
pub fn emph (text: &str) -> ColoredString { text.underline() }
/// group header
pub fn group (text: &str) -> ColoredString { text.bold() .cyan() }
/// field name or group-level fields
pub fn field (text: &str) -> ColoredString { text.bold() .blue() }
/// low priority (generally field-level extra info)
pub fn info (text: &str) -> ColoredString { text .blue() }
/// default value, or unchanged value
pub fn default (text: &str) -> ColoredString { text .green() }
/// generator (or description thereof)
pub fn generator (text: &str) -> ColoredString { text.bold() .yellow() }
/// changed value
pub fn changed (text: &str) -> ColoredString { text .magenta() }
/// informative messages
pub fn notice (text: &str) -> ColoredString { text .bright_yellow() }
}
/* ***** config structure definition ***** */
@ -69,7 +86,7 @@ const ANSI_RESET: &str = "\x1b[0m";
pub enum CfgField<'a> {
/// Silently writes a default to the DB if absent, never prompts for changes.
Silent { key: &'a str, default: &'a str },
/// Writes a default, asks for changes.
/// Writes a default, asks for / allows changes.
Default { key: &'a str, default: &'a str, description: &'a str },
/// No default, asks for value, but can stay empty.
Optional { key: &'a str, description: &'a str },
@ -110,6 +127,10 @@ impl<'a> CfgField<'a> {
matches!(self, CfgField::Password { .. })
}
fn is_generator(&self) -> bool {
matches!(self, CfgField::Generated { .. })
}
/// Optional fields are allowed to be (and stay) empty.
fn is_optional(&self) -> bool {
matches!(self, CfgField::Optional { .. })
@ -212,18 +233,21 @@ pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box<dyn Error
// TODO: add distinction between edit all / edit necessary only
let choices = if !todo.is_empty() {
println!(
"{}The following fields need adjustment: {}{}",
ANSI_NOTICE,
todo.join(", "),
ANSI_RESET
"{}",
style::notice(&format!(
"The following fields need adjustment: {}",
todo.join(", ")
))
);
&["[E]dit (default)", "[L]ist all", "[S]kip group"]
&["[F]ixes only", "[E]dit all", "[L]ist all", "[S]kip group"][..]
} else {
println!(
"{}This group looks fine. OK to [S]kip, unless you want to adjust values here.{}",
ANSI_NOTICE, ANSI_RESET
"{}",
style::notice(
"This group looks fine. OK to [S]kip, unless you want to adjust values here."
)
);
&["[S]kip group (default)", "[E]dit", "[L]ist all"]
&["[S]kip group", "[E]dit all", "[L]ist all"][..]
};
loop {
let choice = prompt_action(choices);
@ -237,6 +261,13 @@ pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box<dyn Error
}
break;
},
'F' => {
for field in group.fields {
if field.is_action_required(&config, group.name) {
check_field(&config, group.name, field, &mut all_valid)?;
}
}
},
'S' => {
if !todo.is_empty() {
all_valid = false;
@ -244,8 +275,8 @@ pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box<dyn Error
break;
},
_ => {
unreachable!();
}, // (prompt already checks)
unreachable!(); // (prompt already checks)
},
}
}
}
@ -256,6 +287,7 @@ pub fn interactive_check(spec: &CfgSpec, config: KV) -> Result<(), Box<dyn Error
}
}
/// Checks a single field and asks what to do with it.
fn check_field(
config: &KV, grpname: &str, field: &CfgField, ok: &mut bool,
) -> Result<(), Box<dyn Error>> {
@ -267,20 +299,23 @@ fn check_field(
let value = config.get(&key).ok();
let mut actions: Vec<&'static str> = Vec::new();
// TODO: adjust order: empty password should offer input first
// TODO: RandomId should offer generating as [R]egenerate
if value.is_some() | field.is_password() | field.is_optional() {
actions.push("[K]eep as-is");
}
if field.default_description().is_some() {
actions.push("[R]eset to default");
if field.is_generator() {
actions.push("[R]e-generate value");
} else {
actions.push("[R]eset to default");
}
}
if field.is_password() {
actions.push("[R]emove");
}
actions.push("[I]nput new value");
if !field.is_password() {
actions.push("[L]ong (multiline) input")
};
actions.push("[L]ong (multiline) input");
}
match prompt_action(&actions) {
'K' => {
@ -310,7 +345,7 @@ fn check_field(
config.set(&key, &value)?;
},
_ => {
return Err("Wat.".into());
unreachable!(); // (prompt already checks)
},
}
Ok(())
@ -319,8 +354,9 @@ fn check_field(
/* ***** displaying various kinds of info ***** */
fn group_header(name: &str, description: &str) {
println!("==============================");
println!("{}{}{} - {}", ANSI_GROUP, name, ANSI_RESET, description);
println!("{}", style::group(&("=".repeat(name.len() + description.len() + 3))));
println!("{} - {}", style::group(name), style::field(description));
println!("{}", style::group(&("=".repeat(name.len() + description.len() + 3))));
println!("");
}
@ -335,14 +371,20 @@ fn show_field(config: &KV, grpname: &str, field: &CfgField) {
return;
}
let key = field.full_key(grpname);
println!("{}{}{} - {}", ANSI_FIELD, &key, ANSI_RESET, field.description());
println!(" default: {}", field.default_description().unwrap_or(EMPTY_VALUE.to_string()));
println!("{} - {}", style::field(&key), style::info(&field.description()));
let default_desc = field.default_description().unwrap_or(EMPTY_VALUE.to_string());
println!(
" {} {}",
style::info("default:"),
(if field.is_generator() { style::generator } else { style::default })(&default_desc)
);
let value = config
.get(&key)
.ok()
.map(|s| if field.is_password() { HIDDEN_PASSWORD.to_string() } else { s })
.unwrap_or(EMPTY_VALUE.to_string());
println!(" current: {}", value);
let fmt = if value == default_desc { style::default } else { style::changed };
println!(" {} {}", style::info("current:"), fmt(&value));
}
/* ***** basic validation ***** */
@ -362,7 +404,16 @@ fn needs_info(config: &KV, group: &CfgGroup) -> Vec<String> {
/// Ask for a choice between several options. First option is the default,
/// format options with the first character in brackets like so: `"[E]xample"`.
fn prompt_action(choices: &[&str]) -> char {
let prompt_message = choices.join(", ");
let prompt_message = {
let mut formatted_choices = Vec::new();
if let Some((first, rest)) = choices.split_first() {
formatted_choices.push(format!("{} (default)", style::emph(first)));
formatted_choices.extend(rest.iter().map(|&choice| choice.to_string()));
} else {
panic!("Empty list of choices.");
}
formatted_choices.join(", ")
};
let default_choice = choices[0].to_uppercase().chars().nth(1).unwrap_or_default();
loop {
println!("{}", prompt_message);