Skip to main content

degenbot_cli/
argv.rs

1//! The clap v4 argv tree and the argv -> [`Command`] mapping (ADR-051 D2).
2//!
3//! This is the ONE place argv is spelled. The mapping below translates clap
4//! matches into the constructor-parsed values of `degenbot-cli-core` and never
5//! re-encodes semantics: every arm, flag, prompt and exit code lives there.
6//!
7//! # Where clap's shape differs from the retired click tree
8//!
9//! - **`exchange` (ADR-051 D5, 34 click verbs -> one data-driven command).**
10//!   Python declared one verb per `(chain, DEX)` pair (`base_aerodrome_v2`,
11//!   `ethereum_uniswap_v3`, …). cli-core models that as
12//!   `exchange activate|deactivate --chain <slug|id> --name <dex>`, resolving
13//!   the pair against [`RETIRED_EXCHANGES`](degenbot_cli_core::RETIRED_EXCHANGES).
14//! - **`aave activate|deactivate`.** Python spelled the market as a verb
15//!   (`aave activate ethereum_aave_v3`); cli-core takes `chain_id` and resolves
16//!   the deployment from [`AAVE_DEPLOYMENTS`](degenbot_cli_core::AAVE_DEPLOYMENTS),
17//!   so the tree is `aave activate [--chain-id <id>]` with Python's Ethereum
18//!   default (1) when no chain layer supplied a value.
19//! - **`aave position show <ADDRESS>`.** The click shape is kept, except that
20//!   its chain selector IS the global `--chain-id` (ADR-051 D8) rather than a
21//!   second, position-local spelling of the same thing; cli-core's arm takes
22//!   the resolved session chain id.
23//! - **`--socket`.** cli-core resolves the operator socket through the cascade
24//!   `--socket` > `DEGENBOT_OPERATOR_SOCKET` > `~/.config/degenbot/operator.sock`
25//!   ([`resolve_socket`](degenbot_cli_core::resolve_socket)); click declared
26//!   `--socket` required. The flag is therefore optional here - cli-core owns
27//!   the cascade.
28//! - **`database reset`.** Python marks it `hidden=True`. The command is
29//!   present and visible here (cli-core carries the arm; hiding it would make
30//!   the Rust tree the only place the arm is unreachable).
31//! - **Flag help text** is the Python help text, so the two surfaces read the
32//!   same. The per-flag click `envvar=` fallbacks (`DEGENBOT_CHUNK_SIZE`, …) are
33//!   NOT re-added: cli-core models these inputs as argv only, and the only env
34//!   vocabulary the console owns is the driver-domain set of ADR-051 D8.
35
36use std::io::Write as _;
37
38use clap::{ArgAction, Args, CommandFactory as _, Parser, Subcommand, ValueEnum};
39use degenbot_cli_core::{
40    AaveCommand, CliContext, CliError, Command, ConfigCommand, DatabaseCommand, ExchangeCommand,
41    FleetCommand, PathCommand, PathDirection, PoolCommand, PoolFamily, PosturePatchEntry,
42    StrategyCommand, StrategyFacet, DEFAULT_CHUNK_SIZE, DEFAULT_TO_BLOCK,
43    DEFAULT_VERIFY_ALL_INTERVAL,
44};
45use degenbot_config::{EnvVars, NodeTransport, ProcessEnv, DEFAULT_CHAIN_ID_ENV};
46
47use crate::VERSION_LINE;
48
49/// The degenbot console.
50#[derive(Debug, Parser)]
51#[command(
52    name = "degenbot",
53    about = "Perform cli.",
54    version = VERSION_LINE,
55    arg_required_else_help = true,
56    disable_help_subcommand = true
57)]
58pub struct Cli {
59    /// The command to run.
60    #[command(subcommand)]
61    pub command: Option<Commands>,
62
63    /// Path to the SQLite database (`--database` > `DEGENBOT_DB_PATH` >
64    /// `<state_home>/degenbot/db/degenbot.db`).
65    #[arg(long, global = true, value_name = "PATH")]
66    pub database: Option<String>,
67
68    /// Session chain id (`--chain-id` > `DEGENBOT_DEFAULT_CHAIN_ID`).
69    #[arg(long, global = true, value_name = "CHAIN_ID")]
70    pub chain_id: Option<String>,
71
72    /// Node RPC endpoint; the value's own scheme picks its transport
73    /// (`wss://` > ws, `http://` > http, `ipc://` or a socket path > ipc).
74    /// Repeat it to set one endpoint per transport. The explicit layer
75    /// outranks the per-chain endpoint env families and the `nodes.*` file
76    /// tables.
77    #[arg(long = "node", global = true, value_name = "URI", value_parser = parse_node)]
78    pub node: Vec<NodeArg>,
79
80    /// Typed config file the `strategy` verbs read and write (`--config` >
81    /// `DEGENBOT_CONFIG` > the XDG config home).
82    #[arg(long, global = true, value_name = "PATH")]
83    pub config: Option<String>,
84}
85
86/// The command groups.
87#[derive(Debug, Subcommand)]
88pub enum Commands {
89    /// Database commands.
90    Database {
91        /// The database command.
92        #[command(subcommand)]
93        command: DatabaseSub,
94    },
95    /// Exchange commands.
96    Exchange {
97        /// The exchange command.
98        #[command(subcommand)]
99        command: ExchangeSub,
100    },
101    /// Pool commands.
102    Pool {
103        /// The pool command.
104        #[command(subcommand)]
105        command: PoolSub,
106    },
107    /// Aave commands.
108    Aave {
109        /// The aave command.
110        #[command(subcommand)]
111        command: AaveSub,
112    },
113    /// Steer the live worker fleet over the operator command channel.
114    Fleet {
115        /// The fleet command.
116        #[command(subcommand)]
117        command: FleetSub,
118    },
119    /// Steer a live bot over the operator command channel.
120    Path {
121        /// The path command.
122        #[command(subcommand)]
123        command: PathSub,
124    },
125    /// Inspect the operator config file and the values it resolves to.
126    Config {
127        /// The config command.
128        #[command(subcommand)]
129        command: ConfigSub,
130    },
131    /// Inspect the typed strategy facets (ADR-055).
132    Strategy {
133        /// The strategy command.
134        #[command(subcommand)]
135        command: StrategySub,
136    },
137}
138
139/// The `database` command group.
140#[derive(Debug, Subcommand)]
141pub enum DatabaseSub {
142    /// Back up the database.
143    Backup,
144    /// Remove and recreate the database.
145    Reset {
146        /// Skip confirmation prompt.
147        #[arg(long)]
148        force: bool,
149    },
150    /// Upgrade the database to the latest schema (RETIRED: the database
151    /// upgrades itself at open; `database heal` is the explicit repair).
152    Upgrade {
153        /// Skip confirmation prompt.
154        #[arg(long)]
155        force: bool,
156    },
157    /// Compact the database.
158    Compact,
159    /// Flip an Alembic-stamped DB into Rust schema ownership (ADR-010).
160    Cutover {
161        /// Report the schema state + what cutover would do; write nothing.
162        #[arg(long)]
163        dry_run: bool,
164        /// Skip the confirmation prompt and run the cutover.
165        #[arg(long)]
166        force: bool,
167    },
168    /// Rebuild an Alembic-stamped DB into Rust ownership via dump-and-restore
169    /// (ADR-011).
170    Heal {
171        /// Report the schema state + what heal would do; write nothing.
172        #[arg(long)]
173        dry_run: bool,
174        /// Skip the confirmation prompt and run the heal.
175        #[arg(long)]
176        force: bool,
177    },
178    /// Inspect the database schema state (read-only; never writes).
179    Inspect,
180}
181
182/// The `exchange` command group.
183#[derive(Debug, Subcommand)]
184pub enum ExchangeSub {
185    /// Activate the exchange. Liquidity pools for all activated exchanges are
186    /// included when running "pool update".
187    Activate {
188        /// The chain selector: a chain slug (`base`, `ethereum`) or a numeric
189        /// chain id.
190        #[arg(long, value_name = "CHAIN")]
191        chain: String,
192        /// The DEX name slug stored in the database (`aerodrome_v2`,
193        /// `uniswap_v3`, …).
194        #[arg(long, value_name = "NAME")]
195        name: String,
196    },
197    /// Deactivate the exchange. Liquidity pools for all deactivated exchanges
198    /// are not included when running "pool update".
199    Deactivate {
200        /// The chain selector: a chain slug (`base`, `ethereum`) or a numeric
201        /// chain id.
202        #[arg(long, value_name = "CHAIN")]
203        chain: String,
204        /// The DEX name slug stored in the database.
205        #[arg(long, value_name = "NAME")]
206        name: String,
207    },
208    /// List the supported exchanges and their activation state.
209    List {
210        /// Restrict the list to one chain (a chain slug or numeric id).
211        #[arg(long, value_name = "CHAIN")]
212        chain: Option<String>,
213    },
214}
215
216/// The `pool` command group.
217#[derive(Debug, Subcommand)]
218pub enum PoolSub {
219    /// Update liquidity pool information for activated exchanges.
220    Update {
221        /// The maximum number of blocks to process before committing changes to
222        /// the database.
223        #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
224        chunk_size: u64,
225        /// The last block in the update range. Must be a valid block
226        /// identifier: 'earliest', 'finalized', 'safe', 'latest', 'pending'.
227        /// An identifier can be given with an optional offset, e.g. 'latest:-64'
228        /// stops 64 blocks before the chain tip, 'safe:128' stops 128 blocks
229        /// after the last 'safe' block.
230        #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
231        to_block: String,
232        /// The pre-commit verification gates.
233        #[command(flatten)]
234        verify: VerifyFlags,
235        /// Block interval for the `--verify-all` full-verification gate. A chunk
236        /// that crosses or lands-on a multiple of this interval triggers a
237        /// pre-commit market-wide verify. Ignored unless `--verify-all` is set.
238        #[arg(
239            long = "verify-all-interval",
240            value_name = "BLOCKS",
241            default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
242        )]
243        verify_all_interval: u64,
244    },
245    /// Verify a pool's committed liquidity map against on-chain truth.
246    Verify {
247        /// The HTTP RPC endpoint to read on-chain truth from.
248        #[arg(long = "rpc-url", value_name = "URL", required = true)]
249        rpc_url: String,
250        /// The chain id the pool lives on. The Rust field is named
251        /// `pool_chain_id` so its clap arg id cannot collide with the global
252        /// `--chain-id` driver flag; the argv spelling stays `--chain`.
253        #[arg(long = "chain", value_name = "CHAIN_ID", required = true)]
254        pool_chain_id: i64,
255        /// The block number to read on-chain truth at.
256        #[arg(long = "block", value_name = "BLOCK", required = true)]
257        block_number: u64,
258        #[arg(
259            long = "pool",
260            value_name = "POOL",
261            required = true,
262            help = "The pool to verify. V3: the pool contract address. V4: the PoolId (pool_hash, bytes32 hex 0x…)."
263        )]
264        pool: String,
265        #[arg(
266            long = "family",
267            value_enum,
268            required = true,
269            help = "The pool family (selects ticks()/tickBitmap() vs PoolManager extsload)."
270        )]
271        family: FamilyArg,
272        #[arg(
273            long = "pool-manager",
274            value_name = "ADDRESS",
275            help = "(V4 only) The deployed V4 PoolManager singleton address (the V4 exchange's factory). Required for --family v4."
276        )]
277        pool_manager: Option<String>,
278    },
279}
280
281/// The `--verify-chunk` / `--verify-all` gate pairs shared by both updaters.
282#[derive(Debug, Clone, Args)]
283#[expect(
284    clippy::struct_excessive_bools,
285    reason = "the two on/off gate pairs are argv vocabulary, not state: each pair is two mutually-overriding flags"
286)]
287pub struct VerifyFlags {
288    /// Run the pre-commit per-chunk on-chain-truth gate before each chunk's
289    /// persist commits. A divergence rolls back the chunk + does NOT advance
290    /// `last_update_block`.
291    #[arg(long = "verify-chunk", action = ArgAction::SetTrue, overrides_with = "no_verify_chunk")]
292    pub verify_chunk: bool,
293    /// Disable the pre-commit per-chunk on-chain-truth gate.
294    #[arg(long = "no-verify-chunk", action = ArgAction::SetTrue, overrides_with = "verify_chunk")]
295    pub no_verify_chunk: bool,
296    /// Run a pre-commit FULL verification at the block boundary set by
297    /// `--verify-all-interval` AND when the run completes its last block. Off
298    /// by default (operator opt-in).
299    #[arg(long = "verify-all", action = ArgAction::SetTrue, overrides_with = "no_verify_all")]
300    pub verify_all: bool,
301    /// Disable the market-wide `--verify-all` gate.
302    #[arg(long = "no-verify-all", action = ArgAction::SetTrue, overrides_with = "verify_all")]
303    pub no_verify_all: bool,
304}
305
306impl VerifyFlags {
307    /// The per-chunk gate value: default ON, `--no-verify-chunk` turns it off.
308    #[must_use]
309    pub const fn chunk_gate(&self) -> bool {
310        self.verify_chunk || !self.no_verify_chunk
311    }
312
313    /// The market-wide gate value: default OFF, `--verify-all` turns it on.
314    #[must_use]
315    pub const fn all_gate(&self) -> bool {
316        self.verify_all && !self.no_verify_all
317    }
318}
319
320/// The pool family selector.
321#[derive(Debug, Clone, Copy, ValueEnum)]
322pub enum FamilyArg {
323    /// V3 (`ticks()`/`tickBitmap()`).
324    V3,
325    /// V4 (`PoolManager` `extsload`).
326    V4,
327}
328
329/// The `aave` command group.
330#[derive(Debug, Subcommand)]
331pub enum AaveSub {
332    /// Activate an Aave market. Positions for activated markets are included
333    /// when running `degenbot aave position update`.
334    Activate,
335    /// Deactivate an Aave market. Positions for deactivated markets are not
336    /// included when running `degenbot aave position update`.
337    Deactivate {
338        /// Market name to flip (default: Aave Ethereum Market).
339        #[arg(
340            long = "name",
341            value_name = "MARKET",
342            default_value = "Aave Ethereum Market"
343        )]
344        market_name: String,
345    },
346    /// Update positions for active Aave markets.
347    Update {
348        /// The maximum number of blocks to process before committing changes to
349        /// the database.
350        #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
351        chunk_size: u64,
352        /// The last block in the update range. Must be a valid block
353        /// identifier: 'earliest', 'finalized', 'safe', 'latest', 'pending'.
354        /// An identifier can be given with an optional offset, e.g. 'latest:-64'
355        /// stops 64 blocks before the chain tip, 'safe:128' stops 128 blocks
356        /// after the last 'safe' block.
357        #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
358        to_block: String,
359        /// The pre-commit verification gates.
360        #[command(flatten)]
361        verify: VerifyFlags,
362        /// Block interval for the `--verify-all` full-verification gate. A chunk
363        /// that crosses or lands-on a multiple of this interval triggers a
364        /// pre-commit market-wide verify. Ignored unless `--verify-all` is set.
365        #[arg(
366            long = "verify-all-interval",
367            value_name = "BLOCKS",
368            default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
369        )]
370        verify_all_interval: u64,
371        /// Stop processing after the first chunk.
372        #[arg(long = "one-chunk", action = ArgAction::SetTrue)]
373        stop_after_one_chunk: bool,
374        /// Preview changes without committing to the database.
375        #[arg(long = "dry-run", action = ArgAction::SetTrue)]
376        dry_run: bool,
377        /// Create a database backup after the completion verification runs
378        /// (once per market, at the end of the run).
379        #[arg(long = "backup", action = ArgAction::SetTrue, overrides_with = "no_backup")]
380        backup: bool,
381        /// Disable the completion backup.
382        #[arg(long = "no-backup", action = ArgAction::SetTrue, overrides_with = "backup")]
383        no_backup: bool,
384    },
385    /// Position commands.
386    Position {
387        /// The position command.
388        #[command(subcommand)]
389        command: AavePositionSub,
390    },
391}
392
393/// The `aave position` command group.
394#[derive(Debug, Subcommand)]
395pub enum AavePositionSub {
396    /// Display current Aave positions for a user.
397    Show {
398        /// The user address.
399        #[arg(value_name = "ADDRESS")]
400        address: String,
401        /// Market name to query (default: Aave Ethereum Market).
402        #[arg(
403            long = "market",
404            value_name = "MARKET",
405            default_value = "Aave Ethereum Market"
406        )]
407        market: String,
408    },
409}
410
411/// The `fleet` command group.
412#[derive(Debug, Subcommand)]
413pub enum FleetSub {
414    /// Inspect or re-tune the live cordon posture thresholds.
415    Posture {
416        /// The posture command.
417        #[command(subcommand)]
418        command: FleetPostureSub,
419    },
420}
421
422/// The `fleet posture` command group.
423#[derive(Debug, Subcommand)]
424pub enum FleetPostureSub {
425    /// Show the LIVE cordon posture: thresholds + Nominal|Cordoned.
426    Show {
427        #[arg(
428            long,
429            value_name = "PATH",
430            help = "Unix domain socket path of the running bot's OperatorServer."
431        )]
432        socket: Option<String>,
433    },
434    /// Re-tune the LIVE cordon thresholds (a partial patch).
435    Set {
436        #[arg(
437            long,
438            value_name = "PATH",
439            help = "Unix domain socket path of the running bot's OperatorServer."
440        )]
441        socket: Option<String>,
442        /// Throttle events within the enter window that cordon the fleet.
443        #[arg(long = "cordon-enter-events", value_name = "COUNT")]
444        cordon_enter_events: Option<u64>,
445        /// Throttled-time duty percent over the duty window that cordons the
446        /// fleet.
447        #[arg(long = "cordon-duty-percent", value_name = "PERCENT")]
448        cordon_duty_percent: Option<f64>,
449        /// Rolling window (ms) for the throttle-event burst enter trigger.
450        #[arg(long = "cordon-enter-window-ms", value_name = "MS")]
451        cordon_enter_window_ms: Option<u64>,
452        /// Trailing window (ms) over which throttled-time duty is evaluated.
453        #[arg(long = "cordon-duty-window-ms", value_name = "MS")]
454        cordon_duty_window_ms: Option<u64>,
455        /// Clean-window hysteresis (ms) required before cordon exits.
456        #[arg(long = "cordon-exit-clean-ms", value_name = "MS")]
457        cordon_exit_clean_ms: Option<u64>,
458        #[arg(
459            long = "cordon-sim-intake-floor",
460            value_name = "COUNT|null",
461            help = "SimDriver new-lease cap while cordoned. The literal null restores half the slot cap."
462        )]
463        cordon_sim_intake_floor: Option<String>,
464    },
465}
466
467/// The `path` command group.
468#[derive(Debug, Subcommand)]
469pub enum PathSub {
470    /// Add ONE specific path to the live bot mid-run.
471    Add {
472        #[arg(
473            long,
474            value_name = "PATH",
475            help = "Unix domain socket path of the running bot's OperatorServer."
476        )]
477        socket: Option<String>,
478        /// A path hop as FAMILY:ADDRESS, or V4:ADDRESS:HASH (pool id). Repeat
479        /// for each hop, in path order.
480        #[arg(long = "hop", value_name = "HOP", required = true)]
481        hops: Vec<String>,
482        /// Applied to every hop when set: 'zfo' (zero-for-one, True for each
483        /// hop) or 'ozf' (one-for-zero, False for each hop). Omit to let the bot
484        /// auto-resolve directions.
485        #[arg(long = "direction", value_enum)]
486        direction: Option<DirectionArg>,
487    },
488    /// Trigger a bounded on-demand discovery sweep on the live bot.
489    Discover {
490        #[arg(
491            long,
492            value_name = "PATH",
493            help = "Unix domain socket path of the running bot's OperatorServer."
494        )]
495        socket: Option<String>,
496        /// Maximum number of paths to process in this discovery sweep.
497        #[arg(long = "bound", value_name = "COUNT")]
498        bound: Option<u64>,
499    },
500}
501
502/// The `--direction` choice on `path add`.
503#[derive(Debug, Clone, Copy, ValueEnum)]
504pub enum DirectionArg {
505    /// Zero-for-one.
506    Zfo,
507    /// One-for-zero.
508    Ozf,
509}
510
511/// The `strategy` command group (ADR-055 facets): the strategy activation
512/// and parameter surface over the typed config.
513#[derive(Debug, Subcommand)]
514pub enum StrategySub {
515    /// List the declared strategy facets.
516    List,
517    /// Show one strategy facet: declared keys, activation, and the settled
518    /// endpoint posture.
519    Show {
520        /// The facet to show.
521        #[arg(value_enum)]
522        facet: FacetArg,
523    },
524    /// Activate a strategy and settle its endpoint posture. Exactly one of
525    /// `--endpoints` / `--endpoints-default` unless the facet already carries
526    /// a settled choice.
527    Activate {
528        /// The facet to activate.
529        #[arg(value_enum)]
530        facet: FacetArg,
531        /// The explicit endpoint set (comma-separated URLs).
532        #[arg(
533            long = "endpoints",
534            value_name = "URLS",
535            conflicts_with = "endpoints_default"
536        )]
537        endpoints: Option<String>,
538        /// Adopt the documented default endpoint set.
539        #[arg(long = "endpoints-default")]
540        endpoints_default: bool,
541    },
542    /// Deactivate a strategy (its recorded endpoint choice is kept).
543    Deactivate {
544        /// The facet to deactivate.
545        #[arg(value_enum)]
546        facet: FacetArg,
547    },
548    /// Set one declared facet key.
549    Set {
550        /// The facet to mutate.
551        #[arg(value_enum)]
552        facet: FacetArg,
553        /// The config key name.
554        key: String,
555        /// The raw value.
556        value: String,
557    },
558    /// Drop one key's override so the declared default applies again
559    /// ("set the default if you have no preference").
560    Default {
561        /// The facet to mutate.
562        #[arg(value_enum)]
563        facet: FacetArg,
564        /// The config key name.
565        key: String,
566    },
567    /// The `default` verb's traditional spelling.
568    Remove {
569        /// The facet to mutate.
570        #[arg(value_enum)]
571        facet: FacetArg,
572        /// The config key name.
573        key: String,
574    },
575}
576
577/// The `config` command group: the read-only view of the operator file.
578#[derive(Debug, Subcommand)]
579pub enum ConfigSub {
580    /// Show the driver-domain values: the file's own with no flag, or every
581    /// layer's winning value (with its source) under `--resolved`.
582    Show {
583        /// Render each value with the layer that supplied it.
584        #[arg(long)]
585        resolved: bool,
586    },
587    /// Print the config file the mutating arms read and write.
588    Path,
589    /// Print one resolved driver-domain value and the layer that supplied it.
590    Get {
591        /// The dotted key or `table.entry` path (e.g. `nodes.http.1`).
592        key: String,
593    },
594    /// Write one declared key or `str_map` entry through the config writer.
595    Set {
596        /// The dotted key or `table.entry` path.
597        key: String,
598        /// The raw value.
599        value: String,
600        /// Skip the confirmation prompt.
601        #[arg(long)]
602        force: bool,
603    },
604    /// Remove one key or `str_map` entry override so the default applies.
605    Unset {
606        /// The dotted key or `table.entry` path.
607        key: String,
608        /// Skip the confirmation prompt.
609        #[arg(long)]
610        force: bool,
611    },
612}
613
614/// The strategy facet selector.
615#[derive(Debug, Clone, Copy, ValueEnum)]
616pub enum FacetArg {
617    /// The settled-block strategy.
618    Settlement,
619    /// The MEVBlocker-ecosystem pending-transaction strategy.
620    #[value(name = "mevblocker_backrun")]
621    MevblockerBackrun,
622    /// The public-mempool pending-transaction strategy.
623    #[value(name = "txpool_backrun")]
624    TxpoolBackrun,
625}
626
627/// One `--node` occurrence: the endpoint, plus the transport its own value
628/// classified as (ADR-062 D6). clap carries the classification so a value no
629/// transport can serve is a usage error at parse time, not a later refusal.
630#[derive(Debug, Clone, PartialEq, Eq)]
631pub struct NodeArg {
632    /// The endpoint verbatim.
633    pub uri: String,
634    /// The transport the value serves.
635    pub transport: NodeTransport,
636}
637
638/// Classify one `--node` value into its transport.
639///
640/// # Errors
641///
642/// A usage message naming the three accepted forms, which clap renders as an
643/// invalid-value error (exit 2).
644fn parse_node(raw: &str) -> Result<NodeArg, String> {
645    let Some(transport) = NodeTransport::classify(raw) else {
646        let forms: Vec<String> = NodeTransport::ALL
647            .iter()
648            .map(|transport| format!("{} — {}", transport.key_path(), transport.expected()))
649            .collect();
650        return Err(format!(
651            "{raw:?} names no node transport; accepted forms: {}",
652            forms.join("; ")
653        ));
654    };
655    Ok(NodeArg {
656        uri: raw.to_string(),
657        transport,
658    })
659}
660
661/// The [`CliContext`] the argv overrides describe (ADR-051 D8).
662#[must_use]
663pub fn context<'a>(cli: &Cli, env: &'a dyn EnvVars) -> CliContext<'a> {
664    let mut ctx = CliContext::new(env);
665    if let Some(database) = &cli.database {
666        ctx = ctx.with_database(database.clone());
667    }
668    if let Some(chain_id) = &cli.chain_id {
669        ctx = ctx.with_chain_id(chain_id.clone());
670    }
671    for node in &cli.node {
672        ctx = ctx.with_node(node.uri.clone(), node.transport);
673    }
674    if let Some(config) = &cli.config {
675        ctx = ctx.with_config(config.clone());
676    }
677    ctx
678}
679
680/// Map argv into a [`Command`], reading the driver-domain env through the real
681/// process environment.
682///
683/// # Errors
684///
685/// [`CliError`] when an arm needs a driver-domain value no layer supplied, or a
686/// malformed `--chain-id` / `cordon_sim_intake_floor`.
687pub fn resolve(cli: &Cli) -> Result<Command, CliError> {
688    resolve_with_env(cli, &ProcessEnv)
689}
690
691/// Map argv into a [`Command`] over an injectable env seam (tests never mutate
692/// the process environment).
693///
694/// # Errors
695///
696/// As [`resolve`].
697///
698/// # Panics
699///
700/// Never: a missing subcommand is a typed [`CliError::InvalidArgument`].
701pub fn resolve_with_env(cli: &Cli, env: &dyn EnvVars) -> Result<Command, CliError> {
702    let ctx = context(cli, env);
703    let Some(command) = &cli.command else {
704        return Err(CliError::InvalidArgument(
705            "a subcommand is required".to_string(),
706        ));
707    };
708    match command {
709        Commands::Database { command } => Ok(Command::Database(database(command))),
710        Commands::Exchange { command } => Ok(Command::Exchange(exchange(command))),
711        Commands::Pool { command } => Ok(Command::Pool(pool(command))),
712        Commands::Aave { command } => Ok(Command::Aave(aave(command, cli, &ctx)?)),
713        Commands::Fleet { command } => Ok(Command::Fleet(fleet(command)?)),
714        Commands::Path { command } => Ok(Command::Path(path(command))),
715        Commands::Strategy { command } => Ok(Command::Strategy(strategy(command))),
716        Commands::Config { command } => Ok(Command::Config(config(command))),
717    }
718}
719
720/// Print clap's own usage block for the no-subcommand-but-args case.
721pub fn write_missing_subcommand_error() {
722    let mut command = Cli::command();
723    let usage = command.render_usage().to_string();
724    let _ = writeln!(
725        std::io::stderr().lock(),
726        "error: a subcommand is required\n\n{usage}"
727    );
728}
729
730fn database(command: &DatabaseSub) -> DatabaseCommand {
731    match command {
732        DatabaseSub::Backup => DatabaseCommand::Backup,
733        DatabaseSub::Reset { force } => DatabaseCommand::Reset { force: *force },
734        DatabaseSub::Upgrade { force } => DatabaseCommand::Upgrade { force: *force },
735        DatabaseSub::Compact => DatabaseCommand::Compact,
736        DatabaseSub::Cutover { dry_run, force } => DatabaseCommand::Cutover {
737            dry_run: *dry_run,
738            force: *force,
739        },
740        DatabaseSub::Heal { dry_run, force } => DatabaseCommand::Heal {
741            dry_run: *dry_run,
742            force: *force,
743        },
744        DatabaseSub::Inspect => DatabaseCommand::Inspect,
745    }
746}
747
748fn exchange(command: &ExchangeSub) -> ExchangeCommand {
749    match command {
750        ExchangeSub::Activate { chain, name } => ExchangeCommand::Activate {
751            chain: chain.clone(),
752            name: name.clone(),
753        },
754        ExchangeSub::Deactivate { chain, name } => ExchangeCommand::Deactivate {
755            chain: chain.clone(),
756            name: name.clone(),
757        },
758        ExchangeSub::List { chain } => ExchangeCommand::List {
759            chain: chain.clone(),
760        },
761    }
762}
763
764fn pool(command: &PoolSub) -> PoolCommand {
765    match command {
766        PoolSub::Update {
767            chunk_size,
768            to_block,
769            verify,
770            verify_all_interval,
771        } => PoolCommand::Update {
772            chunk_size: *chunk_size,
773            to_block: to_block.clone(),
774            verify_chunk: verify.chunk_gate(),
775            verify_all: verify.all_gate(),
776            verify_all_interval: *verify_all_interval,
777        },
778        PoolSub::Verify {
779            rpc_url,
780            pool_chain_id,
781            block_number,
782            pool,
783            family,
784            pool_manager,
785        } => PoolCommand::Verify {
786            rpc_url: rpc_url.clone(),
787            chain_id: *pool_chain_id,
788            block_number: *block_number,
789            pool: pool.clone(),
790            family: match family {
791                FamilyArg::V3 => PoolFamily::V3,
792                FamilyArg::V4 => PoolFamily::V4,
793            },
794            pool_manager: pool_manager.clone(),
795        },
796    }
797}
798
799fn aave(command: &AaveSub, cli: &Cli, ctx: &CliContext<'_>) -> Result<AaveCommand, CliError> {
800    match command {
801        AaveSub::Activate => Ok(AaveCommand::Activate {
802            chain_id: chain_or_default(cli, ctx, 1)?,
803        }),
804        AaveSub::Deactivate { market_name } => Ok(AaveCommand::Deactivate {
805            chain_id: chain_or_default(cli, ctx, 1)?,
806            market_name: market_name.clone(),
807        }),
808        AaveSub::Update {
809            chunk_size,
810            to_block,
811            verify,
812            verify_all_interval,
813            stop_after_one_chunk,
814            dry_run,
815            backup,
816            no_backup,
817        } => Ok(AaveCommand::Update {
818            chunk_size: *chunk_size,
819            to_block: to_block.clone(),
820            verify_chunk: verify.chunk_gate(),
821            verify_all: verify.all_gate(),
822            verify_all_interval: *verify_all_interval,
823            stop_after_one_chunk: *stop_after_one_chunk,
824            dry_run: *dry_run,
825            enable_backup: *backup && !*no_backup,
826        }),
827        AaveSub::Position { command } => match command {
828            AavePositionSub::Show { address, market } => Ok(AaveCommand::PositionShow {
829                address: address.clone(),
830                market: market.clone(),
831                chain_id: chain_or_default(cli, ctx, 1)?,
832            }),
833        },
834    }
835}
836
837fn fleet(command: &FleetSub) -> Result<FleetCommand, CliError> {
838    match command {
839        FleetSub::Posture { command } => match command {
840            FleetPostureSub::Show { socket } => Ok(FleetCommand::PostureShow {
841                socket: socket.clone(),
842            }),
843            FleetPostureSub::Set {
844                socket,
845                cordon_enter_events,
846                cordon_duty_percent,
847                cordon_enter_window_ms,
848                cordon_duty_window_ms,
849                cordon_exit_clean_ms,
850                cordon_sim_intake_floor,
851            } => {
852                let mut patch = Vec::new();
853                if let Some(value) = cordon_enter_events {
854                    patch.push(PosturePatchEntry::int("cordon_enter_events", *value));
855                }
856                if let Some(value) = cordon_duty_percent {
857                    patch.push(PosturePatchEntry::float("cordon_duty_percent", *value));
858                }
859                if let Some(value) = cordon_enter_window_ms {
860                    patch.push(PosturePatchEntry::int("cordon_enter_window_ms", *value));
861                }
862                if let Some(value) = cordon_duty_window_ms {
863                    patch.push(PosturePatchEntry::int("cordon_duty_window_ms", *value));
864                }
865                if let Some(value) = cordon_exit_clean_ms {
866                    patch.push(PosturePatchEntry::int("cordon_exit_clean_ms", *value));
867                }
868                if let Some(value) = cordon_sim_intake_floor {
869                    patch.push(PosturePatchEntry::new(
870                        "cordon_sim_intake_floor",
871                        degenbot_cli_core::parse_sim_intake_floor(value)?,
872                    ));
873                }
874                Ok(FleetCommand::PostureSet {
875                    socket: socket.clone(),
876                    patch,
877                })
878            }
879        },
880    }
881}
882
883fn path(command: &PathSub) -> PathCommand {
884    match command {
885        PathSub::Add {
886            socket,
887            hops,
888            direction,
889        } => PathCommand::Add {
890            socket: socket.clone(),
891            hops: hops.clone(),
892            direction: direction.map(|direction| match direction {
893                DirectionArg::Zfo => PathDirection::Zfo,
894                DirectionArg::Ozf => PathDirection::Ozf,
895            }),
896        },
897        PathSub::Discover { socket, bound } => PathCommand::Discover {
898            socket: socket.clone(),
899            bound: *bound,
900        },
901    }
902}
903
904fn strategy(command: &StrategySub) -> StrategyCommand {
905    match command {
906        StrategySub::List => StrategyCommand::List,
907        StrategySub::Show { facet } => StrategyCommand::Show {
908            facet: facet_of(*facet),
909        },
910        StrategySub::Activate {
911            facet,
912            endpoints,
913            endpoints_default,
914        } => StrategyCommand::Activate {
915            facet: facet_of(*facet),
916            endpoints: endpoints.clone(),
917            endpoints_default: *endpoints_default,
918        },
919        StrategySub::Deactivate { facet } => StrategyCommand::Deactivate {
920            facet: facet_of(*facet),
921        },
922        StrategySub::Set { facet, key, value } => StrategyCommand::Set {
923            facet: facet_of(*facet),
924            key: key.clone(),
925            value: value.clone(),
926        },
927        StrategySub::Default { facet, key } => StrategyCommand::Default {
928            facet: facet_of(*facet),
929            key: key.clone(),
930        },
931        StrategySub::Remove { facet, key } => StrategyCommand::Remove {
932            facet: facet_of(*facet),
933            key: key.clone(),
934        },
935    }
936}
937
938fn config(command: &ConfigSub) -> ConfigCommand {
939    match command {
940        ConfigSub::Show { resolved } => ConfigCommand::Show {
941            resolved: *resolved,
942        },
943        ConfigSub::Path => ConfigCommand::Path,
944        ConfigSub::Get { key } => ConfigCommand::Get { key: key.clone() },
945        ConfigSub::Set { key, value, force } => ConfigCommand::Set {
946            key: key.clone(),
947            value: value.clone(),
948            force: *force,
949        },
950        ConfigSub::Unset { key, force } => ConfigCommand::Unset {
951            key: key.clone(),
952            force: *force,
953        },
954    }
955}
956
957fn facet_of(arg: FacetArg) -> StrategyFacet {
958    match arg {
959        FacetArg::Settlement => StrategyFacet::Settlement,
960        FacetArg::MevblockerBackrun => StrategyFacet::MevblockerBackrun,
961        FacetArg::TxpoolBackrun => StrategyFacet::TxpoolBackrun,
962    }
963}
964
965/// The session chain id for the aave arms, which carry Python's Ethereum
966/// default when NO chain layer named a value. A layer that IS present and
967/// malformed stays an error.
968fn chain_or_default(cli: &Cli, ctx: &CliContext<'_>, default: u64) -> Result<u64, CliError> {
969    let cli_layer = cli
970        .chain_id
971        .as_deref()
972        .is_some_and(|value| !value.is_empty());
973    let env_layer = ctx
974        .env()
975        .get(DEFAULT_CHAIN_ID_ENV)
976        .is_some_and(|value| !value.is_empty());
977    // The loaded config knows every layer, so the operator file's
978    // `session.chain_id` counts exactly like the two argument/env layers.
979    let file_layer = ctx.loaded_config()?.config.session.chain_id.is_some();
980    if cli_layer || env_layer || file_layer {
981        return Ok(ctx.chain_id()?.value);
982    }
983    Ok(default)
984}