1use super::error::{Error, ErrorKind, Result};
2use crate::protocol::target::{DatabaseTarget, ReadMode};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::path::PathBuf;
8
9const DEFAULT_CONFIG_JSON: &str = include_str!("../../config/defaults.json");
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct Config {
14 pub wallet: Option<String>,
15 pub rpc: Option<String>,
16 pub explorer: Option<String>,
17 pub network: Option<String>,
18 #[serde(default)]
19 pub networks: BTreeMap<String, NetworkConfig>,
20 #[serde(default)]
21 pub default_database: Option<String>,
22 #[serde(default)]
23 pub databases: BTreeMap<String, String>,
24 #[serde(default)]
25 pub database_metadata: BTreeMap<String, DatabaseMetadata>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct NetworkConfig {
31 pub rpc: Option<String>,
32 pub explorer: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
37pub struct DatabaseMetadata {
38 pub uri: String,
39 pub network: String,
40 pub circle: String,
41 #[serde(default = "default_read_mode")]
42 pub read_mode: ReadMode,
43 #[serde(default = "default_privacy_class")]
44 pub privacy_class: String,
45 #[serde(default = "default_browser_mode")]
46 pub browser_mode: String,
47 #[serde(default = "default_resource_mode")]
48 pub resource_mode: String,
49 pub owner: String,
50 pub owner_pubkey: String,
51 pub db_id: String,
52 pub code_hash: String,
53 pub code_bytes: usize,
54 pub create_tx: Option<String>,
55 #[serde(default)]
56 pub program_update_tx: Option<String>,
57}
58
59fn default_privacy_class() -> String {
60 "sealed".to_string()
61}
62
63fn default_read_mode() -> ReadMode {
64 ReadMode::Sealed
65}
66
67fn default_browser_mode() -> String {
68 "native_sealed".to_string()
69}
70
71fn default_resource_mode() -> String {
72 "sealed_read".to_string()
73}
74
75impl Config {
76 pub fn rpc_for_network(&self, network: &str) -> Option<String> {
77 if self.network.as_deref() == Some(network) {
78 return self
79 .rpc
80 .clone()
81 .or_else(|| self.networks.get(network)?.rpc.clone());
82 }
83 self.networks
84 .get(network)
85 .and_then(|profile| profile.rpc.clone())
86 }
87
88 pub fn explorer_for_network(&self, network: &str) -> Option<String> {
89 if self.network.as_deref() == Some(network) {
90 return self
91 .explorer
92 .clone()
93 .or_else(|| self.networks.get(network)?.explorer.clone());
94 }
95 self.networks
96 .get(network)
97 .and_then(|profile| profile.explorer.clone())
98 }
99
100 pub fn apply_active_network_profile(&mut self) {
101 let Some(network) = self.network.as_deref() else {
102 return;
103 };
104 let Some(profile) = self.networks.get(network) else {
105 return;
106 };
107 if let Some(rpc) = &profile.rpc {
108 self.rpc = Some(rpc.clone());
109 }
110 if let Some(explorer) = &profile.explorer {
111 self.explorer = Some(explorer.clone());
112 }
113 }
114
115 pub fn metadata_for_target(
116 &self,
117 requested: &str,
118 target: &DatabaseTarget,
119 ) -> Option<&DatabaseMetadata> {
120 self.database_metadata.get(requested).or_else(|| {
121 self.database_metadata.values().find(|metadata| {
122 metadata.uri == requested
123 || metadata.uri == target.raw
124 || (metadata.network == target.network && metadata.circle == target.circle)
125 })
126 })
127 }
128}
129
130pub fn config_path() -> Result<PathBuf> {
131 if let Ok(path) = env::var("OCTRA_SQLITE_CONFIG") {
132 return Ok(PathBuf::from(path));
133 }
134 let home = dirs::home_dir()
135 .ok_or_else(|| Error::with_kind(ErrorKind::Config, "could not locate home directory"))?;
136 Ok(home.join(".octra").join("sqlite.json"))
137}
138
139pub fn load_config() -> Result<Config> {
140 let defaults = bundled_default_config()?;
141 let path = config_path()?;
142 if !path.exists() {
143 return Ok(defaults);
144 }
145 let text = fs::read_to_string(&path).map_err(|error| {
146 Error::with_kind(
147 ErrorKind::Io,
148 format!("reading {}: {error}", path.display()),
149 )
150 })?;
151 let user_config = serde_json::from_str(&text).map_err(|error| {
152 Error::with_kind(
153 ErrorKind::Config,
154 format!("parsing {}: {error}", path.display()),
155 )
156 })?;
157 Ok(merge_config(defaults, user_config))
158}
159
160pub fn write_config(config: &Config) -> Result<()> {
161 let path = config_path()?;
162 if let Some(parent) = path.parent() {
163 fs::create_dir_all(parent)?;
164 }
165 fs::write(&path, serde_json::to_string_pretty(config)? + "\n")?;
166 Ok(())
167}
168
169fn bundled_default_config() -> Result<Config> {
170 serde_json::from_str(DEFAULT_CONFIG_JSON).map_err(|error| {
171 Error::with_kind(
172 ErrorKind::Config,
173 format!("parsing bundled default config: {error}"),
174 )
175 })
176}
177
178fn merge_config(mut defaults: Config, user: Config) -> Config {
179 defaults.wallet = user.wallet.or(defaults.wallet);
180 defaults.network = user.network.or(defaults.network);
181 defaults.networks.extend(user.networks);
182 defaults.rpc = user
183 .rpc
184 .or_else(|| {
185 defaults
186 .network
187 .as_deref()
188 .and_then(|network| defaults.networks.get(network)?.rpc.clone())
189 })
190 .or(defaults.rpc);
191 defaults.explorer = user
192 .explorer
193 .or_else(|| {
194 defaults
195 .network
196 .as_deref()
197 .and_then(|network| defaults.networks.get(network)?.explorer.clone())
198 })
199 .or(defaults.explorer);
200 defaults.default_database = user.default_database.or(defaults.default_database);
201 defaults.databases.extend(user.databases);
202 defaults.database_metadata.extend(user.database_metadata);
203 defaults
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn reads_and_writes_database_names() {
212 let config: Config = serde_json::from_str(
213 r#"{"default_database":"organization","databases":{"organization":"oct://devnet/octABC"}}"#,
214 )
215 .unwrap();
216 assert_eq!(config.default_database.as_deref(), Some("organization"));
217 assert_eq!(
218 config.databases.get("organization").map(String::as_str),
219 Some("oct://devnet/octABC")
220 );
221
222 let written = serde_json::to_string(&config).unwrap();
223 assert!(written.contains("default_database"));
224 assert!(written.contains("databases"));
225 assert!(written.contains("database_metadata"));
226 }
227
228 #[test]
229 fn bundled_defaults_are_product_clean() {
230 let config = bundled_default_config().unwrap();
231 assert_eq!(config.network.as_deref(), Some("devnet"));
232 assert!(config.default_database.is_none());
233 assert_eq!(
234 config.rpc.as_deref(),
235 Some("https://devnet.octrascan.io/rpc")
236 );
237 assert_eq!(
238 config
239 .networks
240 .get("devnet")
241 .and_then(|network| network.rpc.as_deref()),
242 Some("https://devnet.octrascan.io/rpc")
243 );
244 assert_eq!(
245 config.explorer.as_deref(),
246 Some("https://devnet.octrascan.io")
247 );
248 assert_eq!(
249 config
250 .networks
251 .get("mainnet")
252 .and_then(|network| network.rpc.as_deref()),
253 Some("https://octra.network/rpc")
254 );
255 assert_eq!(
256 config
257 .networks
258 .get("mainnet")
259 .and_then(|network| network.explorer.as_deref()),
260 Some("https://octrascan.io")
261 );
262 assert!(config.databases.is_empty());
263 assert!(config.database_metadata.is_empty());
264 }
265
266 #[test]
267 fn user_config_overlays_bundled_defaults() {
268 let defaults: Config = serde_json::from_str(
269 r#"{"rpc":"http://default","network":"devnet","default_database":"remilia","databases":{"remilia":"oct://devnet/octA"}}"#,
270 )
271 .unwrap();
272 let user: Config = serde_json::from_str(
273 r#"{"rpc":"http://custom","default_database":"organization","databases":{"organization":"oct://devnet/octB"},"database_metadata":{"organization":{"uri":"oct://devnet/octB","network":"devnet","circle":"octB","owner":"octOwner","owner_pubkey":"aa","db_id":"bb","code_hash":"cc","code_bytes":123,"create_tx":"tx"}}}"#,
274 )
275 .unwrap();
276 let merged = merge_config(defaults, user);
277 assert_eq!(merged.rpc.as_deref(), Some("http://custom"));
278 assert_eq!(merged.network.as_deref(), Some("devnet"));
279 assert_eq!(merged.default_database.as_deref(), Some("organization"));
280 assert_eq!(
281 merged.databases.get("remilia").map(String::as_str),
282 Some("oct://devnet/octA")
283 );
284 assert_eq!(
285 merged.databases.get("organization").map(String::as_str),
286 Some("oct://devnet/octB")
287 );
288 assert_eq!(
289 merged
290 .database_metadata
291 .get("organization")
292 .map(|metadata| metadata.code_hash.as_str()),
293 Some("cc")
294 );
295 assert_eq!(
296 merged
297 .database_metadata
298 .get("organization")
299 .and_then(|metadata| metadata.program_update_tx.as_deref()),
300 None
301 );
302 }
303
304 #[test]
305 fn network_profiles_supply_active_urls() {
306 let defaults: Config = serde_json::from_str(
307 r#"{
308 "rpc":"http://devnet",
309 "explorer":"https://devnet",
310 "network":"devnet",
311 "networks":{
312 "devnet":{"rpc":"http://devnet","explorer":"https://devnet"},
313 "mainnet":{"rpc":"https://octra.network/rpc","explorer":"https://octrascan.io"}
314 }
315 }"#,
316 )
317 .unwrap();
318 let user: Config = serde_json::from_str(r#"{"network":"mainnet"}"#).unwrap();
319 let merged = merge_config(defaults, user);
320 assert_eq!(merged.network.as_deref(), Some("mainnet"));
321 assert_eq!(merged.rpc.as_deref(), Some("https://octra.network/rpc"));
322 assert_eq!(merged.explorer.as_deref(), Some("https://octrascan.io"));
323 assert_eq!(
324 merged.rpc_for_network("devnet").as_deref(),
325 Some("http://devnet")
326 );
327 }
328}