1use std::collections::BTreeMap;
21
22use degenbot_config::writer::{remove_entry, remove_key, write_entry_with_env, write_key_with_env};
23use degenbot_config::{BaseKind, KeyDecl, LoadedConfig, NodeTransport, Source, SCHEMA};
24
25use crate::context::CliContext;
26use crate::error::CliError;
27use crate::prompt::{PromptPlan, Prompter};
28use crate::report::{ConfigReport, ConfigValue};
29use crate::strategy::MutationOutcome;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ConfigCommand {
34 Show {
38 resolved: bool,
40 },
41 Path,
43 Get {
46 key: String,
48 },
49 Set {
52 key: String,
54 value: String,
56 force: bool,
58 },
59 Unset {
62 key: String,
64 force: bool,
66 },
67}
68
69impl ConfigCommand {
70 #[must_use]
73 pub const fn prompt_plan(&self, _ctx: &CliContext<'_>) -> PromptPlan {
74 match self {
75 Self::Set { .. } | Self::Unset { .. } => PromptPlan::UnlessForce,
76 Self::Show { .. } | Self::Path | Self::Get { .. } => PromptPlan::None,
77 }
78 }
79}
80
81pub(crate) fn execute(
90 command: ConfigCommand,
91 ctx: &CliContext<'_>,
92 prompter: &dyn Prompter,
93) -> Result<ConfigReport, CliError> {
94 match command {
95 ConfigCommand::Path => Ok(ConfigReport::Path(ctx.resolve_config_file()?)),
96 ConfigCommand::Show { resolved } => show(ctx, resolved),
97 ConfigCommand::Get { key } => get(ctx, &key),
98 ConfigCommand::Set { key, value, force } => set(ctx, prompter, &key, &value, force),
99 ConfigCommand::Unset { key, force } => unset(ctx, prompter, &key, force),
100 }
101}
102
103enum ConfigTarget {
106 Scalar(&'static KeyDecl),
108 Entry(&'static KeyDecl, String),
110}
111
112fn get(ctx: &CliContext<'_>, key: &str) -> Result<ConfigReport, CliError> {
115 let wanted = inventory_key(key);
116 let ConfigReport::Shown { values, .. } = show(ctx, true)? else {
117 return Err(CliError::InvalidArgument(format!(
118 "{key:?} is not a driver-domain config value"
119 )));
120 };
121 let value = values
122 .into_iter()
123 .find(|value| value.key == wanted)
124 .ok_or_else(|| {
125 CliError::InvalidArgument(format!(
126 "{key:?} does not name a driver-domain config key: try database.path, \
127 session.chain_id, or a nodes.<transport>[.<chain>] entry"
128 ))
129 })?;
130 Ok(ConfigReport::Got {
131 key: key.to_string(),
132 value: value.value,
133 source: value.source,
134 })
135}
136
137fn inventory_key(key: &str) -> String {
141 if let Some((table, entry)) = key.rsplit_once('.') {
142 if SCHEMA
143 .iter()
144 .any(|decl| decl.toml_path == table && matches!(decl.kind.base, BaseKind::StrMap))
145 {
146 return format!("{table}[{entry}]");
147 }
148 }
149 key.to_string()
150}
151
152fn set(
155 ctx: &CliContext<'_>,
156 prompter: &dyn Prompter,
157 key: &str,
158 value: &str,
159 force: bool,
160) -> Result<ConfigReport, CliError> {
161 let file = ctx.resolve_config_file()?;
162 confirm_mutation(
163 prompter,
164 force,
165 &format!(
166 "Write {key} = {} to {}?",
167 degenbot_config::redact_uri(value),
168 file.display()
169 ),
170 )?;
171 let target = resolve_target(key)?;
172 let outcome = match target {
173 ConfigTarget::Scalar(decl) => write_key_with_env(&file, decl, value, ctx.env()),
174 ConfigTarget::Entry(decl, entry) => {
175 write_entry_with_env(&file, decl, &entry, value, ctx.env())
176 }
177 }
178 .map_err(CliError::Config)?;
179 Ok(ConfigReport::Set {
180 key: key.to_string(),
181 value: value.to_string(),
182 outcome: outcome.into(),
183 })
184}
185
186fn unset(
189 ctx: &CliContext<'_>,
190 prompter: &dyn Prompter,
191 key: &str,
192 force: bool,
193) -> Result<ConfigReport, CliError> {
194 let file = ctx.resolve_config_file()?;
195 confirm_mutation(
196 prompter,
197 force,
198 &format!("Remove {key} from {}?", file.display()),
199 )?;
200 let target = resolve_target(key)?;
201 let outcome = match target {
202 ConfigTarget::Scalar(decl) => {
203 remove_key(&file, decl).map_err(CliError::Config)?;
204 shadow_outcome(ctx, decl.env)
205 }
206 ConfigTarget::Entry(decl, entry) => {
207 remove_entry(&file, decl, &entry).map_err(CliError::Config)?;
208 shadow_outcome(ctx, &format!("{}{entry}", decl.env))
209 }
210 };
211 Ok(ConfigReport::Unset {
212 key: key.to_string(),
213 outcome,
214 })
215}
216
217fn resolve_target(path: &str) -> Result<ConfigTarget, CliError> {
219 if let Some(decl) = SCHEMA.iter().find(|decl| decl.toml_path == path) {
220 return Ok(ConfigTarget::Scalar(decl));
221 }
222 if let Some((table, entry)) = path.rsplit_once('.') {
223 if let Some(decl) = SCHEMA
224 .iter()
225 .find(|decl| decl.toml_path == table && matches!(decl.kind.base, BaseKind::StrMap))
226 {
227 if entry.is_empty() {
228 return Err(CliError::InvalidArgument(format!(
229 "{path:?} names an empty table entry"
230 )));
231 }
232 return Ok(ConfigTarget::Entry(decl, entry.to_string()));
233 }
234 }
235 Err(CliError::InvalidArgument(format!(
236 "{path:?} does not name a declared config key: use a dotted key \
237 (session.chain_id) or a str-map entry path (nodes.http.1)"
238 )))
239}
240
241fn confirm_mutation(prompter: &dyn Prompter, force: bool, message: &str) -> Result<(), CliError> {
243 if force || prompter.confirm(message, false) {
244 Ok(())
245 } else {
246 Err(CliError::Aborted)
247 }
248}
249
250fn shadow_outcome(ctx: &CliContext<'_>, env_name: &str) -> MutationOutcome {
255 if ctx
256 .env()
257 .get(env_name)
258 .is_some_and(|value| !value.is_empty())
259 {
260 MutationOutcome::Shadowed {
261 env: env_name.to_string(),
262 }
263 } else {
264 MutationOutcome::Applied
265 }
266}
267
268fn show(ctx: &CliContext<'_>, resolved: bool) -> Result<ConfigReport, CliError> {
271 let loaded = ctx.loaded_config()?;
272 let mut values = Vec::new();
273 database_path(ctx, resolved, &mut values);
274 session_chain_id(ctx, resolved, &mut values);
275 for transport in NodeTransport::ALL {
276 nodes(ctx, loaded, transport, resolved, &mut values);
277 }
278 Ok(ConfigReport::Shown {
279 file: ctx.resolve_config_file().ok(),
280 values,
281 resolved,
282 })
283}
284
285fn database_path(ctx: &CliContext<'_>, resolved: bool, out: &mut Vec<ConfigValue>) {
288 let path = ctx.database_path();
289 let Ok(database) = path else {
290 if resolved {
293 out.push(ConfigValue::unresolved("database.path"));
294 }
295 return;
296 };
297 if resolved || database.source == Source::File {
298 out.push(ConfigValue::new(
299 "database.path",
300 database.value.display().to_string(),
301 database.source,
302 ));
303 }
304}
305
306fn session_chain_id(ctx: &CliContext<'_>, resolved: bool, out: &mut Vec<ConfigValue>) {
309 match ctx.chain_id() {
310 Ok(chain) => {
311 if resolved || chain.source == Source::File {
312 out.push(ConfigValue::new(
313 "session.chain_id",
314 chain.value.to_string(),
315 chain.source,
316 ));
317 }
318 }
319 Err(_) if resolved => out.push(ConfigValue::unresolved("session.chain_id")),
320 Err(_) => {}
321 }
322}
323
324fn nodes(
327 ctx: &CliContext<'_>,
328 loaded: &LoadedConfig,
329 transport: NodeTransport,
330 resolved: bool,
331 out: &mut Vec<ConfigValue>,
332) {
333 let mut present = false;
334 if let Some(uri) = ctx.node_overrides().get(transport) {
335 present = true;
336 out.push(ConfigValue::new(
337 transport.key_path(),
338 uri.to_string(),
339 Source::Cli,
340 ));
341 }
342 for (chain, uri) in table(loaded, transport) {
343 let source = loaded
344 .entry_source_of(transport.env_prefix(), chain)
345 .unwrap_or(Source::File);
346 if !resolved && source != Source::File {
347 continue;
348 }
349 present = true;
350 out.push(ConfigValue::new(
351 &format!("{}[{chain}]", transport.key_path()),
352 uri.clone(),
353 source,
354 ));
355 }
356 if resolved && !present {
357 out.push(ConfigValue::unresolved(transport.key_path()));
358 }
359}
360
361fn table(loaded: &LoadedConfig, transport: NodeTransport) -> &BTreeMap<String, String> {
363 static EMPTY: std::sync::OnceLock<BTreeMap<String, String>> = std::sync::OnceLock::new();
364 match transport {
365 NodeTransport::Http => loaded.config.nodes.http.as_ref(),
366 NodeTransport::Ws => loaded.config.nodes.ws.as_ref(),
367 NodeTransport::Ipc => loaded.config.nodes.ipc.as_ref(),
368 }
369 .unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new))
370}