use std::collections::BTreeMap;
use degenbot_config::writer::{remove_entry, remove_key, write_entry_with_env, write_key_with_env};
use degenbot_config::{BaseKind, KeyDecl, LoadedConfig, NodeTransport, Source, SCHEMA};
use crate::context::CliContext;
use crate::error::CliError;
use crate::prompt::{PromptPlan, Prompter};
use crate::report::{ConfigReport, ConfigValue};
use crate::strategy::MutationOutcome;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigCommand {
Show {
resolved: bool,
},
Path,
Get {
key: String,
},
Set {
key: String,
value: String,
force: bool,
},
Unset {
key: String,
force: bool,
},
}
impl ConfigCommand {
#[must_use]
pub const fn prompt_plan(&self, _ctx: &CliContext<'_>) -> PromptPlan {
match self {
Self::Set { .. } | Self::Unset { .. } => PromptPlan::UnlessForce,
Self::Show { .. } | Self::Path | Self::Get { .. } => PromptPlan::None,
}
}
}
pub(crate) fn execute(
command: ConfigCommand,
ctx: &CliContext<'_>,
prompter: &dyn Prompter,
) -> Result<ConfigReport, CliError> {
match command {
ConfigCommand::Path => Ok(ConfigReport::Path(ctx.resolve_config_file()?)),
ConfigCommand::Show { resolved } => show(ctx, resolved),
ConfigCommand::Get { key } => get(ctx, &key),
ConfigCommand::Set { key, value, force } => set(ctx, prompter, &key, &value, force),
ConfigCommand::Unset { key, force } => unset(ctx, prompter, &key, force),
}
}
enum ConfigTarget {
Scalar(&'static KeyDecl),
Entry(&'static KeyDecl, String),
}
fn get(ctx: &CliContext<'_>, key: &str) -> Result<ConfigReport, CliError> {
let wanted = inventory_key(key);
let ConfigReport::Shown { values, .. } = show(ctx, true)? else {
return Err(CliError::InvalidArgument(format!(
"{key:?} is not a driver-domain config value"
)));
};
let value = values
.into_iter()
.find(|value| value.key == wanted)
.ok_or_else(|| {
CliError::InvalidArgument(format!(
"{key:?} does not name a driver-domain config key: try database.path, \
session.chain_id, or a nodes.<transport>[.<chain>] entry"
))
})?;
Ok(ConfigReport::Got {
key: key.to_string(),
value: value.value,
source: value.source,
})
}
fn inventory_key(key: &str) -> String {
if let Some((table, entry)) = key.rsplit_once('.') {
if SCHEMA
.iter()
.any(|decl| decl.toml_path == table && matches!(decl.kind.base, BaseKind::StrMap))
{
return format!("{table}[{entry}]");
}
}
key.to_string()
}
fn set(
ctx: &CliContext<'_>,
prompter: &dyn Prompter,
key: &str,
value: &str,
force: bool,
) -> Result<ConfigReport, CliError> {
let file = ctx.resolve_config_file()?;
confirm_mutation(
prompter,
force,
&format!(
"Write {key} = {} to {}?",
degenbot_config::redact_uri(value),
file.display()
),
)?;
let target = resolve_target(key)?;
let outcome = match target {
ConfigTarget::Scalar(decl) => write_key_with_env(&file, decl, value, ctx.env()),
ConfigTarget::Entry(decl, entry) => {
write_entry_with_env(&file, decl, &entry, value, ctx.env())
}
}
.map_err(CliError::Config)?;
Ok(ConfigReport::Set {
key: key.to_string(),
value: value.to_string(),
outcome: outcome.into(),
})
}
fn unset(
ctx: &CliContext<'_>,
prompter: &dyn Prompter,
key: &str,
force: bool,
) -> Result<ConfigReport, CliError> {
let file = ctx.resolve_config_file()?;
confirm_mutation(
prompter,
force,
&format!("Remove {key} from {}?", file.display()),
)?;
let target = resolve_target(key)?;
let outcome = match target {
ConfigTarget::Scalar(decl) => {
remove_key(&file, decl).map_err(CliError::Config)?;
shadow_outcome(ctx, decl.env)
}
ConfigTarget::Entry(decl, entry) => {
remove_entry(&file, decl, &entry).map_err(CliError::Config)?;
shadow_outcome(ctx, &format!("{}{entry}", decl.env))
}
};
Ok(ConfigReport::Unset {
key: key.to_string(),
outcome,
})
}
fn resolve_target(path: &str) -> Result<ConfigTarget, CliError> {
if let Some(decl) = SCHEMA.iter().find(|decl| decl.toml_path == path) {
return Ok(ConfigTarget::Scalar(decl));
}
if let Some((table, entry)) = path.rsplit_once('.') {
if let Some(decl) = SCHEMA
.iter()
.find(|decl| decl.toml_path == table && matches!(decl.kind.base, BaseKind::StrMap))
{
if entry.is_empty() {
return Err(CliError::InvalidArgument(format!(
"{path:?} names an empty table entry"
)));
}
return Ok(ConfigTarget::Entry(decl, entry.to_string()));
}
}
Err(CliError::InvalidArgument(format!(
"{path:?} does not name a declared config key: use a dotted key \
(session.chain_id) or a str-map entry path (nodes.http.1)"
)))
}
fn confirm_mutation(prompter: &dyn Prompter, force: bool, message: &str) -> Result<(), CliError> {
if force || prompter.confirm(message, false) {
Ok(())
} else {
Err(CliError::Aborted)
}
}
fn shadow_outcome(ctx: &CliContext<'_>, env_name: &str) -> MutationOutcome {
if ctx
.env()
.get(env_name)
.is_some_and(|value| !value.is_empty())
{
MutationOutcome::Shadowed {
env: env_name.to_string(),
}
} else {
MutationOutcome::Applied
}
}
fn show(ctx: &CliContext<'_>, resolved: bool) -> Result<ConfigReport, CliError> {
let loaded = ctx.loaded_config()?;
let mut values = Vec::new();
database_path(ctx, resolved, &mut values);
session_chain_id(ctx, resolved, &mut values);
for transport in NodeTransport::ALL {
nodes(ctx, loaded, transport, resolved, &mut values);
}
Ok(ConfigReport::Shown {
file: ctx.resolve_config_file().ok(),
values,
resolved,
})
}
fn database_path(ctx: &CliContext<'_>, resolved: bool, out: &mut Vec<ConfigValue>) {
let path = ctx.database_path();
let Ok(database) = path else {
if resolved {
out.push(ConfigValue::unresolved("database.path"));
}
return;
};
if resolved || database.source == Source::File {
out.push(ConfigValue::new(
"database.path",
database.value.display().to_string(),
database.source,
));
}
}
fn session_chain_id(ctx: &CliContext<'_>, resolved: bool, out: &mut Vec<ConfigValue>) {
match ctx.chain_id() {
Ok(chain) => {
if resolved || chain.source == Source::File {
out.push(ConfigValue::new(
"session.chain_id",
chain.value.to_string(),
chain.source,
));
}
}
Err(_) if resolved => out.push(ConfigValue::unresolved("session.chain_id")),
Err(_) => {}
}
}
fn nodes(
ctx: &CliContext<'_>,
loaded: &LoadedConfig,
transport: NodeTransport,
resolved: bool,
out: &mut Vec<ConfigValue>,
) {
let mut present = false;
if let Some(uri) = ctx.node_overrides().get(transport) {
present = true;
out.push(ConfigValue::new(
transport.key_path(),
uri.to_string(),
Source::Cli,
));
}
for (chain, uri) in table(loaded, transport) {
let source = loaded
.entry_source_of(transport.env_prefix(), chain)
.unwrap_or(Source::File);
if !resolved && source != Source::File {
continue;
}
present = true;
out.push(ConfigValue::new(
&format!("{}[{chain}]", transport.key_path()),
uri.clone(),
source,
));
}
if resolved && !present {
out.push(ConfigValue::unresolved(transport.key_path()));
}
}
fn table(loaded: &LoadedConfig, transport: NodeTransport) -> &BTreeMap<String, String> {
static EMPTY: std::sync::OnceLock<BTreeMap<String, String>> = std::sync::OnceLock::new();
match transport {
NodeTransport::Http => loaded.config.nodes.http.as_ref(),
NodeTransport::Ws => loaded.config.nodes.ws.as_ref(),
NodeTransport::Ipc => loaded.config.nodes.ipc.as_ref(),
}
.unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new))
}