diff --git a/src/config_check.rs b/src/config_check.rs index 8755485..37eff64 100644 --- a/src/config_check.rs +++ b/src/config_check.rs @@ -2,7 +2,18 @@ use crate::key_value::KeyValueStore as KV; use std::error::Error; use std::io::{self, Write}; -const CONFIG_SPEC: CfgSpec<'_> = CfgSpec { +/// 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)", @@ -49,11 +60,11 @@ pub fn populate_defaults(config: &KV) { for group in CONFIG_SPEC.groups { for field in group.fields { match field { - CfgField::Silent { key, default, .. } => { - config.default(&format!("{}-{}", group.name, key), default) + CfgField::Silent { default, .. } => { + config.default(&field.full_key(group.name), default) }, - CfgField::Default { key, default, .. } => { - config.default(&format!("{}-{}", group.name, key), default) + CfgField::Default { default, .. } => { + config.default(&field.full_key(group.name), default) }, _ => {} } @@ -61,48 +72,137 @@ pub fn populate_defaults(config: &KV) { } } -const ANSI_GROUP: &str = "\x1b[1;31m"; -const ANSI_FIELD: &str = "\x1b[1;33m"; -const ANSI_RESET: &str = "\x1b[0m"; - +/// 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> { + let mut all_valid : bool = true; for group in CONFIG_SPEC.groups { group_header( group.name, group.description ); - // TODO: skip category option? - for field in group.fields { - check_field( &config, group.name, field )?; + 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) + } } } - Ok(()) + if all_valid { + Ok(()) + } else { + Err("WARNING: Config is incomplete. Re-run to fix.".into()) + } } -fn check_field( config: &KV, grpname: &str, field: &CfgField ) -> Result<(), Box> { +fn check_field( config: &KV, grpname: &str, field: &CfgField, ok: &mut bool ) -> Result<(), Box> { if field.is_silent() { return Ok(()) } + show_field(config, grpname, field); let key = field.full_key(grpname); - println!( "{}{}{} - {}", ANSI_FIELD, &key, ANSI_RESET, field.description() ); - println!( " default: {}", field.default().unwrap_or("(empty)".to_string())); 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: *****"), - None => println!(" current: (empty)"), + 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)".to_string()) ); + 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 - + // 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 { + 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(); @@ -111,6 +211,7 @@ fn prompt_single_line( ) -> String { 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(); @@ -125,10 +226,13 @@ fn prompt_multiline( ) -> String { 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. @@ -159,20 +263,30 @@ struct CfgSpec<'a> { } impl<'a> CfgField<'a> { + /// Silent fields don't get prompted or shown. fn is_silent(&self) -> bool { - match self { - CfgField::Silent { .. } => true, - _ => false - } + matches!( self, CfgField::Silent { .. } ) } + /// Password fields will be censored when displayed. fn is_password(&self) -> bool { - match self { - CfgField::Password { .. } => true, - _ => false + 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, @@ -183,10 +297,12 @@ impl<'a> CfgField<'a> { }.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)", @@ -197,6 +313,8 @@ impl<'a> CfgField<'a> { }.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 { match self { CfgField::Silent { default, .. } => Some(default.to_string()),