octra-sqlite 0.5.2

Real SQLite inside an Octra Circle, with a Rust CLI and client library
Documentation
use super::error::{Error, ErrorKind, Result};
use crate::protocol::target::{DatabaseTarget, ReadMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::PathBuf;

const DEFAULT_CONFIG_JSON: &str = include_str!("../../config/defaults.json");

/// Local octra-sqlite configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    pub wallet: Option<String>,
    pub rpc: Option<String>,
    pub explorer: Option<String>,
    pub network: Option<String>,
    #[serde(default)]
    pub networks: BTreeMap<String, NetworkConfig>,
    #[serde(default)]
    pub default_database: Option<String>,
    #[serde(default)]
    pub databases: BTreeMap<String, String>,
    #[serde(default)]
    pub database_metadata: BTreeMap<String, DatabaseMetadata>,
}

/// Per-network RPC and explorer profile.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NetworkConfig {
    pub rpc: Option<String>,
    pub explorer: Option<String>,
}

/// Saved database metadata written by `octra-sqlite new`.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct DatabaseMetadata {
    pub uri: String,
    pub network: String,
    pub circle: String,
    #[serde(default = "default_read_mode")]
    pub read_mode: ReadMode,
    #[serde(default = "default_privacy_class")]
    pub privacy_class: String,
    #[serde(default = "default_browser_mode")]
    pub browser_mode: String,
    #[serde(default = "default_resource_mode")]
    pub resource_mode: String,
    pub owner: String,
    pub owner_pubkey: String,
    pub db_id: String,
    pub code_hash: String,
    pub code_bytes: usize,
    pub create_tx: Option<String>,
    #[serde(default)]
    pub program_update_tx: Option<String>,
}

fn default_privacy_class() -> String {
    "sealed".to_string()
}

fn default_read_mode() -> ReadMode {
    ReadMode::Sealed
}

fn default_browser_mode() -> String {
    "native_sealed".to_string()
}

fn default_resource_mode() -> String {
    "sealed_read".to_string()
}

impl Config {
    pub fn rpc_for_network(&self, network: &str) -> Option<String> {
        if self.network.as_deref() == Some(network) {
            return self
                .rpc
                .clone()
                .or_else(|| self.networks.get(network)?.rpc.clone());
        }
        self.networks
            .get(network)
            .and_then(|profile| profile.rpc.clone())
    }

    pub fn explorer_for_network(&self, network: &str) -> Option<String> {
        if self.network.as_deref() == Some(network) {
            return self
                .explorer
                .clone()
                .or_else(|| self.networks.get(network)?.explorer.clone());
        }
        self.networks
            .get(network)
            .and_then(|profile| profile.explorer.clone())
    }

    pub fn apply_active_network_profile(&mut self) {
        let Some(network) = self.network.as_deref() else {
            return;
        };
        let Some(profile) = self.networks.get(network) else {
            return;
        };
        if let Some(rpc) = &profile.rpc {
            self.rpc = Some(rpc.clone());
        }
        if let Some(explorer) = &profile.explorer {
            self.explorer = Some(explorer.clone());
        }
    }

    pub fn metadata_for_target(
        &self,
        requested: &str,
        target: &DatabaseTarget,
    ) -> Option<&DatabaseMetadata> {
        self.database_metadata.get(requested).or_else(|| {
            self.database_metadata.values().find(|metadata| {
                metadata.uri == requested
                    || metadata.uri == target.raw
                    || (metadata.network == target.network && metadata.circle == target.circle)
            })
        })
    }
}

pub fn config_path() -> Result<PathBuf> {
    if let Ok(path) = env::var("OCTRA_SQLITE_CONFIG") {
        return Ok(PathBuf::from(path));
    }
    let home = dirs::home_dir()
        .ok_or_else(|| Error::with_kind(ErrorKind::Config, "could not locate home directory"))?;
    Ok(home.join(".octra").join("sqlite.json"))
}

pub fn load_config() -> Result<Config> {
    let defaults = bundled_default_config()?;
    let path = config_path()?;
    if !path.exists() {
        return Ok(defaults);
    }
    let text = fs::read_to_string(&path).map_err(|error| {
        Error::with_kind(
            ErrorKind::Io,
            format!("reading {}: {error}", path.display()),
        )
    })?;
    let user_config = serde_json::from_str(&text).map_err(|error| {
        Error::with_kind(
            ErrorKind::Config,
            format!("parsing {}: {error}", path.display()),
        )
    })?;
    Ok(merge_config(defaults, user_config))
}

pub fn write_config(config: &Config) -> Result<()> {
    let path = config_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, serde_json::to_string_pretty(config)? + "\n")?;
    Ok(())
}

fn bundled_default_config() -> Result<Config> {
    serde_json::from_str(DEFAULT_CONFIG_JSON).map_err(|error| {
        Error::with_kind(
            ErrorKind::Config,
            format!("parsing bundled default config: {error}"),
        )
    })
}

