meerkat_core/
runtime_bootstrap.rs1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10use crate::connection::{IdentityError, RealmId};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RealmLocator {
20 pub state_root: PathBuf,
21 pub realm: RealmId,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case", tag = "kind")]
27pub enum RealmSelection {
28 Explicit { realm_id: String },
29 Isolated,
30 WorkspaceDerived { root: PathBuf },
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(default)]
36pub struct RealmConfig {
37 pub selection: RealmSelection,
38 pub instance_id: Option<String>,
39 pub backend_hint: Option<String>,
41 pub state_root: Option<PathBuf>,
43}
44
45impl Default for RealmConfig {
46 fn default() -> Self {
47 Self {
48 selection: RealmSelection::Isolated,
49 instance_id: None,
50 backend_hint: None,
51 state_root: None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
58#[serde(default)]
59pub struct ContextConfig {
60 pub context_root: Option<PathBuf>,
61 pub user_config_root: Option<PathBuf>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
66#[serde(default)]
67pub struct RuntimeBootstrap {
68 pub realm: RealmConfig,
69 pub context: ContextConfig,
70}
71
72#[derive(Debug, thiserror::Error)]
74pub enum RuntimeBootstrapError {
75 #[error("`--realm` and `--isolated` cannot be used together")]
76 ConflictingSelection,
77 #[error("invalid explicit realm id: {0}")]
78 InvalidRealmId(String),
79}
80
81pub fn default_state_root() -> PathBuf {
83 dirs::data_dir()
84 .unwrap_or_else(|| PathBuf::from("."))
85 .join("meerkat")
86 .join("realms")
87}
88
89impl RealmConfig {
90 pub fn selection_from_inputs(
92 realm: Option<String>,
93 isolated: bool,
94 default: RealmSelection,
95 ) -> Result<RealmSelection, RuntimeBootstrapError> {
96 if realm.is_some() && isolated {
97 return Err(RuntimeBootstrapError::ConflictingSelection);
98 }
99 if let Some(realm_id) = realm {
100 validate_explicit_realm_id(&realm_id)?;
101 return Ok(RealmSelection::Explicit { realm_id });
102 }
103 if isolated {
104 return Ok(RealmSelection::Isolated);
105 }
106 Ok(default)
107 }
108
109 pub fn resolve_locator(&self) -> Result<RealmLocator, RuntimeBootstrapError> {
111 let state_root = self.state_root.clone().unwrap_or_else(default_state_root);
112 let realm_raw = match &self.selection {
113 RealmSelection::Explicit { realm_id } => realm_id.clone(),
114 RealmSelection::Isolated => generate_realm_id(),
115 RealmSelection::WorkspaceDerived { root } => derive_workspace_realm_id(root),
116 };
117 let realm = RealmId::parse(&realm_raw).map_err(|source| match source {
118 IdentityError::Empty => RuntimeBootstrapError::InvalidRealmId(realm_raw.clone()),
119 IdentityError::InvalidChar(_) => {
120 RuntimeBootstrapError::InvalidRealmId(realm_raw.clone())
121 }
122 })?;
123 Ok(RealmLocator { state_root, realm })
124 }
125}
126
127pub fn validate_explicit_realm_id(realm_id: &str) -> Result<(), RuntimeBootstrapError> {
128 if realm_id.is_empty()
129 || realm_id.len() > 64
130 || realm_id.contains(':')
131 || realm_id.chars().any(char::is_whitespace)
132 {
133 return Err(RuntimeBootstrapError::InvalidRealmId(realm_id.to_string()));
134 }
135 let mut chars = realm_id.chars();
136 let first = chars
137 .next()
138 .ok_or_else(|| RuntimeBootstrapError::InvalidRealmId(realm_id.to_string()))?;
139 if !first.is_ascii_alphanumeric() {
140 return Err(RuntimeBootstrapError::InvalidRealmId(realm_id.to_string()));
141 }
142 if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') {
143 return Err(RuntimeBootstrapError::InvalidRealmId(realm_id.to_string()));
144 }
145 if Uuid::parse_str(realm_id).is_ok() {
147 return Err(RuntimeBootstrapError::InvalidRealmId(realm_id.to_string()));
148 }
149 Ok(())
150}
151
152pub fn generate_realm_id() -> String {
153 format!("realm-{}", crate::time_compat::new_uuid_v7())
154}
155
156pub fn derive_workspace_realm_id(path: &Path) -> String {
157 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
158 let key = canonical.to_string_lossy();
159 format!("ws-{}", fnv1a64_hex(&key))
160}
161
162pub fn fnv1a64_hex(input: &str) -> String {
163 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
164 const PRIME: u64 = 0x0100_0000_01b3;
165 let mut hash = OFFSET;
166 for b in input.as_bytes() {
167 hash ^= u64::from(*b);
168 hash = hash.wrapping_mul(PRIME);
169 }
170 format!("{hash:016x}")
171}
172
173#[cfg(test)]
174#[allow(clippy::expect_used)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn selection_conflict_is_rejected() {
180 let result = RealmConfig::selection_from_inputs(
181 Some("team".to_string()),
182 true,
183 RealmSelection::Isolated,
184 );
185 assert!(matches!(
186 result,
187 Err(RuntimeBootstrapError::ConflictingSelection)
188 ));
189 }
190
191 #[test]
192 fn explicit_realm_id_validation() {
193 assert!(validate_explicit_realm_id("team-alpha_1").is_ok());
194 assert!(validate_explicit_realm_id("bad:name").is_err());
195 assert!(validate_explicit_realm_id("").is_err());
196 assert!(validate_explicit_realm_id("550e8400-e29b-41d4-a716-446655440000").is_err());
197 }
198
199 #[test]
200 fn workspace_selection_is_deterministic() {
201 let root = PathBuf::from(".");
202 let cfg = RealmConfig {
203 selection: RealmSelection::WorkspaceDerived { root },
204 ..RealmConfig::default()
205 };
206 let a = cfg.resolve_locator().map(|locator| locator.realm);
207 let b = cfg.resolve_locator().map(|locator| locator.realm);
208 assert!(a.is_ok());
209 assert_eq!(a.ok(), b.ok());
210 }
211}