use crate::constants::{
DEFAULT_CONFIG_SECTION_NAME, DEFAULT_HOST, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT,
DEFAULT_PASSWORD, DEFAULT_PATH_PREFIX, DEFAULT_USERNAME, DEFAULT_VHOST,
};
use crate::errors::CommandRunError;
use crate::output::TableStyle;
use clap::ArgMatches;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{collections::HashMap, fs, io};
use tabled::Tabled;
use thiserror::Error;
use toml_edit::{DocumentMut, Item, Table, Value};
use url::Url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scheme {
#[default]
Http,
Https,
}
impl Scheme {
pub fn is_https(&self) -> bool {
matches!(self, Scheme::Https)
}
pub fn as_str(&self) -> &'static str {
match self {
Scheme::Http => "http",
Scheme::Https => "https",
}
}
}
impl fmt::Display for Scheme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Scheme {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"http" => Ok(Scheme::Http),
"https" => Ok(Scheme::Https),
_ => Err(format!(
"Invalid scheme: '{}'. Expected 'http' or 'https'",
s
)),
}
}
}
impl From<&str> for Scheme {
fn from(s: &str) -> Self {
s.parse().unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct PreFlightSettings {
pub infer_subcommands: bool,
pub infer_long_options: bool,
}
impl Default for PreFlightSettings {
fn default() -> Self {
Self {
infer_long_options: true,
infer_subcommands: false,
}
}
}
impl PreFlightSettings {
pub fn non_interactive() -> Self {
Self {
infer_long_options: false,
infer_subcommands: false,
}
}
}
#[derive(Error, Debug)]
pub enum ConfigFileError {
#[error("the provided config file at '{0}' does not exist")]
MissingFile(PathBuf),
#[error(
"specified configuration section (--node) '{0}' was not found in the configuration file"
)]
MissingConfigSection(String),
#[error("node '{0}' already exists in the configuration file. Use 'update_node' to modify it")]
NodeAlreadyExists(String),
#[error(transparent)]
IoError(#[from] io::Error),
#[error("failed to deserialize the config file. Make sure it is valid TOML. Details: {0}")]
DeserializationError(#[from] toml::de::Error),
#[error("failed to parse the config file. Make sure it is valid TOML. Details: {0}")]
ParseError(#[from] toml_edit::TomlError),
}
type ConfigurationMap<'a> = HashMap<String, SharedSettings>;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct SharedSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_uri: Option<String>,
#[serde(default = "default_tls")]
pub tls: bool,
#[serde(default = "default_non_interactive")]
pub non_interactive: bool,
#[serde(default = "default_quiet")]
pub quiet: bool,
#[serde(default = "default_verbose")]
pub verbose: bool,
#[serde(default)]
pub scheme: Scheme,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default = "default_path_prefix")]
pub path_prefix: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub virtual_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_style: Option<TableStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ca_certificate_bundle_path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_certificate_file_path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_private_key_file_path: Option<PathBuf>,
}
impl SharedSettings {
pub fn from_config_file(
path: &Path,
section_name: Option<String>,
) -> Result<Self, ConfigFileError> {
let section = section_name.unwrap_or(DEFAULT_CONFIG_SECTION_NAME.to_string());
from_local_path(path).and_then(|cm| {
let err = Err(ConfigFileError::MissingConfigSection(section.clone()));
match cm.get(section.as_str()) {
None => err,
Some(val) => Ok(val.clone()),
}
})
}
pub fn from_args_with_defaults(
general_args: &ArgMatches,
config_file_defaults: &Self,
) -> Result<Self, CommandRunError> {
let base_uri = general_args
.get_one::<String>("base_uri")
.cloned()
.or_else(|| config_file_defaults.base_uri.clone());
if let Some(s) = base_uri {
let url = Url::parse(&s).map_err(|e| CommandRunError::InvalidBaseUri {
uri: s.clone(),
message: e.to_string(),
})?;
Ok(SharedSettings::new_from_uri_with_defaults(
&url,
general_args,
config_file_defaults,
))
} else {
Ok(SharedSettings::new_with_defaults(
general_args,
config_file_defaults,
))
}
}
pub fn from_args(general_args: &ArgMatches) -> Result<Self, CommandRunError> {
let base_uri = general_args.get_one::<String>("base_uri").cloned();
if let Some(s) = base_uri {
let url = Url::parse(&s).map_err(|e| CommandRunError::InvalidBaseUri {
uri: s.clone(),
message: e.to_string(),
})?;
Ok(SharedSettings::new_from_uri(&url, general_args))
} else {
Ok(SharedSettings::new(general_args))
}
}
pub fn new_with_defaults(cli_args: &ArgMatches, config_file_defaults: &Self) -> Self {
let default_hostname = DEFAULT_HOST.to_string();
let should_use_tls =
cli_args.get_one::<bool>("tls").cloned().unwrap_or(false) || config_file_defaults.tls;
let non_interactive = cli_args
.get_one::<bool>("non_interactive")
.cloned()
.unwrap_or(false)
|| config_file_defaults.non_interactive;
let quiet = cli_args.get_one::<bool>("quiet").cloned().unwrap_or(false)
|| config_file_defaults.quiet;
let verbose = cli_args
.get_one::<bool>("verbose")
.cloned()
.unwrap_or(false)
|| config_file_defaults.verbose;
let scheme = if should_use_tls {
Scheme::Https
} else {
config_file_defaults.scheme
};
let hostname = cli_args
.get_one::<String>("host")
.cloned()
.or_else(|| config_file_defaults.hostname.clone())
.unwrap_or(default_hostname);
let port: u16 = cli_args
.get_one::<u16>("port")
.cloned()
.or(config_file_defaults.port)
.or({
if should_use_tls {
Some(DEFAULT_HTTPS_PORT)
} else {
Some(DEFAULT_HTTP_PORT)
}
})
.unwrap();
let path_prefix = cli_args
.get_one::<String>("path_prefix")
.cloned()
.or(Some(config_file_defaults.path_prefix.clone()))
.unwrap_or(DEFAULT_PATH_PREFIX.to_owned());
let username = cli_args
.get_one::<String>("username")
.cloned()
.or_else(|| config_file_defaults.username.clone())
.unwrap_or(DEFAULT_USERNAME.to_string());
let password = cli_args
.get_one::<String>("password")
.cloned()
.or_else(|| config_file_defaults.password.clone())
.unwrap_or(DEFAULT_PASSWORD.to_string());
let vhost = cli_args
.get_one::<String>("vhost")
.cloned()
.or_else(|| config_file_defaults.virtual_host.clone())
.unwrap_or(DEFAULT_VHOST.to_owned());
let table_style = cli_args
.get_one::<TableStyle>("table_style")
.cloned()
.or(Some(TableStyle::default()))
.unwrap_or_default();
let ca_certificate_bundle_path = cli_args
.get_one::<PathBuf>("ca_certificate_bundle_path")
.cloned()
.or_else(|| config_file_defaults.ca_certificate_bundle_path.clone());
let client_certificate_file_path = cli_args
.get_one::<PathBuf>("client_certificate_file_path")
.cloned()
.or_else(|| config_file_defaults.client_certificate_file_path.clone());
let client_private_key_file_path = cli_args
.get_one::<PathBuf>("client_private_key_file_path")
.cloned()
.or_else(|| config_file_defaults.client_private_key_file_path.clone());
Self {
tls: should_use_tls,
ca_certificate_bundle_path,
client_certificate_file_path,
client_private_key_file_path,
non_interactive,
quiet,
verbose,
base_uri: None,
scheme,
hostname: Some(hostname),
port: Some(port),
path_prefix,
username: Some(username),
password: Some(password),
virtual_host: Some(vhost),
table_style: Some(table_style),
}
}
pub fn new(cli_args: &ArgMatches) -> Self {
let default_hostname = DEFAULT_HOST.to_string();
let should_use_tls = cli_args.get_one::<bool>("tls").cloned().unwrap_or(false);
let non_interactive = cli_args
.get_one::<bool>("non_interactive")
.cloned()
.unwrap_or(false)
|| default_non_interactive();
let quiet = cli_args.get_one::<bool>("quiet").cloned().unwrap_or(false) || default_quiet();
let verbose = cli_args
.get_one::<bool>("verbose")
.cloned()
.unwrap_or(false)
|| default_verbose();
let scheme = if should_use_tls {
Scheme::Https
} else {
Scheme::default()
};
let hostname = cli_args
.get_one::<String>("host")
.cloned()
.unwrap_or(default_hostname);
let port: u16 = cli_args
.get_one::<u16>("port")
.cloned()
.or({
if should_use_tls {
Some(DEFAULT_HTTPS_PORT)
} else {
Some(DEFAULT_HTTP_PORT)
}
})
.unwrap();
let path_prefix = cli_args
.get_one::<String>("path_prefix")
.cloned()
.unwrap_or(DEFAULT_PATH_PREFIX.to_owned());
let username = cli_args
.get_one::<String>("username")
.cloned()
.unwrap_or(DEFAULT_USERNAME.to_string());
let password = cli_args
.get_one::<String>("password")
.cloned()
.unwrap_or(DEFAULT_PASSWORD.to_string());
let vhost = cli_args
.get_one::<String>("vhost")
.cloned()
.unwrap_or(DEFAULT_VHOST.to_owned());
let table_style = cli_args
.get_one::<TableStyle>("table_style")
.cloned()
.or(Some(TableStyle::default()))
.unwrap_or_default();
let ca_certificate_bundle_path = cli_args
.get_one::<PathBuf>("ca_certificate_bundle_path")
.cloned();
let client_certificate_file_path = cli_args
.get_one::<PathBuf>("client_certificate_file_path")
.cloned();
let client_private_key_file_path = cli_args
.get_one::<PathBuf>("client_private_key_file_path")
.cloned();
Self {
tls: should_use_tls,
ca_certificate_bundle_path,
client_certificate_file_path,
client_private_key_file_path,
non_interactive,
quiet,
verbose,
base_uri: None,
scheme,
hostname: Some(hostname),
port: Some(port),
path_prefix,
username: Some(username),
password: Some(password),
virtual_host: Some(vhost),
table_style: Some(table_style),
}
}
pub fn new_from_uri_with_defaults(
url: &Url,
cli_args: &ArgMatches,
config_file_defaults: &Self,
) -> Self {
let should_use_tls = cli_args.get_one::<bool>("tls").cloned().unwrap_or(false)
|| config_file_defaults.tls
|| url.scheme() == "https";
let non_interactive = cli_args
.get_one::<bool>("non_interactive")
.cloned()
.unwrap_or(false)
|| config_file_defaults.non_interactive;
let quiet = cli_args.get_one::<bool>("quiet").cloned().unwrap_or(false)
|| config_file_defaults.quiet;
let verbose = cli_args
.get_one::<bool>("verbose")
.cloned()
.unwrap_or(false)
|| config_file_defaults.verbose;
let scheme = if should_use_tls {
Scheme::Https
} else {
config_file_defaults.scheme
};
let hostname = url.host_str().unwrap_or(DEFAULT_HOST).to_string();
let port = url
.port()
.or({
if should_use_tls {
Some(DEFAULT_HTTPS_PORT)
} else {
Some(DEFAULT_HTTP_PORT)
}
})
.unwrap_or(DEFAULT_HTTP_PORT);
let path_prefix = cli_args
.get_one::<String>("path_prefix")
.cloned()
.or(Some(config_file_defaults.path_prefix.clone()))
.unwrap_or(DEFAULT_PATH_PREFIX.to_owned());
let username = cli_args
.get_one::<String>("username")
.cloned()
.or_else(|| config_file_defaults.username.clone())
.unwrap_or(DEFAULT_USERNAME.to_string());
let password = cli_args
.get_one::<String>("password")
.cloned()
.or_else(|| config_file_defaults.password.clone())
.unwrap_or(DEFAULT_PASSWORD.to_string());
let vhost = cli_args
.get_one::<String>("vhost")
.cloned()
.or_else(|| config_file_defaults.virtual_host.clone())
.unwrap_or(DEFAULT_VHOST.to_owned());
let table_style = cli_args
.get_one::<TableStyle>("table_style")
.cloned()
.or(Some(TableStyle::default()))
.unwrap_or_default();
let ca_certificate_bundle_path = cli_args
.get_one::<PathBuf>("ca_certificate_bundle_path")
.cloned();
let client_certificate_file_path = cli_args
.get_one::<PathBuf>("client_certificate_file_path")
.cloned();
let client_private_key_file_path = cli_args
.get_one::<PathBuf>("client_private_key_file_path")
.cloned();
Self {
tls: should_use_tls,
ca_certificate_bundle_path,
client_certificate_file_path,
client_private_key_file_path,
non_interactive,
quiet,
verbose,
base_uri: Some(url.to_string()),
scheme,
hostname: Some(hostname),
port: Some(port),
path_prefix,
username: Some(username),
password: Some(password),
virtual_host: Some(vhost),
table_style: Some(table_style),
}
}
pub fn new_from_uri(url: &Url, cli_args: &ArgMatches) -> Self {
let should_use_tls =
cli_args.get_one::<bool>("tls").cloned().unwrap_or(false) || url.scheme() == "https";
let non_interactive = cli_args
.get_one::<bool>("non_interactive")
.cloned()
.unwrap_or(default_non_interactive());
let quiet = cli_args
.get_one::<bool>("quiet")
.cloned()
.unwrap_or(default_quiet());
let verbose = cli_args
.get_one::<bool>("verbose")
.cloned()
.unwrap_or(default_verbose());
let scheme = if should_use_tls {
Scheme::Https
} else {
Scheme::from(url.scheme())
};
let hostname = url.host_str().unwrap_or(DEFAULT_HOST).to_string();
let port = url
.port()
.or({
if should_use_tls {
Some(DEFAULT_HTTPS_PORT)
} else {
Some(DEFAULT_HTTP_PORT)
}
})
.unwrap_or(DEFAULT_HTTP_PORT);
let path_prefix = cli_args
.get_one::<String>("path_prefix")
.cloned()
.unwrap_or(DEFAULT_PATH_PREFIX.to_owned());
let username = cli_args
.get_one::<String>("username")
.cloned()
.unwrap_or(DEFAULT_USERNAME.to_string());
let password = cli_args
.get_one::<String>("password")
.cloned()
.unwrap_or(DEFAULT_PASSWORD.to_string());
let vhost = cli_args
.get_one::<String>("vhost")
.cloned()
.unwrap_or(DEFAULT_VHOST.to_owned());
let table_style = cli_args
.get_one::<TableStyle>("table_style")
.cloned()
.or(Some(TableStyle::default()))
.unwrap_or_default();
let ca_certificate_bundle_path = cli_args
.get_one::<PathBuf>("ca_certificate_bundle_path")
.cloned();
let client_certificate_file_path = cli_args
.get_one::<PathBuf>("client_certificate_file_path")
.cloned();
let client_private_key_file_path = cli_args
.get_one::<PathBuf>("client_private_key_file_path")
.cloned();
Self {
tls: should_use_tls,
ca_certificate_bundle_path,
client_certificate_file_path,
client_private_key_file_path,
non_interactive,
quiet,
verbose,
base_uri: Some(url.to_string()),
scheme,
hostname: Some(hostname),
port: Some(port),
path_prefix,
username: Some(username),
password: Some(password),
virtual_host: Some(vhost),
table_style: Some(table_style),
}
}
pub fn endpoint(&self) -> String {
let prefix = if self.path_prefix.starts_with('/') {
self.path_prefix.clone()
} else {
format!("/{}", self.path_prefix)
};
format!(
"{}://{}:{}{}",
self.scheme,
self.hostname.as_ref().unwrap(),
self.port.unwrap(),
prefix
)
.trim()
.to_string()
}
}
fn from_local_path(path: &Path) -> Result<ConfigurationMap<'_>, ConfigFileError> {
let expanded_s = shellexpand::tilde(&path.to_string_lossy()).to_string();
let expanded_path = PathBuf::from(&expanded_s);
if expanded_path.exists() {
read_from_local_path(&expanded_path)
} else {
Err(ConfigFileError::MissingFile((*expanded_path).to_path_buf()))
}
}
fn read_from_local_path(path: &PathBuf) -> Result<ConfigurationMap<'_>, ConfigFileError> {
let contents = fs::read_to_string(path)?;
toml::from_str(&contents).map_err(ConfigFileError::from)
}
fn default_tls() -> bool {
false
}
fn default_non_interactive() -> bool {
false
}
fn default_quiet() -> bool {
false
}
fn default_verbose() -> bool {
false
}
fn default_path_prefix() -> String {
"/api".to_string()
}
const PASSWORD_MASK: &str = "********";
#[derive(Debug, Clone, Tabled)]
pub struct ConfigPathEntry {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Tabled)]
pub struct NodeConfigEntry {
pub name: String,
pub hostname: String,
pub port: String,
pub scheme: String,
pub base_uri: String,
pub username: String,
pub password: String,
pub vhost: String,
pub path_prefix: String,
}
impl NodeConfigEntry {
pub fn from_settings_with_name(
name: &str,
settings: &SharedSettings,
reveal_password: bool,
) -> Self {
let password = if reveal_password {
settings.password.clone().unwrap_or_default()
} else {
PASSWORD_MASK.to_string()
};
Self {
name: name.to_string(),
hostname: settings.hostname.clone().unwrap_or_default(),
port: settings.port.map(|p| p.to_string()).unwrap_or_default(),
scheme: if settings.scheme == Scheme::Http {
String::new()
} else {
settings.scheme.to_string()
},
base_uri: settings.base_uri.clone().unwrap_or_default(),
username: settings.username.clone().unwrap_or_default(),
password,
vhost: settings.virtual_host.clone().unwrap_or_default(),
path_prefix: if settings.path_prefix == DEFAULT_PATH_PREFIX {
String::new()
} else {
settings.path_prefix.clone()
},
}
}
}
pub fn resolve_config_file_path(cli_path: Option<&PathBuf>) -> PathBuf {
let path = cli_path
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| crate::constants::DEFAULT_CONFIG_FILE_PATH.to_string());
let expanded = shellexpand::tilde(&path).to_string();
let path_buf = PathBuf::from(&expanded);
path_buf.canonicalize().unwrap_or(path_buf)
}
pub fn config_file_exists(path: &Path) -> bool {
let expanded_s = shellexpand::tilde(&path.to_string_lossy()).to_string();
let expanded_path = PathBuf::from(&expanded_s);
expanded_path.exists()
}
pub fn list_all_nodes(path: &Path) -> Result<Vec<(String, SharedSettings)>, ConfigFileError> {
let cm = from_local_path(path)?;
Ok(cm.into_iter().collect())
}
fn load_config_document(
path: &Path,
create_if_missing: bool,
) -> Result<(PathBuf, DocumentMut), ConfigFileError> {
let expanded_s = shellexpand::tilde(&path.to_string_lossy()).to_string();
let expanded_path = PathBuf::from(&expanded_s);
let contents = if expanded_path.exists() {
fs::read_to_string(&expanded_path)?
} else if create_if_missing {
if let Some(parent) = expanded_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
String::new()
} else {
return Err(ConfigFileError::MissingFile(expanded_path));
};
let doc = contents.parse::<DocumentMut>()?;
Ok((expanded_path, doc))
}
pub fn add_node_to_config_file(
path: &Path,
node_name: &str,
settings: &SharedSettings,
create_file_if_missing: bool,
) -> Result<(), ConfigFileError> {
let (expanded_path, mut doc) = load_config_document(path, create_file_if_missing)?;
if doc.contains_key(node_name) {
return Err(ConfigFileError::NodeAlreadyExists(node_name.to_string()));
}
let node_table = build_node_table(settings);
doc.insert(node_name, Item::Table(node_table));
fs::write(&expanded_path, doc.to_string())?;
Ok(())
}
pub fn update_node_in_config_file(
path: &Path,
node_name: &str,
settings: &SharedSettings,
create_file_if_missing: bool,
) -> Result<(), ConfigFileError> {
let (expanded_path, mut doc) = load_config_document(path, create_file_if_missing)?;
let existing_table = doc.get_mut(node_name).and_then(|item| item.as_table_mut());
if let Some(table) = existing_table {
merge_settings_into_table(table, settings);
} else {
let node_table = build_node_table(settings);
doc.insert(node_name, Item::Table(node_table));
}
fs::write(&expanded_path, doc.to_string())?;
Ok(())
}
fn apply_settings_to_table(table: &mut Table, settings: &SharedSettings, skip_defaults: bool) {
if let Some(ref base_uri) = settings.base_uri {
table.insert("base_uri", Value::from(base_uri.as_str()).into());
}
if let Some(ref hostname) = settings.hostname {
table.insert("hostname", Value::from(hostname.as_str()).into());
}
if let Some(port) = settings.port {
table.insert("port", Value::from(port as i64).into());
}
if let Some(ref username) = settings.username {
table.insert("username", Value::from(username.as_str()).into());
}
if let Some(ref password) = settings.password {
table.insert("password", Value::from(password.as_str()).into());
}
if let Some(ref vhost) = settings.virtual_host {
table.insert("virtual_host", Value::from(vhost.as_str()).into());
}
if settings.scheme != Scheme::Http {
table.insert("scheme", Value::from(settings.scheme.as_str()).into());
}
let include_path_prefix = if skip_defaults {
!settings.path_prefix.is_empty() && settings.path_prefix != DEFAULT_PATH_PREFIX
} else {
!settings.path_prefix.is_empty()
};
if include_path_prefix {
table.insert(
"path_prefix",
Value::from(settings.path_prefix.as_str()).into(),
);
}
if settings.tls {
table.insert("tls", Value::from(true).into());
}
if let Some(ref path) = settings.ca_certificate_bundle_path {
table.insert(
"ca_certificate_bundle_path",
Value::from(path.to_string_lossy().as_ref()).into(),
);
}
if let Some(ref path) = settings.client_certificate_file_path {
table.insert(
"client_certificate_file_path",
Value::from(path.to_string_lossy().as_ref()).into(),
);
}
if let Some(ref path) = settings.client_private_key_file_path {
table.insert(
"client_private_key_file_path",
Value::from(path.to_string_lossy().as_ref()).into(),
);
}
}
fn merge_settings_into_table(table: &mut Table, settings: &SharedSettings) {
apply_settings_to_table(table, settings, false);
}
fn build_node_table(settings: &SharedSettings) -> Table {
let mut table = Table::new();
apply_settings_to_table(&mut table, settings, true);
table
}
pub fn delete_node_from_config_file(
path: &Path,
node_name: &str,
create_file_if_missing: bool,
) -> Result<(), ConfigFileError> {
let (expanded_path, mut doc) = load_config_document(path, create_file_if_missing)?;
doc.remove(node_name);
fs::write(&expanded_path, doc.to_string())?;
Ok(())
}