plenum-bot/src/config_check.rs
2024-12-10 21:27:38 +01:00

327 lines
12 KiB
Rust

use crate::key_value::KeyValueStore as KV;
use std::error::Error;
use std::io::{self, Write};
/// Text shown for an empty field.
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";
const CONFIG_SPEC: CfgSpec<'static> = CfgSpec {
groups: &[
CfgGroup { name: "config",
description: "(internal values)",
fields: &[
CfgField::Silent { key: "version", default: "1" },
],
},
CfgGroup { name: "hedgedoc",
description: "HedgeDoc markdown pad server settings",
fields: &[
CfgField::Default { key: "server-url", default: "https://md.berlin.ccc.de/", description: "Hedgedoc server storing the pads." },
CfgField::Default { key: "template-name", default: "plenum-template", description: "Name of the pad containing the template to use." },
// TODO: make these generators?
CfgField::Optional { key: "last-id", description: "ID of last plenum's pad." },
CfgField::Optional { key: "next-id", description: "ID of next plenum's pad." }
],
},
CfgGroup { name: "email",
description: "Sending emails.",
fields: &[
CfgField::Default { key: "server", default: "mail.berlin.ccc.de", description: "SMTP server used for sending emails." },
CfgField::Default { key: "user", default: "plenum-bot@berlin.ccc.de", description: "User name used for authenticating with the mail server." },
CfgField::Password { key: "password", description: "Password for authenticating with the mail server." },
CfgField::Default { key: "sender", default: "Plenumsbot <plenum-bot@berlin.ccc.de>", description: "Email address to use for \"From:\"." },
CfgField::Default { key: "to", default: "CCCB Intern <intern@berlin.ccc.de>", description: "Recipient of the emails sent." },
CfgField::Optional { key: "last-message-id", description: "Message-Id of last initial announcement to send In-Reply-To (if applicable)." }
],
},
// TODO: Matrix, Wiki
CfgGroup { name: "text",
description: "Various strings used.",
fields: &[
CfgField::Default { key: "email-signature",
default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]",
description: "Text added at the bottom of every email."
},
],
},
],
};
/// Ensure all fields with known default values exist.
pub fn populate_defaults(config: &KV) {
for group in CONFIG_SPEC.groups {
for field in group.fields {
match field {
CfgField::Silent { default, .. } => {
config.default(&field.full_key(group.name), default)
},
CfgField::Default { default, .. } => {
config.default(&field.full_key(group.name), default)
},
_ => {}
}
}
}
}
/// Check all groups / fields and interactively edit as needed.
///
/// Will report if the config is fine or has missing values.
pub fn interactive_check(config: KV) -> Result<(), Box<dyn Error>> {
let mut all_valid : bool = true;
for group in CONFIG_SPEC.groups {
group_header( group.name, group.description );
let todo = needs_info(&config, group);
let choices = if !todo.is_empty() {
println!( "{}The following fields need adjustment: {}{}", ANSI_NOTICE, todo.join(", "), ANSI_RESET );
&["[E]dit (default)", "[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 );
&["[S]kip group (default)", "[E]dit", "[L]ist all", ]
};
loop {
let choice = prompt_action( choices );
match choice {
'L' => { show_group(&config, group); },
'E' => {
for field in group.fields {
check_field( &config, group.name, field, &mut all_valid )?;
}
break;
},
'S' => {
if !todo.is_empty() { all_valid = false; }
break;
},
_ => { return Err("Wat.".into()); }, // shouldn't be able to happen (prompt already checks)
}
}
}
if all_valid {
Ok(())
} else {
Err("WARNING: Config is incomplete. Re-run to fix.".into())
}
}
fn check_field( config: &KV, grpname: &str, field: &CfgField, ok: &mut bool ) -> Result<(), Box<dyn Error>> {
if field.is_silent() { return Ok(()) }
show_field(config, grpname, field);
let key = field.full_key(grpname);
let value = config.get(&key).ok();
let mut actions: Vec<&'static str> = Vec::new();
if value.is_some() { actions.push("[K]eep as-is"); }
if field.is_password() {
match value {
Some(_) => {
println!(" current: {}", HIDDEN_PASSWORD);
},
None => {
println!(" current: {}", EMPTY_VALUE);
actions.push("[K]eep as-is"); // we allow leaving the password empty if you don't want to set it
},
};
//todo!()
// TODO: [K]eep as-is (if exists), [R]emove, [I]nput
} else {
println!( " current: {}", value.unwrap_or(EMPTY_VALUE.to_string()) );
// TODO: [K]eep as-is, [R]eset to default, [I]nput, [L]ong (multiline) input
}
// Action -
Ok(())
}
/* ***** displaying various kinds of info ***** */
fn group_header( name: &str, description: &str ) {
println!("==============================");
println!("{}{}{} - {}", ANSI_GROUP, name, ANSI_RESET, description);
println!("");
}
fn show_group( config: &KV, group: &CfgGroup ) {
for field in group.fields {
show_field( &config, group.name, field );
}
}
fn show_field( config: &KV, grpname: &str, field: &CfgField ) {
if field.is_silent() { return }
let key = field.full_key(grpname);
println!( "{}{}{} - {}", ANSI_FIELD, &key, ANSI_RESET, field.description() );
println!( " default: {}", field.default().unwrap_or(EMPTY_VALUE.to_string()));
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 );
}
/* ***** basic validation ***** */
fn needs_info( config: &KV, group: &CfgGroup ) -> Vec<String> {
let mut acc = Vec::new();
for field in group.fields {
if field.is_action_required(config, group.name) {
acc.push( field.key() );
}
}
acc
}
/* ***** prompting (with actual I/O) ***** */
/// 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 default_choice = choices[0].to_uppercase().chars().nth(1).unwrap_or_default();
loop {
println!( "{}", prompt_message );
print!( "Select: " );
io::stdout().flush().ok();
// Read line and take first non-space character
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let input = input.trim().to_uppercase().chars().next().unwrap_or(default_choice);
// Check list of choices for match
if choices.iter().any(|&choice| choice.to_uppercase().chars().nth(1).unwrap_or_default() == input) {
return input;
} else {
println!("Invalid choice. Try again!");
}
}
}
/// Read a single line of text.
fn prompt_single_line( ) -> String {
print!("New value: ");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
/// Read multiple lines of text, terminated by a line containing just a '.'.
fn prompt_multiline( ) -> String {
println!("Enter new value: (end with '.' on a new line)");
let mut acc = String::new();
loop {
let mut line = String::new();
io::stdin().read_line(&mut line).unwrap();
if line.trim() == "." {
break;
}
acc.push_str(&line);
}
acc
}
/// Read a password without echoing it.
fn prompt_password( ) -> String {
rpassword::prompt_password("New password (not shown) : ").unwrap()
}
/* ***** config structure definition ***** */
/// Describes a field in the configuration.
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.
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 },
/// Empty by default, required, will prompt without echo.
Password { key: &'a str, description: &'a str },
/// Empty by default, can be user-provided, or will be generated randomly.
RandomId { key: &'a str, generator: fn() -> String, generator_description: &'a str, description: &'a str },
}
/// A group of related config fields. The final key of an inner value will be
/// `{group.name}-{key}`.
struct CfgGroup<'a> {
name: &'a str,
description: &'a str,
fields: &'a [CfgField<'a>],
}
/// A description of the entire config, as a collection of named groups.
///
/// Used both to populate defaults and to interactively adjust the configuration.
struct CfgSpec<'a> {
groups: &'a [CfgGroup<'a>],
}
impl<'a> CfgField<'a> {
/// Silent fields don't get prompted or shown.
fn is_silent(&self) -> bool {
matches!( self, CfgField::Silent { .. } )
}
/// Password fields will be censored when displayed.
fn is_password(&self) -> bool {
matches!( self, CfgField::Password { .. } )
}
/// Reports if the field needs changing or is ok as-is.
///
/// (Because of the default population at boot, Silent and Default must
/// *necessarily* always have a value. Optional is optional. Only
/// Password and RandomId might need actions if they are missing.)
fn is_action_required(&self, config: &KV, grpname: &str) -> bool {
if matches!( self, CfgField::Silent { .. } | CfgField::Default { .. } | CfgField::Optional { .. } ) {
false
} else {
config.get(self.full_key(grpname).as_str()).ok().is_none()
}
}
/// Gets `key`, which is the local part of the field name. (`.full_key` is used for the DB.)
fn key(&self) -> String {
match self {
CfgField::Silent { key, .. } => key,
CfgField::Default { key, .. } => key,
CfgField::Optional { key, .. } => key,
CfgField::Password { key, .. } => key,
CfgField::RandomId { key, .. } => key,
}.to_string()
}
/// Full field name / key used in the DB (currently `{grpname}-{fieldname}`.)
fn full_key(&self, grpname: &str) -> String {
format!("{}-{}", grpname, self.key())
}
/// Gets `description` or "(none)".
fn description(&self) -> String {
match self {
CfgField::Silent { .. } => "(none)",
CfgField::Default { description, .. } => description,
CfgField::Optional { description, .. } => description,
CfgField::Password { description, .. } => description,
CfgField::RandomId { description, .. } => description,
}.to_string()
}
/// Gets a description of the default value if one exists.
/// (Either the value itself, or a description of the generator making a default value.)
fn default(&self) -> Option<String> {
match self {
CfgField::Silent { default, .. } => Some(default.to_string()),
CfgField::Default { default, .. } => Some(default.to_string()),
CfgField::RandomId { generator_description, .. } => Some(format!("{}{}{}",'(',generator_description,')')),
_ => None,
}
}
}