Skip to main content

tecton_core/
config.rs

1//! Runtime configuration loading and validation.
2
3use crate::error::{Result, TectonError};
4use directories::ProjectDirs;
5use serde::{Deserialize, Serialize};
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8
9/// Which services the binary should start.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum RuntimeMode {
13    /// S3-compatible storage API only.
14    Storage,
15    /// SQL/compute engine only.
16    Compute,
17    /// Both storage and compute (default).
18    #[default]
19    Full,
20}
21
22impl RuntimeMode {
23    /// Parse a mode string from CLI/env (`storage`, `compute`, `full`).
24    pub fn parse(value: &str) -> Result<Self> {
25        match value.trim().to_ascii_lowercase().as_str() {
26            "storage" | "io" | "s3" => Ok(Self::Storage),
27            "compute" | "sql" | "query" => Ok(Self::Compute),
28            "full" | "all" | "both" => Ok(Self::Full),
29            other => Err(TectonError::invalid_input(format!(
30                "unknown runtime mode '{other}'; expected storage|compute|full"
31            ))),
32        }
33    }
34
35    pub fn includes_storage(self) -> bool {
36        matches!(self, Self::Storage | Self::Full)
37    }
38
39    pub fn includes_compute(self) -> bool {
40        matches!(self, Self::Compute | Self::Full)
41    }
42}
43
44impl std::fmt::Display for RuntimeMode {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::Storage => write!(f, "storage"),
48            Self::Compute => write!(f, "compute"),
49            Self::Full => write!(f, "full"),
50        }
51    }
52}
53
54/// HTTP bind settings for the storage API.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ServerConfig {
57    /// Host interface to bind (default: 127.0.0.1 for local-first security).
58    pub host: String,
59    /// TCP port for the S3-compatible API.
60    pub port: u16,
61}
62
63impl Default for ServerConfig {
64    fn default() -> Self {
65        Self {
66            host: "127.0.0.1".into(),
67            port: 9000,
68        }
69    }
70}
71
72impl ServerConfig {
73    /// Resolve a validated socket address.
74    pub fn socket_addr(&self) -> Result<SocketAddr> {
75        let addr = format!("{}:{}", self.host, self.port);
76        addr.parse()
77            .map_err(|e| TectonError::config(format!("invalid bind address '{addr}': {e}")))
78    }
79}
80
81/// Object-storage backend configuration.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct StorageConfig {
84    /// Backend kind: `fs`, `s3`, `azblob`, or `gcs`.
85    pub backend: String,
86    /// Root path or bucket name depending on backend.
87    pub root: PathBuf,
88    /// Optional bucket (cloud backends).
89    #[serde(default)]
90    pub bucket: Option<String>,
91    /// Optional region (S3).
92    #[serde(default)]
93    pub region: Option<String>,
94    /// Optional endpoint override (MinIO-compatible, custom S3).
95    #[serde(default)]
96    pub endpoint: Option<String>,
97}
98
99impl Default for StorageConfig {
100    fn default() -> Self {
101        Self {
102            backend: "fs".into(),
103            root: default_data_dir().join("objects"),
104            bucket: None,
105            region: None,
106            endpoint: None,
107        }
108    }
109}
110
111/// Compute/SQL engine settings.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ComputeConfig {
114    /// Directory scanned for Parquet/CSV datasets.
115    pub data_dir: PathBuf,
116    /// Maximum rows returned by a single query (safety limit).
117    pub max_rows: usize,
118}
119
120impl Default for ComputeConfig {
121    fn default() -> Self {
122        Self {
123            data_dir: default_data_dir().join("datasets"),
124            max_rows: 10_000,
125        }
126    }
127}
128
129/// Top-level Tecton configuration.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TectonConfig {
132    pub mode: RuntimeMode,
133    pub server: ServerConfig,
134    pub storage: StorageConfig,
135    pub compute: ComputeConfig,
136    /// Log filter directive (e.g. `info`, `tecton=debug`).
137    pub log_level: String,
138}
139
140impl Default for TectonConfig {
141    fn default() -> Self {
142        Self {
143            mode: RuntimeMode::Full,
144            server: ServerConfig::default(),
145            storage: StorageConfig::default(),
146            compute: ComputeConfig::default(),
147            log_level: "info".into(),
148        }
149    }
150}
151
152impl TectonConfig {
153    /// Load configuration from optional file + environment overrides.
154    ///
155    /// Environment variables use the `TECTON_` prefix (e.g. `TECTON_SERVER__PORT=9000`).
156    pub fn load(config_path: Option<&Path>) -> Result<Self> {
157        // Soft-load .env if present; ignore missing file.
158        let _ = dotenvy::dotenv();
159
160        let mut builder = config::Config::builder()
161            .set_default("mode", "full")?
162            .set_default("server.host", "127.0.0.1")?
163            .set_default("server.port", 9000)?
164            .set_default("storage.backend", "fs")?
165            .set_default(
166                "storage.root",
167                default_data_dir()
168                    .join("objects")
169                    .to_string_lossy()
170                    .to_string(),
171            )?
172            .set_default(
173                "compute.data_dir",
174                default_data_dir()
175                    .join("datasets")
176                    .to_string_lossy()
177                    .to_string(),
178            )?
179            .set_default("compute.max_rows", 10_000)?
180            .set_default("log_level", "info")?;
181
182        if let Some(path) = config_path {
183            if path.exists() {
184                builder = builder.add_source(config::File::from(path.to_path_buf()));
185            } else {
186                return Err(TectonError::config(format!(
187                    "config file not found: {}",
188                    path.display()
189                )));
190            }
191        } else {
192            // Try conventional locations without failing if absent.
193            for candidate in default_config_candidates() {
194                if candidate.exists() {
195                    builder = builder.add_source(config::File::from(candidate));
196                    break;
197                }
198            }
199        }
200
201        builder = builder.add_source(
202            config::Environment::with_prefix("TECTON")
203                .separator("__")
204                .try_parsing(true),
205        );
206
207        let cfg: TectonConfig = builder
208            .build()
209            .map_err(|e| TectonError::config(e.to_string()))?
210            .try_deserialize()
211            .map_err(|e| TectonError::config(e.to_string()))?;
212
213        cfg.validate()?;
214        Ok(cfg)
215    }
216
217    /// Validate paths, ports, and backend names.
218    pub fn validate(&self) -> Result<()> {
219        if self.server.port == 0 {
220            return Err(TectonError::invalid_input("server port must be non-zero"));
221        }
222
223        let backend = self.storage.backend.to_ascii_lowercase();
224        if !matches!(backend.as_str(), "fs" | "s3" | "azblob" | "gcs") {
225            return Err(TectonError::invalid_input(format!(
226                "unsupported storage backend '{}'; expected fs|s3|azblob|gcs",
227                self.storage.backend
228            )));
229        }
230
231        if self.compute.max_rows == 0 {
232            return Err(TectonError::invalid_input(
233                "compute.max_rows must be greater than zero",
234            ));
235        }
236
237        // Ensure local directories exist when using the filesystem backend.
238        if backend == "fs" {
239            std::fs::create_dir_all(&self.storage.root)?;
240        }
241        std::fs::create_dir_all(&self.compute.data_dir)?;
242
243        Ok(())
244    }
245}
246
247/// Platform-appropriate default data directory (`~/.local/share/tecton` etc.).
248pub fn default_data_dir() -> PathBuf {
249    ProjectDirs::from("io", "tecton", "tecton")
250        .map(|d| d.data_dir().to_path_buf())
251        .unwrap_or_else(|| PathBuf::from(".data"))
252}
253
254fn default_config_candidates() -> Vec<PathBuf> {
255    let mut paths = vec![PathBuf::from("tecton.toml"), PathBuf::from("tecton.yaml")];
256    if let Some(dirs) = ProjectDirs::from("io", "tecton", "tecton") {
257        paths.push(dirs.config_dir().join("tecton.toml"));
258    }
259    paths
260}
261
262// Allow `config` crate conversion errors into our domain error.
263impl From<config::ConfigError> for TectonError {
264    fn from(value: config::ConfigError) -> Self {
265        TectonError::config(value.to_string())
266    }
267}