Skip to main content

degenbot_cli_core/
config.rs

1//! The `config` command arms: the read-only view of the operator file.
2//!
3//! The console resolves its driver-domain values through the four-layer
4//! cascade, so "which value applies, and who supplied it" is the question an
5//! operator actually has. These arms answer it without ever writing:
6//!
7//! - `config show` lists what the operator FILE declares — the view an editor
8//!   and the mutating arms share, with no layer column.
9//! - `config show --resolved` lists the same keys as the process will resolve
10//!   them, each annotated with its winning [`Source`]; a key no layer supplied
11//!   is reported as unresolved rather than omitted, so the listing is a
12//!   complete inventory of the driver-domain surface.
13//! - `config path` prints the one file the mutating arms write (the
14//!   [`crate::context::CliContext::resolve_config_file`] contract).
15//!
16//! The endpoint tables are per-chain, so a table entry renders as
17//! `nodes.ws[8453] = …`; an explicit `--node` fills a whole transport's slot
18//! for the session chain and renders unindexed as `nodes.ws = … (cli)`.
19
20use 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/// The `config` command group.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ConfigCommand {
34    /// `config show`: the driver-domain values. `--resolved` renders every
35    /// key with the layer that supplied it; without it the report is the file
36    /// layer alone — what the operator wrote.
37    Show {
38        /// Whether to render the winning layer per key.
39        resolved: bool,
40    },
41    /// `config path`: the config file the mutating arms read and write.
42    Path,
43    /// `config get <key>`: the resolved value of one declared key (or one
44    /// `str_map` entry), with the layer that supplied it.
45    Get {
46        /// The dotted key or `table.entry` path.
47        key: String,
48    },
49    /// `config set <key> <value>`: write one declared key (or one `str_map`
50    /// entry) through the degenbot-config writer.
51    Set {
52        /// The dotted key or `table.entry` path.
53        key: String,
54        /// The raw value.
55        value: String,
56        /// Skip the confirmation prompt.
57        force: bool,
58    },
59    /// `config unset <key>`: remove one declared key's override (or one
60    /// `str_map` entry) so the declared default applies again.
61    Unset {
62        /// The dotted key or `table.entry` path.
63        key: String,
64        /// Skip the confirmation prompt.
65        force: bool,
66    },
67}
68
69impl ConfigCommand {
70    /// Read arms never prompt; a mutating arm confirms unless `--force`
71    /// (ADR-051 D4).
72    #[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
81/// Execute a `config` command.
82///
83/// # Errors
84///
85/// [`CliError::Config`] when the context's config layers do not load (a file
86/// the operator named that is unreadable, unparsable, or holds an invalid
87/// value), or [`CliError::InvalidArgument`] when no config file location
88/// resolves for `config path`.
89pub(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
103/// A key the config verb can address: one declared scalar key, or one entry
104/// of an operator-keyed `StrMap` table (spelled `table.entry`).
105enum ConfigTarget {
106    /// A whole declared key (`session.chain_id`, `nodes.http`).
107    Scalar(&'static KeyDecl),
108    /// One entry of an operator-keyed table (`nodes.http.1`).
109    Entry(&'static KeyDecl, String),
110}
111
112/// `config get`: read the value out of the very inventory `config show`
113/// renders, so the two arms cannot disagree about a value or its layer.
114fn 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
137/// Translate an operator-spelled entry path (`nodes.http.1`) into the
138/// inventory's bracketed spelling (`nodes.http[1]`); every other key passes
139/// through unchanged.
140fn 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
152/// `config set`: confirm (unless forced) then write through the single
153/// validate-before-write path.
154fn 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
186/// `config unset`: confirm (unless forced) then drop the override so the
187/// declared default applies again.
188fn 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
217/// Resolve one operator-spelled key into a declared key or a `StrMap` entry.
218fn 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
241/// Ask before mutating unless `--force` was given.
242fn 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
250/// Whether the env layer still supplies `env_name` after a removal: the
251/// environment wins at load time even though the file override is gone, which
252/// the report must say rather than let the operator believe the default took
253/// over.
254fn 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
268/// The `show` arm over the context's ONE loaded config, so every line reports
269/// the same load the resolvers read.
270fn 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
285/// `database.path`: `--database` > `DEGENBOT_DB_PATH` > the declared key >
286/// the state-home default, so the line is never absent in the resolved view.
287fn database_path(ctx: &CliContext<'_>, resolved: bool, out: &mut Vec<ConfigValue>) {
288    let path = ctx.database_path();
289    let Ok(database) = path else {
290        // The default is declared, so this arm cannot lose it; an unresolvable
291        // layers set is reported the same way as any other absent value.
292        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
306/// `session.chain_id`: the one driver-domain key with no default, so its
307/// absence is the common misconfiguration and the listing says so.
308fn 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
324/// One transport's endpoint surface: the explicit slot, then the loaded
325/// per-chain entries, each keeping the layer that supplied it.
326fn 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
361/// The loaded entries of one transport's endpoint table.
362fn 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}