1use confyg::{Confygery, env};
14use fabryk_core::traits::ConfigProvider;
15use fabryk_core::{Error, Result};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct FabrykConfig {
27 pub project_name: String,
29
30 pub base_path: Option<String>,
32
33 pub content: ContentConfig,
35
36 pub graph: GraphConfig,
38
39 pub server: ServerConfig,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45#[serde(default)]
46pub struct ContentConfig {
47 pub path: Option<String>,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(default)]
54pub struct GraphConfig {
55 pub output_path: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct ServerConfig {
63 pub port: u16,
65
66 pub host: String,
68}
69
70impl Default for FabrykConfig {
75 fn default() -> Self {
76 Self {
77 project_name: "fabryk".to_string(),
78 base_path: None,
79 content: ContentConfig::default(),
80 graph: GraphConfig::default(),
81 server: ServerConfig::default(),
82 }
83 }
84}
85
86impl Default for ServerConfig {
87 fn default() -> Self {
88 Self {
89 port: 3000,
90 host: "127.0.0.1".to_string(),
91 }
92 }
93}
94
95impl FabrykConfig {
100 pub fn load(config_path: Option<&str>) -> Result<Self> {
108 let mut builder =
109 Confygery::new().map_err(|e| Error::config(format!("config init: {e}")))?;
110
111 if let Some(path) = Self::resolve_config_path(config_path) {
112 if path.exists() {
113 builder
114 .add_file(&path.to_string_lossy())
115 .map_err(|e| Error::config(format!("config file: {e}")))?;
116 }
117 }
118
119 let mut env_opts = env::Options::with_top_level("FABRYK");
120 env_opts.add_section("content");
121 env_opts.add_section("graph");
122 env_opts.add_section("server");
123 builder
124 .add_env(env_opts)
125 .map_err(|e| Error::config(format!("config env: {e}")))?;
126
127 let config: Self = builder
128 .build()
129 .map_err(|e| Error::config(format!("config build: {e}")))?;
130
131 Ok(config)
132 }
133
134 pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
136 if let Some(path) = explicit {
138 return Some(PathBuf::from(path));
139 }
140
141 if let Ok(path) = std::env::var("FABRYK_CONFIG") {
143 return Some(PathBuf::from(path));
144 }
145
146 Self::default_config_path()
148 }
149
150 pub fn default_config_path() -> Option<PathBuf> {
152 dirs::config_dir().map(|d| d.join("fabryk").join("config.toml"))
153 }
154
155 pub fn to_toml_string(&self) -> Result<String> {
157 toml::to_string_pretty(self).map_err(|e| Error::config(e.to_string()))
158 }
159
160 pub fn to_env_vars(&self) -> Result<Vec<(String, String)>> {
162 let value: toml::Value =
163 toml::Value::try_from(self).map_err(|e| Error::config(e.to_string()))?;
164 let mut vars = Vec::new();
165 flatten_toml_value(&value, "FABRYK", &mut vars);
166 Ok(vars)
167 }
168}
169
170impl fabryk_core::ConfigManager for FabrykConfig {
175 fn load(config_path: Option<&str>) -> Result<Self> {
176 FabrykConfig::load(config_path)
177 }
178
179 fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
180 FabrykConfig::resolve_config_path(explicit)
181 }
182
183 fn default_config_path() -> Option<PathBuf> {
184 FabrykConfig::default_config_path()
185 }
186
187 fn project_name() -> &'static str {
188 "fabryk"
189 }
190
191 fn to_toml_string(&self) -> Result<String> {
192 FabrykConfig::to_toml_string(self)
193 }
194
195 fn to_env_vars(&self) -> Result<Vec<(String, String)>> {
196 FabrykConfig::to_env_vars(self)
197 }
198}
199
200impl ConfigProvider for FabrykConfig {
201 fn project_name(&self) -> &str {
202 &self.project_name
203 }
204
205 fn base_path(&self) -> Result<PathBuf> {
206 match &self.base_path {
207 Some(p) => Ok(PathBuf::from(p)),
208 None => std::env::current_dir()
209 .map_err(|e| Error::config(format!("Could not determine base path: {e}"))),
210 }
211 }
212
213 fn content_path(&self, content_type: &str) -> Result<PathBuf> {
214 match &self.content.path {
215 Some(p) => Ok(PathBuf::from(p)),
216 None => Ok(self.base_path()?.join(content_type)),
217 }
218 }
219}
220
221fn flatten_toml_value(value: &toml::Value, prefix: &str, out: &mut Vec<(String, String)>) {
227 match value {
228 toml::Value::Table(table) => {
229 for (key, val) in table {
230 let env_key = format!("{}_{}", prefix, key.to_uppercase());
231 flatten_toml_value(val, &env_key, out);
232 }
233 }
234 toml::Value::Array(arr) => {
235 if let Ok(json) = serde_json::to_string(arr) {
236 out.push((prefix.to_string(), json));
237 }
238 }
239 toml::Value::String(s) => {
240 out.push((prefix.to_string(), s.clone()));
241 }
242 toml::Value::Integer(i) => {
243 out.push((prefix.to_string(), i.to_string()));
244 }
245 toml::Value::Float(f) => {
246 out.push((prefix.to_string(), f.to_string()));
247 }
248 toml::Value::Boolean(b) => {
249 out.push((prefix.to_string(), b.to_string()));
250 }
251 toml::Value::Datetime(dt) => {
252 out.push((prefix.to_string(), dt.to_string()));
253 }
254 }
255}
256
257#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::collections::HashMap;
265 use std::sync::Mutex;
266
267 static ENV_MUTEX: Mutex<()> = Mutex::new(());
269
270 struct EnvGuard {
272 key: String,
273 prev: Option<String>,
274 }
275
276 impl EnvGuard {
277 fn new(key: &str, value: &str) -> Self {
278 let prev = std::env::var(key).ok();
279 unsafe { std::env::set_var(key, value) };
281 Self {
282 key: key.to_string(),
283 prev,
284 }
285 }
286
287 fn remove(key: &str) -> Self {
288 let prev = std::env::var(key).ok();
289 unsafe { std::env::remove_var(key) };
291 Self {
292 key: key.to_string(),
293 prev,
294 }
295 }
296 }
297
298 impl Drop for EnvGuard {
299 fn drop(&mut self) {
300 if let Some(val) = &self.prev {
302 unsafe { std::env::set_var(&self.key, val) };
303 } else {
304 unsafe { std::env::remove_var(&self.key) };
305 }
306 }
307 }
308
309 #[test]
314 fn test_fabryk_config_default() {
315 let config = FabrykConfig::default();
316 assert_eq!(config.project_name, "fabryk");
317 assert!(config.base_path.is_none());
318 assert!(config.content.path.is_none());
319 assert!(config.graph.output_path.is_none());
320 assert_eq!(config.server.port, 3000);
321 assert_eq!(config.server.host, "127.0.0.1");
322 }
323
324 #[test]
329 fn test_fabryk_config_from_toml() {
330 let toml_str = r#"
331 project_name = "my-app"
332 base_path = "/data"
333
334 [content]
335 path = "/data/content"
336
337 [graph]
338 output_path = "/data/graphs"
339
340 [server]
341 port = 8080
342 host = "0.0.0.0"
343 "#;
344
345 let config: FabrykConfig = toml::from_str(toml_str).unwrap();
346 assert_eq!(config.project_name, "my-app");
347 assert_eq!(config.base_path.as_deref(), Some("/data"));
348 assert_eq!(config.content.path.as_deref(), Some("/data/content"));
349 assert_eq!(config.graph.output_path.as_deref(), Some("/data/graphs"));
350 assert_eq!(config.server.port, 8080);
351 assert_eq!(config.server.host, "0.0.0.0");
352 }
353
354 #[test]
355 fn test_fabryk_config_to_toml() {
356 let config = FabrykConfig::default();
357 let toml_str = config.to_toml_string().unwrap();
358 assert!(toml_str.contains("project_name = \"fabryk\""));
359 assert!(toml_str.contains("[server]"));
360 assert!(toml_str.contains("port = 3000"));
361
362 let parsed: FabrykConfig = toml::from_str(&toml_str).unwrap();
364 assert_eq!(parsed.project_name, config.project_name);
365 assert_eq!(parsed.server.port, config.server.port);
366 }
367
368 #[test]
373 fn test_fabryk_config_load_from_file() {
374 let dir = tempfile::TempDir::new().unwrap();
375 let path = dir.path().join("config.toml");
376 std::fs::write(
377 &path,
378 r#"
379 project_name = "loaded-app"
380 [server]
381 port = 9090
382 "#,
383 )
384 .unwrap();
385
386 let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
387 assert_eq!(config.project_name, "loaded-app");
388 assert_eq!(config.server.port, 9090);
389 }
390
391 #[test]
392 fn test_fabryk_config_load_defaults() {
393 let config = FabrykConfig::load(Some("/nonexistent/config.toml")).unwrap();
395 assert_eq!(config.project_name, "fabryk");
396 assert_eq!(config.server.port, 3000);
397 }
398
399 #[test]
400 fn test_fabryk_config_load_env_overlay() {
401 let _lock = ENV_MUTEX.lock().unwrap();
402 let dir = tempfile::TempDir::new().unwrap();
403 let path = dir.path().join("config.toml");
404 std::fs::write(
405 &path,
406 r#"
407 project_name = "file-app"
408 [server]
409 host = "127.0.0.1"
410 "#,
411 )
412 .unwrap();
413
414 let _guard = EnvGuard::new("FABRYK_SERVER_HOST", "0.0.0.0");
417 let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
418 assert_eq!(config.server.host, "0.0.0.0");
419 }
420
421 #[test]
426 fn test_fabryk_config_resolve_config_path_explicit() {
427 let path = FabrykConfig::resolve_config_path(Some("/explicit/config.toml"));
428 assert_eq!(path, Some(PathBuf::from("/explicit/config.toml")));
429 }
430
431 #[test]
432 fn test_fabryk_config_resolve_config_path_env() {
433 let _lock = ENV_MUTEX.lock().unwrap();
434 let _guard = EnvGuard::new("FABRYK_CONFIG", "/env/config.toml");
435 let path = FabrykConfig::resolve_config_path(None);
436 assert_eq!(path, Some(PathBuf::from("/env/config.toml")));
437 }
438
439 #[test]
440 fn test_fabryk_config_resolve_config_path_default() {
441 let _lock = ENV_MUTEX.lock().unwrap();
442 let _guard = EnvGuard::remove("FABRYK_CONFIG");
443 let path = FabrykConfig::resolve_config_path(None);
444 assert!(path.is_some());
445 let p = path.unwrap();
446 assert!(p.to_str().unwrap().contains("fabryk"));
447 assert!(p.to_str().unwrap().ends_with("config.toml"));
448 }
449
450 #[test]
455 fn test_fabryk_config_provider_project_name() {
456 let config = FabrykConfig {
457 project_name: "test-project".into(),
458 ..Default::default()
459 };
460 assert_eq!(config.project_name(), "test-project");
461 }
462
463 #[test]
464 fn test_fabryk_config_provider_base_path() {
465 let config = FabrykConfig {
466 base_path: Some("/my/data".into()),
467 ..Default::default()
468 };
469 assert_eq!(config.base_path().unwrap(), PathBuf::from("/my/data"));
470 }
471
472 #[test]
473 fn test_fabryk_config_provider_base_path_default() {
474 let config = FabrykConfig::default();
475 let base = config.base_path().unwrap();
476 assert_eq!(base, std::env::current_dir().unwrap());
478 }
479
480 #[test]
481 fn test_fabryk_config_provider_content_path() {
482 let config = FabrykConfig {
483 base_path: Some("/project".into()),
484 ..Default::default()
485 };
486 let path = config.content_path("concepts").unwrap();
487 assert_eq!(path, PathBuf::from("/project/concepts"));
488 }
489
490 #[test]
491 fn test_fabryk_config_provider_content_path_explicit() {
492 let config = FabrykConfig {
493 content: ContentConfig {
494 path: Some("/custom/content".into()),
495 },
496 ..Default::default()
497 };
498 let path = config.content_path("anything").unwrap();
499 assert_eq!(path, PathBuf::from("/custom/content"));
500 }
501
502 #[test]
507 fn test_fabryk_config_to_env_vars() {
508 let config = FabrykConfig::default();
509 let vars = config.to_env_vars().unwrap();
510 let map: HashMap<_, _> = vars.into_iter().collect();
511 assert_eq!(map.get("FABRYK_PROJECT_NAME").unwrap(), "fabryk");
512 assert_eq!(map.get("FABRYK_SERVER_PORT").unwrap(), "3000");
513 assert_eq!(map.get("FABRYK_SERVER_HOST").unwrap(), "127.0.0.1");
514 }
515
516 #[test]
521 fn test_fabryk_config_is_clone() {
522 let config = FabrykConfig::default();
523 let cloned = config.clone();
524 assert_eq!(config.project_name, cloned.project_name);
525 }
526
527 #[test]
528 fn test_fabryk_config_send_sync() {
529 fn assert_send_sync<T: Send + Sync>() {}
530 assert_send_sync::<FabrykConfig>();
531 }
532}