1use std::fmt;
24use std::str::FromStr;
25
26use crate::config::LaminarConfig;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Profile {
34 #[default]
36 BareMetal,
37 Embedded,
39 Durable,
41 Cluster,
43}
44
45impl Profile {
46 #[must_use]
58 pub fn from_config(config: &LaminarConfig, has_discovery: bool) -> Self {
59 if has_discovery {
60 return Self::Cluster;
61 }
62 if let Some(url) = &config.object_store_url {
63 if url.starts_with("s3://")
64 || url.starts_with("gs://")
65 || url.starts_with("az://")
66 || url.starts_with("abfs://")
67 || url.starts_with("abfss://")
68 {
69 return Self::Durable;
70 }
71 if url.starts_with("file://") {
72 return Self::Embedded;
73 }
74 }
75 if config.storage_dir.is_some()
76 || config
77 .checkpoint
78 .as_ref()
79 .and_then(|checkpoint| checkpoint.data_dir.as_ref())
80 .is_some()
81 {
82 return Self::Embedded;
83 }
84 Self::BareMetal
85 }
86
87 pub fn validate_features(self) -> Result<(), ProfileError> {
96 match self {
102 Self::BareMetal | Self::Embedded | Self::Durable | Self::Cluster => Ok(()),
103 }
104 }
105
106 pub fn validate_config(
115 self,
116 config: &LaminarConfig,
117 object_store_url: Option<&str>,
118 ) -> Result<(), ProfileError> {
119 match self {
120 Self::BareMetal => Ok(()),
121 Self::Embedded => {
122 if let Some(url) = object_store_url.filter(|url| url.starts_with("file://")) {
123 laminar_core::storage::object_store_builder::file_url_path(url)
124 .map_err(|error| ProfileError::RequirementNotMet(error.to_string()))?;
125 return Ok(());
126 }
127 if config.storage_dir.is_some()
128 || config
129 .checkpoint
130 .as_ref()
131 .and_then(|checkpoint| checkpoint.data_dir.as_ref())
132 .is_some()
133 {
134 return Ok(());
135 }
136 Err(ProfileError::RequirementNotMet(
137 "Embedded profile requires an absolute file:// checkpoint URL or local storage directory"
138 .into(),
139 ))
140 }
141 Self::Durable | Self::Cluster => {
142 if object_store_url.is_none() {
143 return Err(ProfileError::RequirementNotMet(
144 "Durable/Cluster profile requires an \
145 object_store_url"
146 .into(),
147 ));
148 }
149 Ok(())
150 }
151 }
152 }
153
154 pub fn apply_defaults(self, config: &mut LaminarConfig) {
158 match self {
159 Self::BareMetal => {
160 }
162 Self::Embedded => {
163 if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
165 config.default_buffer_size = 32_768;
166 }
167 }
168 Self::Durable => {
169 if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
171 config.default_buffer_size = 131_072;
172 }
173 }
174 Self::Cluster => {
175 if config.default_buffer_size == LaminarConfig::default().default_buffer_size {
177 config.default_buffer_size = 262_144;
178 }
179 }
180 }
181 }
182}
183
184impl FromStr for Profile {
185 type Err = ProfileError;
186
187 fn from_str(s: &str) -> Result<Self, Self::Err> {
188 match s.to_ascii_lowercase().as_str() {
189 "bare_metal" | "baremetal" | "bare-metal" => Ok(Self::BareMetal),
190 "embedded" => Ok(Self::Embedded),
191 "durable" => Ok(Self::Durable),
192 "cluster" => Ok(Self::Cluster),
193 _ => Err(ProfileError::UnknownProfileName(s.into())),
194 }
195 }
196}
197
198impl fmt::Display for Profile {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 match self {
201 Self::BareMetal => write!(f, "bare_metal"),
202 Self::Embedded => write!(f, "embedded"),
203 Self::Durable => write!(f, "durable"),
204 Self::Cluster => write!(f, "cluster"),
205 }
206 }
207}
208
209#[derive(Debug, thiserror::Error)]
211pub enum ProfileError {
212 #[error("profile requirement not met: {0}")]
214 RequirementNotMet(String),
215
216 #[error("feature `{0}` not compiled — enable it in Cargo.toml")]
218 FeatureNotCompiled(String),
219
220 #[error("unknown profile name: {0}")]
222 UnknownProfileName(String),
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_bare_metal_zero_config() {
231 let config = LaminarConfig::default();
232 let profile = Profile::BareMetal;
233
234 assert!(profile.validate_features().is_ok());
236 assert!(profile.validate_config(&config, None).is_ok());
237 }
238
239 #[test]
240 fn test_embedded_requires_storage_dir() {
241 let config = LaminarConfig::default();
242 let result = Profile::Embedded.validate_config(&config, None);
243 assert!(result.is_err());
244 assert!(matches!(
245 result.unwrap_err(),
246 ProfileError::RequirementNotMet(_)
247 ));
248 }
249
250 #[test]
251 fn embedded_accepts_an_absolute_file_checkpoint_url() {
252 let config = LaminarConfig::default();
253 let directory = tempfile::tempdir().unwrap();
254 let normalized = directory.path().display().to_string().replace('\\', "/");
255 let url = if normalized.starts_with('/') {
256 format!("file://{normalized}")
257 } else {
258 format!("file:///{normalized}")
259 };
260 assert!(Profile::Embedded
261 .validate_config(&config, Some(&url))
262 .is_ok());
263 assert!(Profile::Embedded
264 .validate_config(&config, Some("file://./relative"))
265 .is_err());
266 let config_with_fallback = LaminarConfig {
267 storage_dir: Some(directory.path().to_path_buf()),
268 ..LaminarConfig::default()
269 };
270 assert!(Profile::Embedded
271 .validate_config(&config_with_fallback, Some("file://./relative"))
272 .is_err());
273 }
274
275 #[test]
276 fn test_durable_fails_without_object_store_url() {
277 let config = LaminarConfig::default();
278 let result = Profile::Durable.validate_config(&config, None);
279 assert!(result.is_err());
280 assert!(matches!(
281 result.unwrap_err(),
282 ProfileError::RequirementNotMet(_)
283 ));
284 }
285
286 #[test]
287 fn test_profile_from_str() {
288 assert_eq!(Profile::from_str("bare_metal").unwrap(), Profile::BareMetal);
289 assert_eq!(Profile::from_str("baremetal").unwrap(), Profile::BareMetal);
290 assert_eq!(Profile::from_str("bare-metal").unwrap(), Profile::BareMetal);
291 assert_eq!(Profile::from_str("embedded").unwrap(), Profile::Embedded);
292 assert_eq!(Profile::from_str("durable").unwrap(), Profile::Durable);
293 assert_eq!(Profile::from_str("cluster").unwrap(), Profile::Cluster);
294 assert_eq!(Profile::from_str("DURABLE").unwrap(), Profile::Durable);
296 assert!(Profile::from_str("quantum").is_err());
298 assert!(matches!(
299 Profile::from_str("quantum").unwrap_err(),
300 ProfileError::UnknownProfileName(_)
301 ));
302 }
303
304 #[test]
305 fn test_all_profiles_validate_features() {
306 assert!(Profile::BareMetal.validate_features().is_ok());
308 assert!(Profile::Embedded.validate_features().is_ok());
309 assert!(Profile::Durable.validate_features().is_ok());
310 assert!(Profile::Cluster.validate_features().is_ok());
311 }
312
313 #[test]
314 fn test_profile_display() {
315 assert_eq!(Profile::BareMetal.to_string(), "bare_metal");
316 assert_eq!(Profile::Embedded.to_string(), "embedded");
317 assert_eq!(Profile::Durable.to_string(), "durable");
318 assert_eq!(Profile::Cluster.to_string(), "cluster");
319 }
320
321 #[test]
322 fn test_profile_default() {
323 assert_eq!(Profile::default(), Profile::BareMetal);
324 }
325
326 #[test]
327 fn test_apply_defaults_bare_metal_noop() {
328 let mut config = LaminarConfig::default();
329 let original_buffer = config.default_buffer_size;
330 Profile::BareMetal.apply_defaults(&mut config);
331 assert_eq!(config.default_buffer_size, original_buffer);
332 }
333
334 #[test]
335 fn test_apply_defaults_does_not_override_user_values() {
336 let mut config = LaminarConfig {
337 default_buffer_size: 999,
338 ..LaminarConfig::default()
339 };
340 Profile::Durable.apply_defaults(&mut config);
341 assert_eq!(config.default_buffer_size, 999);
343 }
344
345 #[test]
346 fn test_from_config_bare_metal() {
347 let config = LaminarConfig::default();
348 assert_eq!(Profile::from_config(&config, false), Profile::BareMetal);
349 }
350
351 #[test]
352 fn test_from_config_embedded_storage_dir() {
353 let config = LaminarConfig {
354 storage_dir: Some(std::path::PathBuf::from("/tmp/data")),
355 ..LaminarConfig::default()
356 };
357 assert_eq!(Profile::from_config(&config, false), Profile::Embedded);
358 }
359
360 #[test]
361 fn test_from_config_embedded_file_url() {
362 let config = LaminarConfig {
363 object_store_url: Some("file:///tmp/checkpoints".to_string()),
364 ..LaminarConfig::default()
365 };
366 assert_eq!(Profile::from_config(&config, false), Profile::Embedded);
367 }
368
369 #[test]
370 fn test_from_config_durable_s3() {
371 let config = LaminarConfig {
372 object_store_url: Some("s3://my-bucket/prefix".to_string()),
373 ..LaminarConfig::default()
374 };
375 assert_eq!(Profile::from_config(&config, false), Profile::Durable);
376 }
377
378 #[test]
379 fn test_from_config_durable_gs() {
380 let config = LaminarConfig {
381 object_store_url: Some("gs://my-bucket/prefix".to_string()),
382 ..LaminarConfig::default()
383 };
384 assert_eq!(Profile::from_config(&config, false), Profile::Durable);
385 }
386
387 #[test]
388 fn test_from_config_durable_az() {
389 let config = LaminarConfig {
390 object_store_url: Some("az://container/prefix".to_string()),
391 ..LaminarConfig::default()
392 };
393 assert_eq!(Profile::from_config(&config, false), Profile::Durable);
394 }
395
396 #[test]
397 fn test_from_config_durable_abfs() {
398 let config = LaminarConfig {
399 object_store_url: Some("abfs://container/prefix".to_string()),
400 ..LaminarConfig::default()
401 };
402 assert_eq!(Profile::from_config(&config, false), Profile::Durable);
403 }
404
405 #[test]
406 fn test_from_config_durable_abfss() {
407 let config = LaminarConfig {
408 object_store_url: Some("abfss://container/prefix".to_string()),
409 ..LaminarConfig::default()
410 };
411 assert_eq!(Profile::from_config(&config, false), Profile::Durable);
412 }
413
414 #[test]
415 fn test_from_config_cluster() {
416 let config = LaminarConfig::default();
417 assert_eq!(Profile::from_config(&config, true), Profile::Cluster);
418 }
419
420 #[test]
421 fn test_from_config_cluster_overrides_url() {
422 let config = LaminarConfig {
423 object_store_url: Some("s3://bucket/prefix".to_string()),
424 ..LaminarConfig::default()
425 };
426 assert_eq!(Profile::from_config(&config, true), Profile::Cluster);
428 }
429}