use crate::{
cfg::cfg_dir,
generate::{
interactive::{prompt_for_choice, user_question},
project_variables::StringEntry,
},
id::ClusterSeed,
util::{
CommandOutput, DEFAULT_LATTICE_PREFIX, DEFAULT_NATS_HOST, DEFAULT_NATS_PORT,
DEFAULT_NATS_TIMEOUT_MS,
},
};
use clap::{Args, Subcommand};
use log::warn;
use serde_json::json;
use std::{
collections::HashMap,
fs::File,
io::{BufReader, Error, ErrorKind},
path::{Path, PathBuf},
process::Command,
};
pub mod context;
use anyhow::{bail, Context, Result};
use context::{DefaultContext, WashContext};
const CTX_DIR_NAME: &str = "contexts";
const INDEX_JSON: &str = "index.json";
const HOST_CONFIG_FILE: &str = "host_config.json";
const HOST_CONFIG_NAME: &str = "host_config";
pub(crate) async fn handle_command(ctx_cmd: CtxCommand) -> Result<CommandOutput> {
use CtxCommand::*;
match ctx_cmd {
List(cmd) => handle_list(cmd),
Default(cmd) => handle_default(cmd),
Edit(cmd) => handle_edit(cmd),
New(cmd) => handle_new(cmd),
Del(cmd) => handle_del(cmd),
}
}
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum CtxCommand {
#[clap(name = "list")]
List(ListCommand),
#[clap(name = "del")]
Del(DelCommand),
#[clap(name = "new")]
New(NewCommand),
#[clap(name = "default")]
Default(DefaultCommand),
#[clap(name = "edit")]
Edit(EditCommand),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ListCommand {
#[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
directory: Option<PathBuf>,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DelCommand {
#[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
directory: Option<PathBuf>,
#[clap(name = "name")]
name: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct NewCommand {
#[clap(name = "name", required_unless_present("interactive"))]
pub(crate) name: Option<String>,
#[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
directory: Option<PathBuf>,
#[clap(long = "interactive", short = 'i')]
interactive: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DefaultCommand {
#[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
directory: Option<PathBuf>,
#[clap(name = "name")]
name: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct EditCommand {
#[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
directory: Option<PathBuf>,
#[clap(name = "name")]
pub(crate) name: Option<String>,
#[clap(short = 'e', long = "editor", env = "EDITOR")]
pub(crate) editor: String,
}
fn handle_list(cmd: ListCommand) -> Result<CommandOutput> {
let dir = context_dir(cmd.directory)?;
let _ = ensure_host_config_context(&dir);
let index = get_index(&dir);
let contexts = context_filestems_from_path(get_contexts(&dir)?);
let text_contexts = match &index {
Ok(default_context) => contexts
.clone()
.into_iter()
.map(|f| {
if f == default_context.name {
format!("{} (default)", f)
} else {
f
}
})
.collect::<Vec<String>>()
.join("\n"),
Err(_) => contexts.join("\n"),
};
let mut map = HashMap::new();
map.insert("contexts".to_string(), json!(contexts));
map.insert(
"default".to_string(),
json!(index.map(|i| i.name).unwrap_or_else(|_| "N/A".to_string())),
);
Ok(CommandOutput::new(
format!(
"== Contexts found in {} ==\n{}",
dir.display(),
text_contexts
),
map,
))
}
fn handle_default(cmd: DefaultCommand) -> Result<CommandOutput> {
let dir = context_dir(cmd.directory)?;
let _ = ensure_host_config_context(&dir);
let contexts = get_contexts(&dir)?;
let new_default = cmd.name.unwrap_or_else(|| {
select_context(contexts.clone(), &dir, "Select a default context:").unwrap_or_default()
});
if contexts.contains(&context_path_from_name(&dir, &new_default)) {
set_default_context(&dir, new_default)?;
Ok(CommandOutput::from("Set new context successfully"))
} else {
Err(Error::new(
ErrorKind::NotFound,
"Failed to set new default, context supplied was not found".to_string(),
)
.into())
}
}
fn handle_del(cmd: DelCommand) -> Result<CommandOutput> {
let dir = context_dir(cmd.directory)?;
let contexts = get_contexts(&dir)?;
let ctx_to_delete = cmd.name.unwrap_or_else(|| {
select_context(contexts.clone(), &dir, "Select a context to delete:").unwrap_or_default()
});
let path = context_path_from_name(&dir, &ctx_to_delete);
if contexts.contains(&path) {
std::fs::remove_file(path)?;
Ok(CommandOutput::from("Removed file successfully"))
} else {
Err(Error::new(
ErrorKind::NotFound,
"Failed to delete, no context found".to_string(),
)
.into())
}
}
fn handle_new(cmd: NewCommand) -> Result<CommandOutput> {
let dir = context_dir(cmd.directory.clone())?;
let new_context = if cmd.interactive {
prompt_for_context()?
} else {
WashContext::named(cmd.name.unwrap())
};
let filename = format!("{}.json", new_context.name);
let options = sanitize_filename::Options {
truncate: true,
windows: true,
replacement: "_",
};
let sanitized = sanitize_filename::sanitize_with_options(filename, options);
let context_path = dir.join(sanitized.clone());
serde_json::to_writer(&File::create(context_path)?, &new_context)?;
Ok(CommandOutput::from(format!(
"Created context {} with default values",
sanitized
)))
}
fn handle_edit(cmd: EditCommand) -> Result<CommandOutput> {
let dir = context_dir(cmd.directory.clone())?;
let editor = which::which(cmd.editor)?;
let _ = ensure_host_config_context(&dir);
let ctx = if let Some(ctx) = cmd.name {
std::fs::metadata(context_path_from_name(&dir, &ctx))
.ok()
.map(|_| ctx)
} else {
let contexts = get_contexts(&dir)?;
select_context(contexts, &dir, "Select a context to edit:")
};
if let Some(ctx_name) = ctx {
if ctx_name == HOST_CONFIG_NAME {
warn!("Edits to the host_config context will be overwritten, make changes to the host config instead");
}
let status = Command::new(editor)
.arg(&context_path_from_name(&dir, &ctx_name))
.status()?;
match status.success() {
true => Ok(CommandOutput::from("Finished editing context successfully")),
false => bail!("Failed to edit context"),
}
} else {
Err(Error::new(
ErrorKind::NotFound,
"Unable to find context supplied, please ensure it exists".to_string(),
)
.into())
}
}
fn get_index(context_dir: &Path) -> Result<DefaultContext> {
let file = std::fs::File::open(context_dir.join(INDEX_JSON))?;
let reader = BufReader::new(file);
let index = serde_json::from_reader(reader)?;
Ok(index)
}
fn set_default_context(context_dir: &Path, default_context: String) -> Result<()> {
let index = DefaultContext::new(default_context);
let context_path = context_dir.join(INDEX_JSON);
serde_json::to_writer(&File::create(context_path)?, &index).map_err(|e| e.into())
}
pub(crate) fn get_default_context(context_dir: &Path) -> Result<WashContext> {
match get_index(context_dir) {
Ok(index) if index.name == HOST_CONFIG_NAME => {
create_host_config_context(context_dir)?;
load_context(&context_path_from_name(context_dir, HOST_CONFIG_NAME))
}
Ok(index) => load_context(&context_path_from_name(context_dir, &index.name)),
_ => {
create_host_config_context(context_dir)?;
set_default_context(context_dir, HOST_CONFIG_NAME.to_string())?;
load_context(&context_path_from_name(context_dir, HOST_CONFIG_NAME))
}
}
}
pub(crate) fn load_context(context_path: &Path) -> Result<WashContext> {
let file = std::fs::File::open(context_path)?;
let reader = BufReader::new(file);
let ctx = serde_json::from_reader(reader)?;
Ok(ctx)
}
fn ensure_host_config_context(context_dir: &Path) -> Result<()> {
create_host_config_context(context_dir)?;
if get_default_context(context_dir).is_err() {
set_default_context(context_dir, HOST_CONFIG_NAME.to_string())?;
}
Ok(())
}
fn create_host_config_context(context_dir: &Path) -> Result<()> {
let host_config_path = cfg_dir()?.join(HOST_CONFIG_FILE);
let host_config_ctx = WashContext {
name: HOST_CONFIG_NAME.to_string(),
..load_context(&host_config_path)?
};
let output_ctx_path = context_path_from_name(context_dir, HOST_CONFIG_NAME);
serde_json::to_writer(&File::create(output_ctx_path)?, &host_config_ctx).map_err(|e| e.into())
}
fn get_contexts(context_dir: &Path) -> Result<Vec<PathBuf>> {
let paths = std::fs::read_dir(context_dir)
.with_context(|| format!("please ensure directory {} exists", context_dir.display()))?;
let index = std::ffi::OsString::from(INDEX_JSON);
Ok(paths
.filter_map(|p| {
if let Ok(ctx_entry) = p {
let path = ctx_entry.path();
let ctx_filename = ctx_entry.file_name();
match path.extension().map(|os| os.to_str()).unwrap_or_default() {
Some("json") if ctx_filename == index => None,
Some("json") => Some(path),
_ => None,
}
} else {
None
}
})
.collect())
}
fn context_filestems_from_path(contexts: Vec<PathBuf>) -> Vec<String> {
contexts
.iter()
.filter_map(|p| {
p.file_stem()
.unwrap_or_default()
.to_os_string()
.into_string()
.ok()
})
.collect()
}
pub(crate) fn context_dir(cmd_dir: Option<PathBuf>) -> Result<PathBuf> {
let dir = if let Some(dir) = cmd_dir {
dir
} else {
cfg_dir()?.join(CTX_DIR_NAME)
};
if std::fs::metadata(&dir).is_err() {
let _ = std::fs::create_dir_all(&dir);
}
Ok(dir)
}
fn context_path_from_name(dir: &Path, name: &str) -> PathBuf {
dir.join(format!("{}.json", name))
}
fn select_context(contexts: Vec<PathBuf>, dir: &Path, prompt: &str) -> Option<String> {
let default = get_index(dir).ok().map(|i| i.name);
let choices: Vec<String> = context_filestems_from_path(contexts);
let entry = StringEntry {
default,
choices: Some(choices.clone()),
regex: None,
};
if let Ok(choice) = prompt_for_choice(&entry, prompt) {
choices.get(choice).map(|c| c.to_string())
} else {
None
}
}
fn prompt_for_context() -> Result<WashContext> {
let name = user_question(
"What do you want to name the context?",
&Some("default".to_string()),
)?;
let cluster_seed = match user_question(
"What cluster seed do you want to use to sign invocations?",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s.parse::<ClusterSeed>()?),
_ => None,
};
let ctl_host = user_question(
"What is the control interface connection host?",
&Some(DEFAULT_NATS_HOST.to_string()),
)?;
let ctl_port = user_question(
"What is the control interface connection port?",
&Some(DEFAULT_NATS_PORT.to_string()),
)?;
let ctl_jwt = match user_question(
"Enter your JWT that you use to authenticate to the control interface connection, if applicable",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s),
_ => None,
};
let ctl_seed = match user_question(
"Enter your user seed that you use to authenticate to the control interface connection, if applicable",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s),
_ => None,
};
let ctl_credsfile = match user_question(
"Enter the absolute path to control interface connection credsfile, if applicable",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s),
_ => None,
};
let ctl_timeout = user_question(
"What should the control interface timeout be (in milliseconds)?",
&Some(DEFAULT_NATS_TIMEOUT_MS.to_string()),
)?;
let ctl_lattice_prefix = user_question(
"What is the control interface connection lattice prefix?",
&Some(DEFAULT_LATTICE_PREFIX.to_string()),
)?;
let rpc_host = user_question(
"What is the RPC host?",
&Some(DEFAULT_NATS_HOST.to_string()),
)?;
let rpc_port = user_question(
"What is the RPC connection port?",
&Some(DEFAULT_NATS_PORT.to_string()),
)?;
let rpc_jwt = match user_question(
"Enter your JWT that you use to authenticate to the RPC connection, if applicable",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s),
_ => None,
};
let rpc_seed = match user_question(
"Enter your user seed that you use to authenticate to the RPC connection, if applicable",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s),
_ => None,
};
let rpc_credsfile = match user_question(
"Enter the absolute path to RPC connection credsfile, if applicable",
&Some("".to_string()),
) {
Ok(s) if s.is_empty() => None,
Ok(s) => Some(s),
_ => None,
};
let rpc_timeout = user_question(
"What should the RPC timeout be (in milliseconds)?",
&Some(DEFAULT_NATS_TIMEOUT_MS.to_string()),
)?;
let rpc_lattice_prefix = user_question(
"What is the RPC connection lattice prefix?",
&Some(DEFAULT_LATTICE_PREFIX.to_string()),
)?;
Ok(WashContext::new(
name,
cluster_seed,
ctl_host,
ctl_port.parse().unwrap_or_default(),
ctl_jwt,
ctl_seed,
ctl_credsfile.map(PathBuf::from),
ctl_timeout.parse()?,
ctl_lattice_prefix,
rpc_host,
rpc_port.parse().unwrap_or_default(),
rpc_jwt,
rpc_seed,
rpc_credsfile.map(PathBuf::from),
rpc_timeout.parse()?,
rpc_lattice_prefix,
))
}
#[cfg(test)]
mod test {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct Cmd {
#[clap(subcommand)]
cmd: CtxCommand,
}
#[test]
fn test_ctx_comprehensive() {
let cmd: Cmd = Parser::try_parse_from(&[
"ctx",
"new",
"my_name",
"--interactive",
"--directory",
"./contexts",
])
.unwrap();
match cmd.cmd {
CtxCommand::New(cmd) => {
assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
assert!(cmd.interactive);
assert_eq!(cmd.name.unwrap(), "my_name");
}
_ => panic!("ctx constructed incorrect command"),
}
let cmd: Cmd = Parser::try_parse_from(&[
"ctx",
"edit",
"my_context",
"--editor",
"vim",
"--directory",
"./contexts",
])
.unwrap();
match cmd.cmd {
CtxCommand::Edit(cmd) => {
assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
assert_eq!(cmd.editor, "vim");
assert_eq!(cmd.name.unwrap(), "my_context");
}
_ => panic!("ctx constructed incorrect command"),
}
let cmd: Cmd =
Parser::try_parse_from(&["ctx", "del", "my_context", "--directory", "./contexts"])
.unwrap();
match cmd.cmd {
CtxCommand::Del(cmd) => {
assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
assert_eq!(cmd.name.unwrap(), "my_context");
}
_ => panic!("ctx constructed incorrect command"),
}
let cmd: Cmd =
Parser::try_parse_from(&["ctx", "list", "--directory", "./contexts"]).unwrap();
match cmd.cmd {
CtxCommand::List(cmd) => {
assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
}
_ => panic!("ctx constructed incorrect command"),
}
let cmd: Cmd =
Parser::try_parse_from(&["ctx", "default", "host_config", "--directory", "./contexts"])
.unwrap();
match cmd.cmd {
CtxCommand::Default(cmd) => {
assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
assert_eq!(cmd.name.unwrap(), "host_config");
}
_ => panic!("ctx constructed incorrect command"),
}
}
}