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}