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