Skip to main content

degenbot_cli_core/
strategy.rs

1//! The `strategy` command arms: the console's typed view AND write path over
2//! the strategy facets.
3//!
4//! A strategy **facet** is one typed per-strategy config section
5//! (`strategy.settlement`, `strategy.mevblocker_backrun`, `strategy.txpool_backrun`); each facet's `active` key
6//! selects it (the retired single-arm selector could not express a
7//! two-strategy host). Activation settles an endpoint posture: EITHER an
8//! explicit `--endpoints` set OR the explicit `--endpoints-default` choice,
9//! and the typed readiness validation is the single authority for what
10//! counts as settled.
11//!
12//! All config mutation flows through the `degenbot-config` writer (nothing
13//! else in the workspace edits a config.toml programmatically), and every
14//! write is mirrored through the loader so a shadowing env var is reported
15//! rather than silently masking the file.
16
17use std::path::Path;
18
19use degenbot_config::readiness::StrategyArm;
20use degenbot_config::writer::{write_key_with_env, WriteOutcome};
21use degenbot_config::{strategy_readiness, SCHEMA};
22
23use crate::context::CliContext;
24use crate::error::CliError;
25use crate::prompt::PromptPlan;
26use crate::report::StrategyReport;
27
28/// One strategy arm — the per-ecosystem selection surface. Settlement plus the
29/// two per-ecosystem pending-transaction backruns (ADR-055 D1).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum StrategyFacet {
32    /// The settled-block strategy (settlement arbitrage).
33    Settlement,
34    /// The MEVBlocker-ecosystem pending-transaction strategy.
35    MevblockerBackrun,
36    /// The public-mempool pending-transaction strategy.
37    TxpoolBackrun,
38}
39
40impl StrategyFacet {
41    /// Every facet, in declaration order.
42    pub const ALL: [Self; 3] = [
43        Self::Settlement,
44        Self::MevblockerBackrun,
45        Self::TxpoolBackrun,
46    ];
47
48    /// The canonical lowercase spelling.
49    #[must_use]
50    pub const fn as_str(self) -> &'static str {
51        match self {
52            Self::Settlement => "settlement",
53            Self::MevblockerBackrun => "mevblocker_backrun",
54            Self::TxpoolBackrun => "txpool_backrun",
55        }
56    }
57
58    /// The reaction kind this facet implements (ADR-055 D1).
59    #[must_use]
60    pub const fn trigger_kind(self) -> &'static str {
61        match self {
62            Self::Settlement => "settled-block",
63            Self::MevblockerBackrun | Self::TxpoolBackrun => "pending-transaction",
64        }
65    }
66
67    /// The facet's dotted TOML section path.
68    #[must_use]
69    pub const fn config_section(self) -> &'static str {
70        match self {
71            Self::Settlement => "strategy.settlement",
72            Self::MevblockerBackrun => "strategy.mevblocker_backrun",
73            Self::TxpoolBackrun => "strategy.txpool_backrun",
74        }
75    }
76
77    /// The facet's activation key.
78    #[must_use]
79    pub fn activation_key(self) -> &'static degenbot_config::KeyDecl {
80        facet_key(self.config_section(), "active")
81    }
82
83    /// The facet's explicit-endpoints key.
84    #[must_use]
85    pub fn endpoints_key(self) -> &'static degenbot_config::KeyDecl {
86        facet_key(self.config_section(), "endpoints")
87    }
88
89    /// The pinned default endpoint set `--endpoints-default` stamps into
90    /// the facet's `endpoints` list at activation time.
91    #[must_use]
92    pub fn default_endpoint_set(self) -> Vec<String> {
93        let urls: &[&str] = match self {
94            Self::Settlement => degenbot_config::SETTLEMENT_DEFAULT_ENDPOINTS,
95            Self::MevblockerBackrun => {
96                std::slice::from_ref(&degenbot_config::DEFAULT_BACKRUN_STREAM_URL)
97            }
98            Self::TxpoolBackrun => degenbot_config::DEFAULT_TXPOOL_BACKRUN_RELAYS,
99        };
100        urls.iter().map(|url| (*url).to_string()).collect()
101    }
102
103    /// Parse a facet selector case-insensitively.
104    ///
105    /// # Errors
106    ///
107    /// [`CliError::InvalidArgument`] naming the accepted spellings.
108    pub fn parse(raw: &str) -> Result<Self, CliError> {
109        match raw.trim().to_ascii_lowercase().as_str() {
110            "settlement" => Ok(Self::Settlement),
111            "mevblocker_backrun" => Ok(Self::MevblockerBackrun),
112            "txpool_backrun" => Ok(Self::TxpoolBackrun),
113            other => Err(CliError::InvalidArgument(format!(
114                "unknown strategy facet {other:?} (expected one of: settlement \
115                 mevblocker_backrun txpool_backrun)"
116            ))),
117        }
118    }
119}
120
121/// Lookup a schema key by dotted section + field.
122///
123/// # Panics
124///
125/// Panics when the facet's declared section carries no such field; callers
126/// pair a facet's own section with one of its declared fields.
127#[expect(clippy::panic)] // both the section and the field are schema-registry facts
128fn facet_key(section: &str, field: &str) -> &'static degenbot_config::KeyDecl {
129    SCHEMA
130        .iter()
131        .find(|k| k.section == section && k.field == field)
132        .unwrap_or_else(|| panic!("{section}.{field} must be declared"))
133}
134
135/// A console descriptor entry for one strategy facet: the typed data the
136/// `list`/`show` arms render. The declared keys come straight from `SCHEMA`
137/// (the one declaration site), so the descriptor cannot drift from the config.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct StrategyFacetDescriptor {
140    /// The facet this entry describes.
141    pub facet: StrategyFacet,
142    /// The facet's dotted TOML section path.
143    pub config_section: &'static str,
144    /// The facet's reaction kind.
145    pub trigger_kind: &'static str,
146    /// The declared config fields under this facet, sourced from the schema
147    /// registry.
148    pub fields: Vec<&'static str>,
149    /// The declared `DEGENBOT_*` env names under this facet, sourced from the
150    /// schema registry.
151    pub envs: Vec<&'static str>,
152}
153
154/// Build the descriptor for one facet from the schema registry.
155#[must_use]
156pub fn descriptor(facet: StrategyFacet) -> StrategyFacetDescriptor {
157    let section = facet.config_section();
158    let mut fields = Vec::new();
159    let mut envs = Vec::new();
160    for key in SCHEMA {
161        if key.section == section {
162            fields.push(key.field);
163            envs.push(key.env);
164        }
165    }
166    StrategyFacetDescriptor {
167        facet,
168        config_section: section,
169        trigger_kind: facet.trigger_kind(),
170        fields,
171        envs,
172    }
173}
174
175/// Build the descriptor for every facet, in declaration order.
176#[must_use]
177pub fn descriptors() -> Vec<StrategyFacetDescriptor> {
178    StrategyFacet::ALL.into_iter().map(descriptor).collect()
179}
180
181/// The endpoint posture one facet renders for `show`.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum EndpointSummary {
184    /// The facet is dormant.
185    Inactive,
186    /// The facet is active but no endpoint choice is recorded.
187    Unset,
188    /// The explicit default choice, resolved.
189    Default(Vec<String>),
190    /// The operator's endpoint set, resolved.
191    Explicit(Vec<String>),
192}
193
194/// The `strategy` command group.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum StrategyCommand {
197    /// `strategy list`: every declared facet with its section and trigger kind.
198    List,
199    /// `strategy show <facet>`: one facet's descriptor, activation, and
200    /// resolved endpoint posture.
201    Show {
202        /// The facet to show.
203        facet: StrategyFacet,
204    },
205    /// `strategy activate <facet>`: activate the facet and settle its endpoint
206    /// posture (`--endpoints <csv>` or `--endpoints-default`, exactly one;
207    /// neither is allowed only when the facet already carries a settled
208    /// choice).
209    Activate {
210        /// The facet to activate.
211        facet: StrategyFacet,
212        /// The explicit endpoint set (comma-separated).
213        endpoints: Option<String>,
214        /// Adopt the pinned default endpoint set as the facet's endpoints.
215        endpoints_default: bool,
216    },
217    /// `strategy deactivate <facet>`: deactivate the facet; its recorded
218    /// endpoint choice stays put for a later re-activation.
219    Deactivate {
220        /// The facet to deactivate.
221        facet: StrategyFacet,
222    },
223    /// `strategy set <facet> <key> <value>`: write one declared facet key.
224    Set {
225        /// The facet to mutate.
226        facet: StrategyFacet,
227        /// The config key name.
228        key: String,
229        /// The raw value.
230        value: String,
231    },
232    /// `strategy default <facet> <key>`: drop one key's override so the
233    /// declared schema default applies again.
234    Default {
235        /// The facet to mutate.
236        facet: StrategyFacet,
237        /// The config key name.
238        key: String,
239    },
240    /// `strategy remove <facet> <key>`: the `default` verb's traditional
241    /// spelling (`strategy default` reads clearer; both behave identically).
242    Remove {
243        /// The facet to mutate.
244        facet: StrategyFacet,
245        /// The config key name.
246        key: String,
247    },
248}
249
250impl StrategyCommand {
251    /// No strategy arm prompts: writes are config-only and reversible.
252    #[must_use]
253    pub const fn prompt_plan(&self, _ctx: &CliContext<'_>) -> PromptPlan {
254        PromptPlan::None
255    }
256}
257
258/// The write outcome a mutation renders.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum MutationOutcome {
261    /// The write took effect.
262    Applied,
263    /// The write landed in the file but the env layer will shadow it at load
264    /// time (the rendered line says so loudly).
265    Shadowed {
266        /// The env var name holding the shadowing value (for a `str_map`
267        /// entry, the family prefix plus the entry key).
268        env: String,
269    },
270}
271
272impl From<WriteOutcome> for MutationOutcome {
273    fn from(outcome: WriteOutcome) -> Self {
274        match outcome {
275            WriteOutcome::Written => Self::Applied,
276            WriteOutcome::Shadowed { env } => Self::Shadowed { env },
277        }
278    }
279}
280
281/// Execute a `strategy` command.
282///
283/// # Errors
284///
285/// [`CliError`] for an unsettled activation, an off-allowlist endpoint, an
286/// unknown or facet-foreign key, or a config read/write failure.
287pub(crate) fn execute(
288    command: &StrategyCommand,
289    ctx: &CliContext<'_>,
290    _prompter: &dyn crate::prompt::Prompter,
291) -> Result<StrategyReport, CliError> {
292    match command {
293        StrategyCommand::List => Ok(StrategyReport::Listed {
294            rows: descriptors(),
295        }),
296        StrategyCommand::Show { facet } => {
297            let file = ctx.resolve_config_file()?;
298            let loaded = load(&file, ctx)?;
299            let readiness = strategy_readiness(&loaded.config);
300            let summary = endpoint_summary(*facet, &loaded.config, &readiness);
301            Ok(StrategyReport::Shown {
302                descriptor: descriptor(*facet),
303                activation: Some(summary),
304            })
305        }
306        StrategyCommand::Activate {
307            facet,
308            endpoints,
309            endpoints_default,
310        } => {
311            let file = ctx.resolve_config_file()?;
312            activate(*facet, endpoints.as_ref(), *endpoints_default, &file, ctx)
313        }
314        StrategyCommand::Deactivate { facet } => {
315            let file = ctx.resolve_config_file()?;
316            let outcome = write_key_with_env(&file, facet.activation_key(), "false", ctx.env())
317                .map_err(|error| strategy_write_error(&error))?
318                .into();
319            Ok(StrategyReport::Deactivated {
320                facet: *facet,
321                outcome,
322            })
323        }
324        StrategyCommand::Set { facet, key, value } => {
325            let file = ctx.resolve_config_file()?;
326            let key = facet_lookup(*facet, key)?;
327            let outcome = write_key_with_env(&file, key, value, ctx.env())
328                .map_err(|error| strategy_write_error(&error))?
329                .into();
330            Ok(StrategyReport::Set {
331                facet: *facet,
332                key: key.field,
333                outcome,
334            })
335        }
336        StrategyCommand::Default { facet, key } | StrategyCommand::Remove { facet, key } => {
337            let file = ctx.resolve_config_file()?;
338            let key = facet_lookup(*facet, key)?;
339            let loaded = load(&file, ctx)?;
340            if loaded.source_of(key.env) != Some(degenbot_config::loader::Source::File) {
341                return Err(CliError::InvalidArgument(format!(
342                    "{} has no file override to remove (its present value is default or env)",
343                    key.toml_path
344                )));
345            }
346            degenbot_config::writer::remove_key(&file, key)
347                .map_err(|error| strategy_write_error(&error))?;
348            Ok(StrategyReport::Defaulted {
349                facet: *facet,
350                key: key.field,
351            })
352        }
353    }
354}
355
356/// Resolve one facet's endpoint summary for `show`.
357fn endpoint_summary(
358    facet: StrategyFacet,
359    cfg: &degenbot_config::schema::BotConfig,
360    readiness: &Result<degenbot_config::StrategyReadiness, degenbot_config::StrategyReadinessError>,
361) -> EndpointSummary {
362    let active = match facet {
363        StrategyFacet::Settlement => cfg.strategy.settlement.active,
364        StrategyFacet::MevblockerBackrun => cfg.strategy.mevblocker_backrun.active,
365        StrategyFacet::TxpoolBackrun => cfg.strategy.txpool_backrun.active,
366    };
367    if !active {
368        return EndpointSummary::Inactive;
369    }
370    match readiness {
371        Ok(readiness) => {
372            let arm = match facet {
373                StrategyFacet::Settlement => &readiness.settlement,
374                StrategyFacet::MevblockerBackrun => &readiness.mevblocker_backrun,
375                StrategyFacet::TxpoolBackrun => &readiness.txpool_backrun,
376            };
377            match arm {
378                StrategyArm::Active(urls) => {
379                    // Report provenance: an endpoints list that equals the
380                    // pinned default set reads as "pinned default",
381                    // anything else as the operator's own set.
382                    if *urls == facet.default_endpoint_set() {
383                        EndpointSummary::Default(urls.clone())
384                    } else {
385                        EndpointSummary::Explicit(urls.clone())
386                    }
387                }
388                StrategyArm::Inactive => EndpointSummary::Unset,
389            }
390        }
391        Err(_) => EndpointSummary::Unset,
392    }
393}
394
395/// Load the file-backed config through the standard loader (env included).
396fn load(file: &Path, ctx: &CliContext<'_>) -> Result<degenbot_config::LoadedConfig, CliError> {
397    ctx.load_bot_config_at(file)
398}
399
400/// The activate arm: settle the posture THEN set `active` (an activation can
401/// never leave the config in a state the readiness gate would refuse).
402fn activate(
403    facet: StrategyFacet,
404    endpoints: Option<&String>,
405    endpoints_default: bool,
406    file: &Path,
407    ctx: &CliContext<'_>,
408) -> Result<StrategyReport, CliError> {
409    if endpoints.is_some() && endpoints_default {
410        return Err(CliError::InvalidArgument(format!(
411            "strategy {}: choose exactly one of --endpoints and --endpoints-default",
412            facet.as_str()
413        )));
414    }
415    // `--endpoints-default` resolves to the pinned set and takes the same
416    // single write path: the persisted `endpoints` list IS the posture (no
417    // denormalized marker key to keep in step with it).
418    let stamped = endpoints_default.then(|| facet.default_endpoint_set().join(","));
419    let chosen = endpoints.cloned().or(stamped);
420    let loaded = load(file, ctx)?;
421    let already_settled = facet_has_settled_choice(facet, &loaded.config);
422    if chosen.is_none() && !already_settled {
423        return Err(CliError::InvalidArgument(format!(
424            "activating strategy {} requires an endpoint choice: pass exactly one of \
425             --endpoints <urls> or --endpoints-default",
426            facet.as_str()
427        )));
428    }
429
430    // The write plan applied to an in-memory candidate first: readiness over
431    // the candidate refuses an activation the boot would later reject (an
432    // off-allowlist endpoint), so a refused command leaves the file untouched.
433    let mut candidate = loaded.config.clone();
434    if let Some(urls) = &chosen {
435        candidate
436            .assign(
437                facet.endpoints_key().section,
438                facet.endpoints_key().field,
439                urls,
440            )
441            .map_err(CliError::InvalidArgument)?;
442    }
443    let activation = facet.activation_key();
444    candidate
445        .assign(activation.section, activation.field, "true")
446        .map_err(CliError::InvalidArgument)?;
447    let readiness = strategy_readiness(&candidate)
448        .map_err(|error| CliError::InvalidArgument(error.to_string()))?;
449    let arm = match facet {
450        StrategyFacet::Settlement => &readiness.settlement,
451        StrategyFacet::MevblockerBackrun => &readiness.mevblocker_backrun,
452        StrategyFacet::TxpoolBackrun => &readiness.txpool_backrun,
453    };
454    let StrategyArm::Active(urls) = arm else {
455        return Err(CliError::InvalidArgument(format!(
456            "strategy {} did not settle to an active arm: this is a CLI bug",
457            facet.as_str()
458        )));
459    };
460
461    // Only now touch the file, mirroring the validated plan.
462    let mut outcome = MutationOutcome::Applied;
463    if let Some(urls) = &chosen {
464        outcome = write_key_with_env(file, facet.endpoints_key(), urls, ctx.env())
465            .map_err(|error| strategy_write_error(&error))?
466            .into();
467    }
468    let activation_outcome = write_key_with_env(file, facet.activation_key(), "true", ctx.env())
469        .map_err(|error| strategy_write_error(&error))?
470        .into();
471    if matches!(outcome, MutationOutcome::Applied) {
472        outcome = activation_outcome;
473    }
474
475    let summary = if endpoints_default {
476        EndpointSummary::Default(urls.clone())
477    } else {
478        EndpointSummary::Explicit(urls.clone())
479    };
480    Ok(StrategyReport::Activated {
481        facet,
482        summary,
483        outcome,
484    })
485}
486
487/// Whether the loaded config already settles this facet's endpoint choice.
488fn facet_has_settled_choice(
489    facet: StrategyFacet,
490    cfg: &degenbot_config::schema::BotConfig,
491) -> bool {
492    let endpoints = match facet {
493        StrategyFacet::Settlement => cfg.strategy.settlement.endpoints.as_deref(),
494        StrategyFacet::MevblockerBackrun => cfg.strategy.mevblocker_backrun.endpoints.as_deref(),
495        StrategyFacet::TxpoolBackrun => cfg.strategy.txpool_backrun.endpoints.as_deref(),
496    };
497    endpoints.is_some_and(|raw| raw.split(',').any(|s| !s.trim().is_empty()))
498}
499
500/// Resolve a `set`/`default` key against the facet's declared section.
501fn facet_lookup(
502    facet: StrategyFacet,
503    key: &str,
504) -> Result<&'static degenbot_config::KeyDecl, CliError> {
505    let matched = SCHEMA
506        .iter()
507        .find(|k| k.section == facet.config_section() && k.field == key);
508    let Some(key) = matched else {
509        let declared = descriptor(facet).fields.clone().join(", ");
510        return Err(CliError::InvalidArgument(format!(
511            "unknown key {key:?} on facet {} (declared: {declared})",
512            facet.config_section()
513        )));
514    };
515    // The activation, endpoint, and default keys carry CLI verbs of their own;
516    // routing them through `set`/`default` bypasses the readiness gates.
517    if matches!(key.field, "active" | "endpoints") {
518        return Err(CliError::InvalidArgument(format!(
519            "key {key:?} is owned by `strategy activate`; use the activate verb \
520             (or --endpoints / --endpoints-default) instead"
521        )));
522    }
523    Ok(key)
524}
525
526/// Surface a write refusal with its facet context.
527fn strategy_write_error(error: &degenbot_config::ConfigError) -> CliError {
528    CliError::InvalidArgument(error.to_string())
529}
530
531#[cfg(test)]
532#[expect(
533    clippy::unwrap_used,
534    clippy::expect_used,
535    clippy::panic,
536    reason = "unit tests: a malformed fixture must fail the test loudly"
537)]
538mod tests {
539    use super::*;
540    use crate::report::StrategyReport;
541    use degenbot_config::{BotConfigLoader, MapEnv, SECTION_PATHS};
542    use std::collections::BTreeMap;
543    use std::path::PathBuf;
544
545    struct NoPrompt;
546    impl crate::prompt::Prompter for NoPrompt {
547        fn confirm(&self, _message: &str, default: bool) -> bool {
548            default
549        }
550    }
551
552    fn test_file(name: &str) -> PathBuf {
553        let dir = std::env::temp_dir().join(format!("strategy-cli-{name}-{}", std::process::id()));
554        let file = dir.join("config.toml");
555        let _ = std::fs::remove_file(&file);
556        file
557    }
558
559    fn empty_env() -> MapEnv {
560        MapEnv::new(BTreeMap::new())
561    }
562
563    fn ctx_at<'e>(env: &'e MapEnv, file: &Path) -> CliContext<'e> {
564        let path = file.to_string_lossy().into_owned();
565        CliContext::new(env).with_config(path)
566    }
567
568    #[test]
569    fn facets_name_their_typed_sections_and_trigger_kinds() {
570        for descriptor in descriptors() {
571            assert!(
572                SECTION_PATHS.contains(&descriptor.config_section),
573                "{} must be a declared schema section",
574                descriptor.config_section
575            );
576        }
577    }
578
579    #[test]
580    fn facet_descriptors_mirror_their_schema_keys() {
581        for descriptor in descriptors() {
582            let expected_fields: Vec<&str> = SCHEMA
583                .iter()
584                .filter(|key| key.section == descriptor.config_section)
585                .map(|key| key.field)
586                .collect();
587            assert_eq!(descriptor.fields, expected_fields);
588            let expected_envs: Vec<&str> = SCHEMA
589                .iter()
590                .filter(|key| key.section == descriptor.config_section)
591                .map(|key| key.env)
592                .collect();
593            assert_eq!(descriptor.envs, expected_envs);
594        }
595        assert!(
596            !descriptor(StrategyFacet::Settlement).fields.is_empty(),
597            "strategy.settlement declares its keys"
598        );
599    }
600
601    #[test]
602    fn facet_selector_parses_case_insensitively_and_refuses_unknown() {
603        assert_eq!(
604            StrategyFacet::parse("Settlement").unwrap(),
605            StrategyFacet::Settlement
606        );
607        assert_eq!(
608            StrategyFacet::parse("  mevblocker_backrun ").unwrap(),
609            StrategyFacet::MevblockerBackrun
610        );
611        assert_eq!(
612            StrategyFacet::parse("txpool_backrun").unwrap(),
613            StrategyFacet::TxpoolBackrun
614        );
615        assert!(
616            StrategyFacet::parse("backrun").is_err(),
617            "the retired single backrun spelling must not resolve"
618        );
619        assert!(StrategyFacet::parse("sandwich").is_err());
620    }
621
622    #[test]
623    fn both_backrun_facets_activate_independently() {
624        let file = test_file("activate-both-backruns");
625        let env = empty_env();
626        let ctx = ctx_at(&env, &file);
627        execute(
628            &StrategyCommand::Activate {
629                facet: StrategyFacet::MevblockerBackrun,
630                endpoints: None,
631                endpoints_default: true,
632            },
633            &ctx,
634            &NoPrompt,
635        )
636        .expect("activate mevblocker");
637        execute(
638            &StrategyCommand::Activate {
639                facet: StrategyFacet::TxpoolBackrun,
640                endpoints: None,
641                endpoints_default: true,
642            },
643            &ctx,
644            &NoPrompt,
645        )
646        .expect("activate peer");
647        let loaded = BotConfigLoader::new()
648            .with_config_path(&file)
649            .without_env()
650            .load()
651            .expect("config loads");
652        assert!(loaded.config.strategy.mevblocker_backrun.active);
653        assert!(loaded.config.strategy.txpool_backrun.active);
654        assert_eq!(
655            loaded.config.strategy.txpool_backrun.endpoints.as_deref(),
656            Some(
657                degenbot_config::DEFAULT_TXPOOL_BACKRUN_RELAYS
658                    .join(",")
659                    .as_str()
660            )
661        );
662    }
663
664    // ── list / show ──
665
666    #[test]
667    fn list_reports_every_facet() {
668        let env = empty_env();
669        let ctx = CliContext::new(&env);
670        let report = execute(&StrategyCommand::List, &ctx, &NoPrompt).expect("list executes");
671        let StrategyReport::Listed { rows } = report else {
672            panic!("list must produce Listed");
673        };
674        assert_eq!(rows.len(), StrategyFacet::ALL.len());
675    }
676
677    #[test]
678    fn show_reports_activation_and_posture() {
679        let file = test_file("show");
680        let env = empty_env();
681        let ctx = ctx_at(&env, &file);
682        // Activate with the default posture first: show then renders it.
683        execute(
684            &StrategyCommand::Activate {
685                facet: StrategyFacet::MevblockerBackrun,
686                endpoints: None,
687                endpoints_default: true,
688            },
689            &ctx,
690            &NoPrompt,
691        )
692        .expect("activate executes");
693        let report = execute(
694            &StrategyCommand::Show {
695                facet: StrategyFacet::MevblockerBackrun,
696            },
697            &ctx,
698            &NoPrompt,
699        )
700        .expect("show executes");
701        let StrategyReport::Shown {
702            activation: Some(summary),
703            ..
704        } = report
705        else {
706            panic!("show must produce Shown with activation");
707        };
708        assert_eq!(
709            summary,
710            EndpointSummary::Default(vec![degenbot_config::DEFAULT_BACKRUN_STREAM_URL.to_string()])
711        );
712    }
713
714    // ── activate ──
715
716    #[test]
717    fn activate_with_endpoints_default_stamps_the_pinned_set_and_active() {
718        let file = test_file("activate-default");
719        let env = empty_env();
720        let ctx = ctx_at(&env, &file);
721        execute(
722            &StrategyCommand::Activate {
723                facet: StrategyFacet::MevblockerBackrun,
724                endpoints: None,
725                endpoints_default: true,
726            },
727            &ctx,
728            &NoPrompt,
729        )
730        .expect("activate executes");
731        let loaded = BotConfigLoader::new()
732            .with_config_path(&file)
733            .without_env()
734            .load()
735            .expect("written config loads");
736        assert!(loaded.config.strategy.mevblocker_backrun.active);
737        assert_eq!(
738            loaded
739                .config
740                .strategy
741                .mevblocker_backrun
742                .endpoints
743                .as_deref(),
744            Some(degenbot_config::DEFAULT_BACKRUN_STREAM_URL),
745            "the pinned default must be stamped into the persisted list"
746        );
747    }
748
749    #[test]
750    fn activate_with_explicit_endpoints_writes_them() {
751        let file = test_file("activate-explicit");
752        let env = empty_env();
753        let ctx = ctx_at(&env, &file);
754        execute(
755            &StrategyCommand::Activate {
756                facet: StrategyFacet::Settlement,
757                endpoints: Some("https://rpc.mevblocker.io/noreverts".to_string()),
758                endpoints_default: false,
759            },
760            &ctx,
761            &NoPrompt,
762        )
763        .expect("activate executes");
764        let loaded = BotConfigLoader::new()
765            .with_config_path(&file)
766            .without_env()
767            .load()
768            .expect("written config loads");
769        assert!(loaded.config.strategy.settlement.active);
770        assert_eq!(
771            loaded.config.strategy.settlement.endpoints.as_deref(),
772            Some("https://rpc.mevblocker.io/noreverts")
773        );
774    }
775
776    #[test]
777    fn a_refused_activation_leaves_no_file_residue() {
778        let file = test_file("activate-atomic");
779        let env = empty_env();
780        let ctx = ctx_at(&env, &file);
781        let error = execute(
782            &StrategyCommand::Activate {
783                facet: StrategyFacet::Settlement,
784                endpoints: Some("https://rpc.mevblocker.io/fast".to_string()),
785                endpoints_default: false,
786            },
787            &ctx,
788            &NoPrompt,
789        )
790        .expect_err("off-allowlist activation must refuse");
791        assert!(
792            error.message().contains("RELAYS_AND_GUARDRAILS"),
793            "{}",
794            error.message()
795        );
796        assert!(
797            !file.exists(),
798            "a refused activation must not create or touch the config file"
799        );
800    }
801
802    #[test]
803    fn activate_without_a_choice_refuses_naming_both_flags() {
804        let file = test_file("activate-nochoice");
805        let env = empty_env();
806        let ctx = ctx_at(&env, &file);
807        let error = execute(
808            &StrategyCommand::Activate {
809                facet: StrategyFacet::Settlement,
810                endpoints: None,
811                endpoints_default: false,
812            },
813            &ctx,
814            &NoPrompt,
815        )
816        .expect_err("unsettled activation must refuse");
817        let message = error.message();
818        assert!(message.contains("--endpoints"), "{message}");
819        assert!(message.contains("--endpoints-default"), "{message}");
820        // Nothing was written.
821        assert!(!file.exists() || std::fs::read_to_string(&file).expect("read").is_empty());
822    }
823
824    #[test]
825    fn activate_with_both_choices_refuses() {
826        let file = test_file("activate-both");
827        let env = empty_env();
828        let ctx = ctx_at(&env, &file);
829        let error = execute(
830            &StrategyCommand::Activate {
831                facet: StrategyFacet::MevblockerBackrun,
832                endpoints: Some("wss://searchers.example".to_string()),
833                endpoints_default: true,
834            },
835            &ctx,
836            &NoPrompt,
837        )
838        .expect_err("both choices must refuse");
839        assert!(
840            error.message().contains("exactly one"),
841            "{}",
842            error.message()
843        );
844    }
845
846    #[test]
847    fn activate_refuses_off_allowlist_settlement_endpoints_at_write_time() {
848        let file = test_file("activate-offallowlist");
849        let env = empty_env();
850        let ctx = ctx_at(&env, &file);
851        let error = execute(
852            &StrategyCommand::Activate {
853                facet: StrategyFacet::Settlement,
854                endpoints: Some("https://rpc.mevblocker.io/fast".to_string()),
855                endpoints_default: false,
856            },
857            &ctx,
858            &NoPrompt,
859        )
860        .expect_err("off-allowlist endpoints must refuse at write time");
861        assert!(
862            error.message().contains("RELAYS_AND_GUARDRAILS"),
863            "{}",
864            error.message()
865        );
866    }
867
868    #[test]
869    fn reactivating_with_a_new_choice_overwrites_the_old() {
870        let file = test_file("activate-rewrite");
871        let env = empty_env();
872        let ctx = ctx_at(&env, &file);
873        execute(
874            &StrategyCommand::Activate {
875                facet: StrategyFacet::MevblockerBackrun,
876                endpoints: Some("wss://searchers.example".to_string()),
877                endpoints_default: false,
878            },
879            &ctx,
880            &NoPrompt,
881        )
882        .expect("initial activate");
883        // Flip to the default posture: the explicit key must drop away.
884        execute(
885            &StrategyCommand::Activate {
886                facet: StrategyFacet::MevblockerBackrun,
887                endpoints: None,
888                endpoints_default: true,
889            },
890            &ctx,
891            &NoPrompt,
892        )
893        .expect("re-activation executes");
894        let loaded = BotConfigLoader::new()
895            .with_config_path(&file)
896            .without_env()
897            .load()
898            .expect("config loads");
899        assert!(loaded.config.strategy.mevblocker_backrun.active);
900        assert_eq!(
901            loaded
902                .config
903                .strategy
904                .mevblocker_backrun
905                .endpoints
906                .as_deref(),
907            Some(degenbot_config::DEFAULT_BACKRUN_STREAM_URL),
908            "the default re-activation must restamp the pinned list"
909        );
910    }
911
912    #[test]
913    fn activate_reports_an_env_shadow_loudly() {
914        let file = test_file("activate-shadowed");
915        let env = MapEnv::new(BTreeMap::from([(
916            "DEGENBOT_STRATEGY_MEVBLOCKER_BACKRUN_ACTIVE".to_string(),
917            "false".to_string(),
918        )]));
919        let ctx = ctx_at(&env, &file);
920        let report = execute(
921            &StrategyCommand::Activate {
922                facet: StrategyFacet::MevblockerBackrun,
923                endpoints: None,
924                endpoints_default: true,
925            },
926            &ctx,
927            &NoPrompt,
928        )
929        .expect("activate executes");
930        let StrategyReport::Activated {
931            outcome: MutationOutcome::Shadowed { env },
932            ..
933        } = report
934        else {
935            panic!("the shadow must reach the report");
936        };
937        assert_eq!(env, "DEGENBOT_STRATEGY_MEVBLOCKER_BACKRUN_ACTIVE");
938    }
939
940    #[test]
941    fn deactivate_leaves_the_recorded_choice_alone() {
942        let file = test_file("deactivate");
943        let env = empty_env();
944        let ctx = ctx_at(&env, &file);
945        execute(
946            &StrategyCommand::Activate {
947                facet: StrategyFacet::Settlement,
948                endpoints: None,
949                endpoints_default: true,
950            },
951            &ctx,
952            &NoPrompt,
953        )
954        .expect("activate");
955        execute(
956            &StrategyCommand::Deactivate {
957                facet: StrategyFacet::Settlement,
958            },
959            &ctx,
960            &NoPrompt,
961        )
962        .expect("deactivate");
963        let loaded = BotConfigLoader::new()
964            .with_config_path(&file)
965            .without_env()
966            .load()
967            .expect("config loads");
968        assert!(!loaded.config.strategy.settlement.active);
969        assert_eq!(
970            loaded.config.strategy.settlement.endpoints.as_deref(),
971            Some(
972                degenbot_config::SETTLEMENT_DEFAULT_ENDPOINTS
973                    .join(",")
974                    .as_str()
975            ),
976            "deactivation keeps the recorded endpoint set for re-activation"
977        );
978    }
979
980    // ── set / default / remove ──
981
982    #[test]
983    fn set_writes_a_typed_override_and_default_restores_it() {
984        let file = test_file("set-default");
985        let env = empty_env();
986        let ctx = ctx_at(&env, &file);
987        execute(
988            &StrategyCommand::Set {
989                facet: StrategyFacet::MevblockerBackrun,
990                key: "priority_fee_gwei".to_string(),
991                value: "7".to_string(),
992            },
993            &ctx,
994            &NoPrompt,
995        )
996        .expect("set executes");
997        let loaded = BotConfigLoader::new()
998            .with_config_path(&file)
999            .without_env()
1000            .load()
1001            .expect("config loads");
1002        assert_eq!(
1003            loaded.config.strategy.mevblocker_backrun.priority_fee_gwei,
1004            7
1005        );
1006
1007        execute(
1008            &StrategyCommand::Default {
1009                facet: StrategyFacet::MevblockerBackrun,
1010                key: "priority_fee_gwei".to_string(),
1011            },
1012            &ctx,
1013            &NoPrompt,
1014        )
1015        .expect("default executes");
1016        let restored = BotConfigLoader::new()
1017            .with_config_path(&file)
1018            .without_env()
1019            .load()
1020            .expect("config loads");
1021        assert_eq!(
1022            restored
1023                .config
1024                .strategy
1025                .mevblocker_backrun
1026                .priority_fee_gwei,
1027            2
1028        );
1029    }
1030
1031    #[test]
1032    fn remove_is_the_default_verb_alias() {
1033        let file = test_file("remove-alias");
1034        let env = empty_env();
1035        let ctx = ctx_at(&env, &file);
1036        execute(
1037            &StrategyCommand::Set {
1038                facet: StrategyFacet::MevblockerBackrun,
1039                key: "bundle_gas_est".to_string(),
1040                value: "333000".to_string(),
1041            },
1042            &ctx,
1043            &NoPrompt,
1044        )
1045        .expect("set executes");
1046        execute(
1047            &StrategyCommand::Remove {
1048                facet: StrategyFacet::MevblockerBackrun,
1049                key: "bundle_gas_est".to_string(),
1050            },
1051            &ctx,
1052            &NoPrompt,
1053        )
1054        .expect("remove executes");
1055        let restored = BotConfigLoader::new()
1056            .with_config_path(&file)
1057            .without_env()
1058            .load()
1059            .expect("config loads");
1060        assert_eq!(
1061            restored.config.strategy.mevblocker_backrun.bundle_gas_est,
1062            300_000
1063        );
1064    }
1065
1066    #[test]
1067    fn set_refuses_unknown_and_activation_owned_keys() {
1068        let file = test_file("set-refuse");
1069        let env = empty_env();
1070        let ctx = ctx_at(&env, &file);
1071        for (key, expectation) in [
1072            ("no_such_key", "declared"),
1073            ("log_stderr", "declared"),
1074            ("active", "activate verb"),
1075            ("endpoints", "activate verb"),
1076        ] {
1077            let error = execute(
1078                &StrategyCommand::Set {
1079                    facet: StrategyFacet::MevblockerBackrun,
1080                    key: key.to_string(),
1081                    value: "1".to_string(),
1082                },
1083                &ctx,
1084                &NoPrompt,
1085            )
1086            .expect_err("key must refuse");
1087            assert!(
1088                error.message().contains(expectation),
1089                "{key}: {}",
1090                error.message()
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn default_refuses_when_no_override_is_present() {
1097        let file = test_file("default-noop");
1098        let env = empty_env();
1099        let ctx = ctx_at(&env, &file);
1100        let error = execute(
1101            &StrategyCommand::Default {
1102                facet: StrategyFacet::MevblockerBackrun,
1103                key: "priority_fee_gwei".to_string(),
1104            },
1105            &ctx,
1106            &NoPrompt,
1107        )
1108        .expect_err("no-op default must refuse");
1109        assert!(
1110            error.message().contains("no file override"),
1111            "{}",
1112            error.message()
1113        );
1114    }
1115}