fn merge_config(mut defaults: Config, user: Config) -> Config {
    defaults.wallet = user.wallet.or(defaults.wallet);
    defaults.network = user.network.or(defaults.network);
    defaults.networks.extend(user.networks);
    defaults.rpc = user
        .rpc
        .or_else(|| {
            defaults
                .network
                .as_deref()
                .and_then(|network| defaults.networks.get(network)?.rpc.clone())
        })
        .or(defaults.rpc);
    defaults.explorer = user
        .explorer
        .or_else(|| {
            defaults
                .network
                .as_deref()
                .and_then(|network| defaults.networks.get(network)?.explorer.clone())
        })
        .or(defaults.explorer);
    defaults.default_database = user.default_database.or(defaults.default_database);
    defaults.databases.extend(user.databases);
    defaults.database_metadata.extend(user.database_metadata);
    defaults
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reads_and_writes_database_names() {
        let config: Config = serde_json::from_str(
            r#"{"default_database":"organization","databases":{"organization":"oct://devnet/octABC"}}"#,
        )
        .unwrap();
        assert_eq!(config.default_database.as_deref(), Some("organization"));
        assert_eq!(
            config.databases.get("organization").map(String::as_str),
            Some("oct://devnet/octABC")
        );

        let written = serde_json::to_string(&config).unwrap();
        assert!(written.contains("default_database"));
        assert!(written.contains("databases"));
        assert!(written.contains("database_metadata"));
    }

    #[test]
    fn bundled_defaults_are_product_clean() {
        let config = bundled_default_config().unwrap();
        assert_eq!(config.network.as_deref(), Some("devnet"));
        assert!(config.default_database.is_none());
        assert_eq!(
            config.rpc.as_deref(),
            Some("https://devnet.octrascan.io/rpc")
        );
        assert_eq!(
            config
                .networks
                .get("devnet")
                .and_then(|network| network.rpc.as_deref()),
            Some("https://devnet.octrascan.io/rpc")
        );
        assert_eq!(
            config.explorer.as_deref(),
            Some("https://devnet.octrascan.io")
        );
        assert_eq!(
            config
                .networks
                .get("mainnet")
                .and_then(|network| network.rpc.as_deref()),
            Some("https://octra.network/rpc")
        );
        assert_eq!(
            config
                .networks
                .get("mainnet")
                .and_then(|network| network.explorer.as_deref()),
            Some("https://octrascan.io")
        );
        assert!(config.databases.is_empty());
        assert!(config.database_metadata.is_empty());
    }

    #[test]
    fn user_config_overlays_bundled_defaults() {
        let defaults: Config = serde_json::from_str(
            r#"{"rpc":"http://default","network":"devnet","default_database":"remilia","databases":{"remilia":"oct://devnet/octA"}}"#,
        )
        .unwrap();
        let user: Config = serde_json::from_str(
            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"}}}"#,
        )
        .unwrap();
        let merged = merge_config(defaults, user);
        assert_eq!(merged.rpc.as_deref(), Some("http://custom"));
        assert_eq!(merged.network.as_deref(), Some("devnet"));
        assert_eq!(merged.default_database.as_deref(), Some("organization"));
        assert_eq!(
            merged.databases.get("remilia").map(String::as_str),
            Some("oct://devnet/octA")
        );
        assert_eq!(
            merged.databases.get("organization").map(String::as_str),
            Some("oct://devnet/octB")
        );
        assert_eq!(
            merged
                .database_metadata
                .get("organization")
                .map(|metadata| metadata.code_hash.as_str()),
            Some("cc")
        );
        assert_eq!(
            merged
                .database_metadata
                .get("organization")
                .and_then(|metadata| metadata.program_update_tx.as_deref()),
            None
        );
    }

    #[test]
    fn network_profiles_supply_active_urls() {
        let defaults: Config = serde_json::from_str(
            r#"{
                "rpc":"http://devnet",
                "explorer":"https://devnet",
                "network":"devnet",
                "networks":{
                    "devnet":{"rpc":"http://devnet","explorer":"https://devnet"},
                    "mainnet":{"rpc":"https://octra.network/rpc","explorer":"https://octrascan.io"}
                }
            }"#,
        )
        .unwrap();
        let user: Config = serde_json::from_str(r#"{"network":"mainnet"}"#).unwrap();
        let merged = merge_config(defaults, user);
        assert_eq!(merged.network.as_deref(), Some("mainnet"));
        assert_eq!(merged.rpc.as_deref(), Some("https://octra.network/rpc"));
        assert_eq!(merged.explorer.as_deref(), Some("https://octrascan.io"));
        assert_eq!(
            merged.rpc_for_network("devnet").as_deref(),
            Some("http://devnet")
        );
    }
}