Skip to main content

degenbot_cli_core/
context.rs

1//! The driver-domain context a command executes against (ADR-051 D8,
2//! ADR-062 D7).
3//!
4//! The database path / chain id / node URIs are resolved through the
5//! `degenbot-config` resolvers — the SAME cascades the Python console reads —
6//! over a config loaded ONCE from the context's injectable [`EnvVars`] seam
7//! (never `std::env`) plus the CLI override values the argv facade threaded
8//! in. One load, four layers, every value tagged with the layer that supplied
9//! it.
10
11use std::path::PathBuf;
12use std::sync::OnceLock;
13
14use degenbot_config::{
15    resolve_chain_id, resolve_database_path_with, resolve_node_request_uri,
16    resolve_node_subscription_uri, EnvVars, LoadedConfig, NodeOverrides, NodeTransport, Resolved,
17};
18
19/// The resolved inputs a console command runs against.
20pub struct CliContext<'a> {
21    env: &'a dyn EnvVars,
22    database: Option<String>,
23    chain_id: Option<String>,
24    nodes: NodeOverrides,
25    config: Option<String>,
26    loaded: OnceLock<LoadedConfig>,
27}
28
29impl<'a> CliContext<'a> {
30    /// A context over `env` with no CLI overrides (file + env + defaults only).
31    #[must_use]
32    pub fn new(env: &'a dyn EnvVars) -> Self {
33        Self {
34            env,
35            database: None,
36            chain_id: None,
37            nodes: NodeOverrides::new(),
38            config: None,
39            loaded: OnceLock::new(),
40        }
41    }
42
43    /// Set the `--database` override (highest-precedence layer).
44    #[must_use]
45    pub fn with_database(mut self, database: impl Into<String>) -> Self {
46        self.database = Some(database.into());
47        self
48    }
49
50    /// Set the `--chain-id` override.
51    #[must_use]
52    pub fn with_chain_id(mut self, chain_id: impl Into<String>) -> Self {
53        self.chain_id = Some(chain_id.into());
54        self
55    }
56
57    /// Set one `--node` override: an explicit endpoint for the transport its
58    /// own value classified as (ADR-062 D6). The flag cannot name a transport
59    /// its value does not carry, so each occurrence fills exactly one slot.
60    #[must_use]
61    pub fn with_node(mut self, uri: impl Into<String>, transport: NodeTransport) -> Self {
62        self.nodes = self.nodes.with_transport(transport, uri);
63        self
64    }
65
66    /// Set every explicit endpoint at once (a caller that classified a whole
67    /// argv vector itself, e.g. the repeatable `--node`).
68    #[must_use]
69    pub fn with_node_overrides(mut self, nodes: NodeOverrides) -> Self {
70        self.nodes = nodes;
71        self
72    }
73
74    /// Set the `--config` override (the typed config file the strategy
75    /// verbs read and write).
76    #[must_use]
77    pub fn with_config(mut self, path: impl Into<String>) -> Self {
78        self.config = Some(path.into());
79        self
80    }
81
82    /// The env seam (the loaders' only env reader).
83    #[must_use]
84    pub fn env(&self) -> &'a dyn EnvVars {
85        self.env
86    }
87
88    /// The `--database` override, if any.
89    #[must_use]
90    pub fn database_override(&self) -> Option<&str> {
91        self.database.as_deref()
92    }
93
94    /// The explicit node endpoints the argv facade threaded in.
95    #[must_use]
96    pub fn node_overrides(&self) -> &NodeOverrides {
97        &self.nodes
98    }
99
100    /// The config file the driver-domain resolvers read: the `--config`
101    /// override when given, else the standard path — `DEGENBOT_CONFIG` (honored
102    /// even when the file is missing: the operator asked for it) else the
103    /// XDG/HOME config file when it exists. `None` means the context has no
104    /// file layer at all, which is contractually the schema defaults.
105    #[must_use]
106    pub fn config_file(&self) -> Option<PathBuf> {
107        match &self.config {
108            Some(path) => Some(PathBuf::from(path)),
109            None => degenbot_config::standard_file_path_with(self.env),
110        }
111    }
112
113    /// The four layers loaded once per context: the file layer
114    /// ([`Self::config_file`]) over the env seam, plus the declared defaults.
115    /// Every driver-domain resolver reads this value, so a command resolves
116    /// its database path, chain id, and endpoints against ONE load.
117    ///
118    /// # Errors
119    ///
120    /// [`crate::error::CliError::Config`] carrying the loader's fail-closed
121    /// [`degenbot_config::ConfigError`] when a file the operator named is
122    /// unreadable, unparsable, or holds an invalid value.
123    pub fn loaded_config(&self) -> Result<&LoadedConfig, crate::error::CliError> {
124        if let Some(loaded) = self.loaded.get() {
125            return Ok(loaded);
126        }
127        let loaded = match self.config_file() {
128            Some(file) => degenbot_config::BotConfigLoader::new()
129                .with_config_path(file)
130                .with_env_ref(self.env)
131                .load(),
132            None => degenbot_config::BotConfigLoader::new()
133                .with_env_ref(self.env)
134                .load(),
135        }
136        .map_err(crate::error::CliError::Config)?;
137        Ok(self.loaded.get_or_init(|| loaded))
138    }
139
140    /// Resolve the database path: `--database` > `DEGENBOT_DB_PATH` >
141    /// `database.path` > the state-home default. The env seam supplies
142    /// `HOME` / `$XDG_STATE_HOME` for the `~` expansion of the default.
143    ///
144    /// # Errors
145    ///
146    /// [`crate::error::CliError::Config`] when the context's config layers do
147    /// not load.
148    pub fn database_path(&self) -> Result<Resolved<PathBuf>, crate::error::CliError> {
149        let resolved =
150            resolve_database_path_with(self.loaded_config()?, self.database.as_deref(), self.env);
151        Ok(resolved)
152    }
153
154    /// Resolve the session chain id: `--chain-id` > `DEGENBOT_DEFAULT_CHAIN_ID`
155    /// > `session.chain_id`.
156    ///
157    /// # Errors
158    ///
159    /// [`crate::error::CliError::Config`] when no layer named a chain, when
160    /// the explicit value is not an integer, or when the config layers do not
161    /// load.
162    pub fn chain_id(&self) -> Result<Resolved<u64>, crate::error::CliError> {
163        Ok(resolve_chain_id(
164            self.loaded_config()?,
165            self.chain_id.as_deref(),
166        )?)
167    }
168
169    /// Resolve the node URI a REQUEST consumer uses for `chain_id`: ipc, then
170    /// ws, then http (ADR-062 D3).
171    ///
172    /// The updater arms resolve the chain they actually operate on (which may
173    /// differ from the session chain id, e.g. a validated Aave deployment).
174    ///
175    /// # Errors
176    ///
177    /// [`crate::error::CliError::Config`] when no layer supplied an endpoint
178    /// for the chain, or when the config layers do not load.
179    pub fn node_request_uri_for(
180        &self,
181        chain_id: u64,
182    ) -> Result<Resolved<String>, crate::error::CliError> {
183        Ok(resolve_node_request_uri(
184            self.loaded_config()?,
185            chain_id,
186            self.node_overrides(),
187        )?)
188    }
189
190    /// Resolve the node URI a REQUEST consumer uses for the session chain id.
191    ///
192    /// # Errors
193    ///
194    /// [`crate::error::CliError::Config`] when the chain id or the endpoint is
195    /// unresolved, or when the config layers do not load.
196    pub fn node_request_uri(&self) -> Result<Resolved<String>, crate::error::CliError> {
197        let chain_id = self.chain_id()?;
198        self.node_request_uri_for(chain_id.value)
199    }
200
201    /// Resolve the node URI a SUBSCRIPTION consumer uses for the session
202    /// chain id: ipc or ws, never http (ADR-062 D3 — a feed never polls).
203    ///
204    /// # Errors
205    ///
206    /// [`crate::error::CliError::Config`] when the chain id or the feed
207    /// endpoint is unresolved, or when the config layers do not load.
208    pub fn node_subscription_uri(&self) -> Result<Resolved<String>, crate::error::CliError> {
209        let chain_id = self.chain_id()?;
210        Ok(resolve_node_subscription_uri(
211            self.loaded_config()?,
212            chain_id.value,
213            self.node_overrides(),
214        )?)
215    }
216
217    /// Resolve the config file the strategy verbs write to: the `--config`
218    /// override, else the `DEGENBOT_CONFIG` env var (honored even when the
219    /// file is absent — the operator asked for it), else the XDG config home
220    /// (a write there creates the file). No home and no override is a typed
221    /// refusal, never a silent skip.
222    ///
223    /// # Errors
224    ///
225    /// [`CliError::Config`]-carrying [`crate::error::CliError`] when no
226    /// config location resolves at all.
227    pub fn resolve_config_file(&self) -> Result<std::path::PathBuf, crate::error::CliError> {
228        use crate::error::CliError;
229
230        if let Some(path) = &self.config {
231            return Ok(std::path::PathBuf::from(path));
232        }
233        if let Some(path) = degenbot_config::standard_file_path_with(self.env) {
234            return Ok(path);
235        }
236        if let Some(home) = degenbot_config::config_home(self.env) {
237            return Ok(home.join("degenbot").join("config.toml"));
238        }
239        Err(CliError::InvalidArgument(
240            "no config file location: pass --config or set DEGENBOT_CONFIG".to_string(),
241        ))
242    }
243
244    /// Load the typed config over this context's env + the resolved file.
245    ///
246    /// # Errors
247    ///
248    /// The loader's fail-closed [`degenbot_config::ConfigError`] wrapped in
249    /// [`crate::error::CliError::InvalidArgument`].
250    pub fn load_bot_config(&self) -> Result<degenbot_config::LoadedConfig, crate::error::CliError> {
251        let file = self.resolve_config_file()?;
252        self.load_bot_config_at(&file)
253    }
254
255    /// Load the typed config over this context's env + an explicit file.
256    /// An absent file is the empty default config (a first write creates it);
257    /// an existing-but-unreadable file surfaces the loader's refusal.
258    ///
259    /// # Errors
260    ///
261    /// The loader's fail-closed [`degenbot_config::ConfigError`] wrapped in
262    /// [`crate::error::CliError::InvalidArgument`].
263    pub fn load_bot_config_at(
264        &self,
265        file: &std::path::Path,
266    ) -> Result<degenbot_config::LoadedConfig, crate::error::CliError> {
267        if !file.exists() {
268            return degenbot_config::BotConfigLoader::new()
269                .with_env_ref(self.env)
270                .load()
271                .map_err(|error| crate::error::CliError::InvalidArgument(error.to_string()));
272        }
273        degenbot_config::BotConfigLoader::new()
274            .with_config_path(file)
275            .with_env_ref(self.env)
276            .load()
277            .map_err(|error| crate::error::CliError::InvalidArgument(error.to_string()))
278    }
279}