Skip to main content

octra_sqlite/protocol/
target.rs

1//! `oct://` database targets and Octra read modes.
2
3use super::error::{Error, Result};
4use serde::{Deserialize, Serialize};
5
6/// Read authentication mode for a database target.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8#[serde(rename_all = "snake_case")]
9pub enum ReadMode {
10    /// Probe Circle metadata before choosing signed or unsigned reads.
11    Auto,
12    /// Signed Octra view auth reads.
13    #[default]
14    Sealed,
15    /// Unsigned public Circle view reads.
16    Public,
17}
18
19impl ReadMode {
20    /// Return the URI/config spelling for this mode.
21    pub fn as_str(self) -> &'static str {
22        match self {
23            ReadMode::Auto => "auto",
24            ReadMode::Sealed => "sealed",
25            ReadMode::Public => "public",
26        }
27    }
28
29    /// Return whether this mode can use unsigned Circle views.
30    pub fn allows_unsigned_read(self) -> bool {
31        matches!(self, ReadMode::Auto | ReadMode::Public)
32    }
33}
34
35/// Parsed `oct://` target plus read mode and RPC context.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DatabaseTarget {
38    /// Original target text.
39    pub raw: String,
40    /// Octra network name.
41    pub network: String,
42    /// Circle ID.
43    pub circle: String,
44    /// Resolved Octra RPC URL.
45    pub rpc: String,
46    /// Requested or detected read mode.
47    pub read_mode: ReadMode,
48}
49
50/// Parse a Circle ID or `oct://` URI using optional network and RPC defaults.
51pub fn parse_database_target(
52    value: &str,
53    default_network: Option<&str>,
54    default_rpc: Option<&str>,
55) -> Result<DatabaseTarget> {
56    let default_rpc = default_rpc.unwrap_or_default().to_string();
57    if let Some(rest) = value.strip_prefix("oct://") {
58        let without_query = rest.split('?').next().unwrap_or(rest);
59        let pieces: Vec<&str> = without_query
60            .trim_matches('/')
61            .split('/')
62            .filter(|p| !p.is_empty())
63            .collect();
64        let (network, circle) = match pieces.as_slice() {
65            [circle] => (
66                default_network
67                    .ok_or_else(|| Error::new("network is required for oct://<circle-id> URIs"))?
68                    .to_string(),
69                (*circle).to_string(),
70            ),
71            [network, circle] => ((*network).to_string(), (*circle).to_string()),
72            _ => {
73                return Err(Error::new(
74                    "oct database URI must look like oct://NETWORK/<circle-id>",
75                ));
76            }
77        };
78        if !circle.starts_with("oct") {
79            return Err(Error::new("Circle ID must start with oct"));
80        }
81        let read_mode = read_mode_from_query(rest)?;
82        return Ok(DatabaseTarget {
83            raw: value.to_string(),
84            network,
85            circle,
86            rpc: default_rpc,
87            read_mode,
88        });
89    }
90    if value.starts_with("oct") {
91        return Ok(DatabaseTarget {
92            raw: value.to_string(),
93            network: default_network
94                .ok_or_else(|| Error::new("network is required for bare Circle IDs"))?
95                .to_string(),
96            circle: value.to_string(),
97            rpc: default_rpc,
98            read_mode: ReadMode::Auto,
99        });
100    }
101    Err(Error::new(format!(
102        "unknown database {value}; use a database name, Circle ID, or oct://NETWORK/<circle-id>"
103    )))
104}
105
106fn read_mode_from_query(rest: &str) -> Result<ReadMode> {
107    let Some((_, query)) = rest.split_once('?') else {
108        return Ok(ReadMode::Auto);
109    };
110    for pair in query.split('&') {
111        let Some((key, value)) = pair.split_once('=') else {
112            continue;
113        };
114        if key == "read_mode" {
115            return match value {
116                "auto" => Ok(ReadMode::Auto),
117                "sealed" => Ok(ReadMode::Sealed),
118                "public" => Ok(ReadMode::Public),
119                _ => Err(Error::new("read_mode must be auto, sealed, or public")),
120            };
121        }
122    }
123    Ok(ReadMode::Auto)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn parses_oct_database_target() {
132        let target =
133            parse_database_target("oct://devnet/octABC", None, Some("http://rpc")).unwrap();
134        assert_eq!(target.network, "devnet");
135        assert_eq!(target.circle, "octABC");
136        assert_eq!(target.rpc, "http://rpc");
137        assert_eq!(target.read_mode, ReadMode::Auto);
138    }
139
140    #[test]
141    fn bare_circle_uses_default_network() {
142        let target = parse_database_target("octABC", Some("devnet"), None).unwrap();
143        assert_eq!(target.network, "devnet");
144        assert_eq!(target.circle, "octABC");
145        assert_eq!(target.read_mode, ReadMode::Auto);
146    }
147
148    #[test]
149    fn parses_read_mode_query() {
150        let target =
151            parse_database_target("oct://devnet/octABC?read_mode=public", None, None).unwrap();
152        assert_eq!(target.read_mode, ReadMode::Public);
153    }
154}