tecton-core 0.1.0

Shared utilities, configuration, logging, and types for Tecton
Documentation
//! Runtime configuration loading and validation.

use crate::error::{Result, TectonError};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};

/// Which services the binary should start.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeMode {
    /// S3-compatible storage API only.
    Storage,
    /// SQL/compute engine only.
    Compute,
    /// Both storage and compute (default).
    #[default]
    Full,
}

impl RuntimeMode {
    /// Parse a mode string from CLI/env (`storage`, `compute`, `full`).
    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"),
        }
    }
}

/// HTTP bind settings for the storage API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
    /// Host interface to bind (default: 127.0.0.1 for local-first security).
    pub host: String,
    /// TCP port for the S3-compatible API.
    pub port: u16,
}

impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".into(),
            port: 9000,
        }
    }
}

impl ServerConfig {
    /// Resolve a validated socket address.
    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}")))
    }
}

/// Object-storage backend configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
    /// Backend kind: `fs`, `s3`, `azblob`, or `gcs`.
    pub backend: String,
    /// Root path or bucket name depending on backend.
    pub root: PathBuf,
    /// Optional bucket (cloud backends).
    #[serde(default)]
    pub bucket: Option<String>,
    /// Optional region (S3).
    #[serde(default)]
    pub region: Option<String>,
    /// Optional endpoint override (MinIO-compatible, custom S3).
    #[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,
        }
    }
}

/// Compute/SQL engine settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputeConfig {
    /// Directory scanned for Parquet/CSV datasets.
    pub data_dir: PathBuf,
    /// Maximum rows returned by a single query (safety limit).
    pub max_rows: usize,
}

impl Default for ComputeConfig {
    fn default() -> Self {
        Self {
            data_dir: default_data_dir().join("datasets"),
            max_rows: 10_000,
        }
    }
}

/// Top-level Tecton configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TectonConfig {
    pub mode: RuntimeMode,
    pub server: ServerConfig,
    pub storage: StorageConfig,
    pub compute: ComputeConfig,
    /// Log filter directive (e.g. `info`, `tecton=debug`).
    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 {
    /// Load configuration from optional file + environment overrides.
    ///
    /// Environment variables use the `TECTON_` prefix (e.g. `TECTON_SERVER__PORT=9000`).
    pub fn load(config_path: Option<&Path>) -> Result<Self> {
        // Soft-load .env if present; ignore missing file.
        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 {
            // Try conventional locations without failing if absent.
            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)
    }

    /// Validate paths, ports, and backend names.
    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",
            ));
        }

        // Ensure local directories exist when using the filesystem backend.
        if backend == "fs" {
            std::fs::create_dir_all(&self.storage.root)?;
        }
        std::fs::create_dir_all(&self.compute.data_dir)?;

        Ok(())
    }
}

/// Platform-appropriate default data directory (`~/.local/share/tecton` etc.).
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
}

// Allow `config` crate conversion errors into our domain error.
impl From<config::ConfigError> for TectonError {
    fn from(value: config::ConfigError) -> Self {
        TectonError::config(value.to_string())
    }
}