1use crate::error::{Result, TectonError};
4use directories::ProjectDirs;
5use serde::{Deserialize, Serialize};
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum RuntimeMode {
13 Storage,
15 Compute,
17 #[default]
19 Full,
20}
21
22impl RuntimeMode {
23 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#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ServerConfig {
57 pub host: String,
59 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct StorageConfig {
84 pub backend: String,
86 pub root: PathBuf,
88 #[serde(default)]
90 pub bucket: Option<String>,
91 #[serde(default)]
93 pub region: Option<String>,
94 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ComputeConfig {
114 pub data_dir: PathBuf,
116 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#[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 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 pub fn load(config_path: Option<&Path>) -> Result<Self> {
157 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 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 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 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
247pub 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
262impl From<config::ConfigError> for TectonError {
264 fn from(value: config::ConfigError) -> Self {
265 TectonError::config(value.to_string())
266 }
267}