use crate::error::{Result, TectonError};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeMode {
Storage,
Compute,
#[default]
Full,
}
impl RuntimeMode {
pub fn parse(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"storage" | "io" | "s3" => Ok(Self::Storage),
"compute" | "sql" | "query" => Ok(Self::Compute),
"full" | "all" | "both" => Ok(Self::Full),
other => Err(TectonError::invalid_input(format!(
"unknown runtime mode '{other}'; expected storage|compute|full"
))),
}
}
pub fn includes_storage(self) -> bool {
matches!(self, Self::Storage | Self::Full)
}
pub fn includes_compute(self) -> bool {
matches!(self, Self::Compute | Self::Full)
}
}
impl std::fmt::Display for RuntimeMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Storage => write!(f, "storage"),
Self::Compute => write!(f, "compute"),
Self::Full => write!(f, "full"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".into(),
port: 9000,
}
}
}
impl ServerConfig {
pub fn socket_addr(&self) -> Result<SocketAddr> {
let addr = format!("{}:{}", self.host, self.port);
addr.parse()
.map_err(|e| TectonError::config(format!("invalid bind address '{addr}': {e}")))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
pub backend: String,
pub root: PathBuf,
#[serde(default)]
pub bucket: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
backend: "fs".into(),
root: default_data_dir().join("objects"),
bucket: None,
region: None,
endpoint: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputeConfig {
pub data_dir: PathBuf,
pub max_rows: usize,
}
impl Default for ComputeConfig {
fn default() -> Self {
Self {
data_dir: default_data_dir().join("datasets"),
max_rows: 10_000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TectonConfig {
pub mode: RuntimeMode,
pub server: ServerConfig,
pub storage: StorageConfig,
pub compute: ComputeConfig,
pub log_level: String,
}
impl Default for TectonConfig {
fn default() -> Self {
Self {
mode: RuntimeMode::Full,
server: ServerConfig::default(),
storage: StorageConfig::default(),
compute: ComputeConfig::default(),
log_level: "info".into(),
}
}
}
impl TectonConfig {
pub fn load(config_path: Option<&Path>) -> Result<Self> {
let _ = dotenvy::dotenv();
let mut builder = config::Config::builder()
.set_default("mode", "full")?
.set_default("server.host", "127.0.0.1")?
.set_default("server.port", 9000)?
.set_default("storage.backend", "fs")?
.set_default(
"storage.root",
default_data_dir()
.join("objects")
.to_string_lossy()
.to_string(),
)?
.set_default(
"compute.data_dir",
default_data_dir()
.join("datasets")
.to_string_lossy()
.to_string(),
)?
.set_default("compute.max_rows", 10_000)?
.set_default("log_level", "info")?;
if let Some(path) = config_path {
if path.exists() {
builder = builder.add_source(config::File::from(path.to_path_buf()));
} else {
return Err(TectonError::config(format!(
"config file not found: {}",
path.display()
)));
}
} else {
for candidate in default_config_candidates() {
if candidate.exists() {
builder = builder.add_source(config::File::from(candidate));
break;
}
}
}
builder = builder.add_source(
config::Environment::with_prefix("TECTON")
.separator("__")
.try_parsing(true),
);
let cfg: TectonConfig = builder
.build()
.map_err(|e| TectonError::config(e.to_string()))?
.try_deserialize()
.map_err(|e| TectonError::config(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<()> {
if self.server.port == 0 {
return Err(TectonError::invalid_input("server port must be non-zero"));
}
let backend = self.storage.backend.to_ascii_lowercase();
if !matches!(backend.as_str(), "fs" | "s3" | "azblob" | "gcs") {
return Err(TectonError::invalid_input(format!(
"unsupported storage backend '{}'; expected fs|s3|azblob|gcs",
self.storage.backend
)));
}
if self.compute.max_rows == 0 {
return Err(TectonError::invalid_input(
"compute.max_rows must be greater than zero",
));
}
if backend == "fs" {
std::fs::create_dir_all(&self.storage.root)?;
}
std::fs::create_dir_all(&self.compute.data_dir)?;
Ok(())
}
}
pub fn default_data_dir() -> PathBuf {
ProjectDirs::from("io", "tecton", "tecton")
.map(|d| d.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from(".data"))
}
fn default_config_candidates() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("tecton.toml"), PathBuf::from("tecton.yaml")];
if let Some(dirs) = ProjectDirs::from("io", "tecton", "tecton") {
paths.push(dirs.config_dir().join("tecton.toml"));
}
paths
}
impl From<config::ConfigError> for TectonError {
fn from(value: config::ConfigError) -> Self {
TectonError::config(value.to_string())
}
}