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
266 struct EnvGuard {
268 key: String,
269 prev: Option<String>,
270 }
271
272 impl EnvGuard {
273 fn new(key: &str, value: &str) -> Self {
274 let prev = std::env::var(key).ok();
275 unsafe { std::env::set_var(key, value) };
277 Self {
278 key: key.to_string(),
279 prev,
280 }
281 }
282
283 fn remove(key: &str) -> Self {
284 let prev = std::env::var(key).ok();
285 unsafe { std::env::remove_var(key) };
287 Self {
288 key: key.to_string(),
289 prev,
290 }
291 }
292 }
293
294 impl Drop for EnvGuard {
295 fn drop(&mut self) {
296 if let Some(val) = &self.prev {
298 unsafe { std::env::set_var(&self.key, val) };
299 } else {
300 unsafe { std::env::remove_var(&self.key) };
301 }
302 }
303 }
304
305 #[test]
310 fn test_fabryk_config_default() {
311 let config = FabrykConfig::default();
312 assert_eq!(config.project_name, "fabryk");
313 assert!(config.base_path.is_none());
314 assert!(config.content.path.is_none());
315 assert!(config.graph.output_path.is_none());
316 assert_eq!(config.server.port, 3000);
317 assert_eq!(config.server.host, "127.0.0.1");
318 }
319
320 #[test]
325 fn test_fabryk_config_from_toml() {
326 let toml_str = r#"
327 project_name = "my-app"
328 base_path = "/data"
329
330 [content]
331 path = "/data/content"
332
333 [graph]
334 output_path = "/data/graphs"
335
336 [server]
337 port = 8080
338 host = "0.0.0.0"
339 "#;
340
341 let config: FabrykConfig = toml::from_str(toml_str).unwrap();
342 assert_eq!(config.project_name, "my-app");
343 assert_eq!(config.base_path.as_deref(), Some("/data"));
344 assert_eq!(config.content.path.as_deref(), Some("/data/content"));
345 assert_eq!(config.graph.output_path.as_deref(), Some("/data/graphs"));
346 assert_eq!(config.server.port, 8080);
347 assert_eq!(config.server.host, "0.0.0.0");
348 }
349
350 #[test]
351 fn test_fabryk_config_to_toml() {
352 let config = FabrykConfig::default();
353 let toml_str = config.to_toml_string().unwrap();
354 assert!(toml_str.contains("project_name = \"fabryk\""));
355 assert!(toml_str.contains("[server]"));
356 assert!(toml_str.contains("port = 3000"));
357
358 let parsed: FabrykConfig = toml::from_str(&toml_str).unwrap();
360 assert_eq!(parsed.project_name, config.project_name);
361 assert_eq!(parsed.server.port, config.server.port);
362 }
363
364 #[test]
369 fn test_fabryk_config_load_from_file() {
370 let dir = tempfile::TempDir::new().unwrap();
371 let path = dir.path().join("config.toml");
372 std::fs::write(
373 &path,
374 r#"
375 project_name = "loaded-app"
376 [server]
377 port = 9090
378 "#,
379 )
380 .unwrap();
381
382 let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
383 assert_eq!(config.project_name, "loaded-app");
384 assert_eq!(config.server.port, 9090);
385 }
386
387 #[test]
388 fn test_fabryk_config_load_defaults() {
389 let config = FabrykConfig::load(Some("/nonexistent/config.toml")).unwrap();
391 assert_eq!(config.project_name, "fabryk");
392 assert_eq!(config.server.port, 3000);
393 }
394
395 #[test]
396 fn test_fabryk_config_load_env_overlay() {
397 let dir = tempfile::TempDir::new().unwrap();
398 let path = dir.path().join("config.toml");
399 std::fs::write(
400 &path,
401 r#"
402 project_name = "file-app"
403 [server]
404 host = "127.0.0.1"
405 "#,
406 )
407 .unwrap();
408
409 let _guard = EnvGuard::new("FABRYK_SERVER_HOST", "0.0.0.0");
412 let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
413 assert_eq!(config.server.host, "0.0.0.0");
414 }
415
416 #[test]
421 fn test_fabryk_config_resolve_config_path_explicit() {
422 let path = FabrykConfig::resolve_config_path(Some("/explicit/config.toml"));
423 assert_eq!(path, Some(PathBuf::from("/explicit/config.toml")));
424 }
425
426 #[test]
427 fn test_fabryk_config_resolve_config_path_env() {
428 let _guard = EnvGuard::new("FABRYK_CONFIG", "/env/config.toml");
429 let path = FabrykConfig::resolve_config_path(None);
430 assert_eq!(path, Some(PathBuf::from("/env/config.toml")));
431 }
432
433 #[test]
434 fn test_fabryk_config_resolve_config_path_default() {
435 let _guard = EnvGuard::remove("FABRYK_CONFIG");
436 let path = FabrykConfig::resolve_config_path(None);
437 assert!(path.is_some());
438 let p = path.unwrap();
439 assert!(p.to_str().unwrap().contains("fabryk"));
440 assert!(p.to_str().unwrap().ends_with("config.toml"));
441 }
442
443 #[test]
448 fn test_fabryk_config_provider_project_name() {
449 let config = FabrykConfig {
450 project_name: "test-project".into(),
451 ..Default::default()
452 };
453 assert_eq!(config.project_name(), "test-project");
454 }
455
456 #[test]
457 fn test_fabryk_config_provider_base_path() {
458 let config = FabrykConfig {
459 base_path: Some("/my/data".into()),
460 ..Default::default()
461 };
462 assert_eq!(config.base_path().unwrap(), PathBuf::from("/my/data"));
463 }
464
465 #[test]
466 fn test_fabryk_config_provider_base_path_default() {
467 let config = FabrykConfig::default();
468 let base = config.base_path().unwrap();
469 assert_eq!(base, std::env::current_dir().unwrap());
471 }
472
473 #[test]
474 fn test_fabryk_config_provider_content_path() {
475 let config = FabrykConfig {
476 base_path: Some("/project".into()),
477 ..Default::default()
478 };
479 let path = config.content_path("concepts").unwrap();
480 assert_eq!(path, PathBuf::from("/project/concepts"));
481 }
482
483 #[test]
484 fn test_fabryk_config_provider_content_path_explicit() {
485 let config = FabrykConfig {
486 content: ContentConfig {
487 path: Some("/custom/content".into()),
488 },
489 ..Default::default()
490 };
491 let path = config.content_path("anything").unwrap();
492 assert_eq!(path, PathBuf::from("/custom/content"));
493 }
494
495 #[test]
500 fn test_fabryk_config_to_env_vars() {
501 let config = FabrykConfig::default();
502 let vars = config.to_env_vars().unwrap();
503 let map: HashMap<_, _> = vars.into_iter().collect();
504 assert_eq!(map.get("FABRYK_PROJECT_NAME").unwrap(), "fabryk");
505 assert_eq!(map.get("FABRYK_SERVER_PORT").unwrap(), "3000");
506 assert_eq!(map.get("FABRYK_SERVER_HOST").unwrap(), "127.0.0.1");
507 }
508
509 #[test]
514 fn test_fabryk_config_is_clone() {
515 let config = FabrykConfig::default();
516 let cloned = config.clone();
517 assert_eq!(config.project_name, cloned.project_name);
518 }
519
520 #[test]
521 fn test_fabryk_config_send_sync() {
522 fn assert_send_sync<T: Send + Sync>() {}
523 assert_send_sync::<FabrykConfig>();
524 }
525}