Skip to main content

octra_sqlite/client/
config.rs

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/// Local octra-sqlite configuration.
12#[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/// Per-network RPC and explorer profile.
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct NetworkConfig {
31    pub rpc: Option<String>,
32    pub explorer: Option<String>,
33}
34
35/// Saved database metadata written by `octra-sqlite new`.
36#[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}