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