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 && path.exists()
113 {
114 builder
115 .add_file(&path.to_string_lossy())
116 .map_err(|e| Error::config(format!("config file: {e}")))?;
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 crate::config_utils::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
221#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::collections::HashMap;
229 use std::sync::Mutex;
230
231 static ENV_MUTEX: Mutex<()> = Mutex::new(());
233
234 struct EnvGuard {
236 key: String,
237 prev: Option<String>,
238 }
239
240 impl EnvGuard {
241 fn new(key: &str, value: &str) -> Self {
242 let prev = std::env::var(key).ok();
243 unsafe { std::env::set_var(key, value) };
245 Self {
246 key: key.to_string(),
247 prev,
248 }
249 }
250
251 fn remove(key: &str) -> Self {
252 let prev = std::env::var(key).ok();
253 unsafe { std::env::remove_var(key) };
255 Self {
256 key: key.to_string(),
257 prev,
258 }
259 }
260 }
261
262 impl Drop for EnvGuard {
263 fn drop(&mut self) {
264 if let Some(val) = &self.prev {
266 unsafe { std::env::set_var(&self.key, val) };
267 } else {
268 unsafe { std::env::remove_var(&self.key) };
269 }
270 }
271 }
272
273 #[test]
278 fn test_fabryk_config_default() {
279 let config = FabrykConfig::default();
280 assert_eq!(config.project_name, "fabryk");
281 assert!(config.base_path.is_none());
282 assert!(config.content.path.is_none());
283 assert!(config.graph.output_path.is_none());
284 assert_eq!(config.server.port, 3000);
285 assert_eq!(config.server.host, "127.0.0.1");
286 }
287
288 #[test]
293 fn test_fabryk_config_from_toml() {
294 let toml_str = r#"
295 project_name = "my-app"
296 base_path = "/data"
297
298 [content]
299 path = "/data/content"
300
301 [graph]
302 output_path = "/data/graphs"
303
304 [server]
305 port = 8080
306 host = "0.0.0.0"
307 "#;
308
309 let config: FabrykConfig = toml::from_str(toml_str).unwrap();
310 assert_eq!(config.project_name, "my-app");
311 assert_eq!(config.base_path.as_deref(), Some("/data"));
312 assert_eq!(config.content.path.as_deref(), Some("/data/content"));
313 assert_eq!(config.graph.output_path.as_deref(), Some("/data/graphs"));
314 assert_eq!(config.server.port, 8080);
315 assert_eq!(config.server.host, "0.0.0.0");
316 }
317
318 #[test]
319 fn test_fabryk_config_to_toml() {
320 let config = FabrykConfig::default();
321 let toml_str = config.to_toml_string().unwrap();
322 assert!(toml_str.contains("project_name = \"fabryk\""));
323 assert!(toml_str.contains("[server]"));
324 assert!(toml_str.contains("port = 3000"));
325
326 let parsed: FabrykConfig = toml::from_str(&toml_str).unwrap();
328 assert_eq!(parsed.project_name, config.project_name);
329 assert_eq!(parsed.server.port, config.server.port);
330 }
331
332 #[test]
337 fn test_fabryk_config_load_from_file() {
338 let dir = tempfile::TempDir::new().unwrap();
339 let path = dir.path().join("config.toml");
340 std::fs::write(
341 &path,
342 r#"
343 project_name = "loaded-app"
344 [server]
345 port = 9090
346 "#,
347 )
348 .unwrap();
349
350 let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
351 assert_eq!(config.project_name, "loaded-app");
352 assert_eq!(config.server.port, 9090);
353 }
354
355 #[test]
356 fn test_fabryk_config_load_defaults() {
357 let config = FabrykConfig::load(Some("/nonexistent/config.toml")).unwrap();
359 assert_eq!(config.project_name, "fabryk");
360 assert_eq!(config.server.port, 3000);
361 }
362
363 #[test]
364 fn test_fabryk_config_load_env_overlay() {
365 let _lock = ENV_MUTEX.lock().unwrap();
366 let dir = tempfile::TempDir::new().unwrap();
367 let path = dir.path().join("config.toml");
368 std::fs::write(
369 &path,
370 r#"
371 project_name = "file-app"
372 [server]
373 host = "127.0.0.1"
374 "#,
375 )
376 .unwrap();
377
378 let _guard = EnvGuard::new("FABRYK_SERVER_HOST", "0.0.0.0");
381 let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
382 assert_eq!(config.server.host, "0.0.0.0");
383 }
384
385 #[test]
390 fn test_fabryk_config_resolve_config_path_explicit() {
391 let path = FabrykConfig::resolve_config_path(Some("/explicit/config.toml"));
392 assert_eq!(path, Some(PathBuf::from("/explicit/config.toml")));
393 }
394
395 #[test]
396 fn test_fabryk_config_resolve_config_path_env() {
397 let _lock = ENV_MUTEX.lock().unwrap();
398 let _guard = EnvGuard::new("FABRYK_CONFIG", "/env/config.toml");
399 let path = FabrykConfig::resolve_config_path(None);
400 assert_eq!(path, Some(PathBuf::from("/env/config.toml")));
401 }
402
403 #[test]
404 fn test_fabryk_config_resolve_config_path_default() {
405 let _lock = ENV_MUTEX.lock().unwrap();
406 let _guard = EnvGuard::remove("FABRYK_CONFIG");
407 let path = FabrykConfig::resolve_config_path(None);
408 assert!(path.is_some());
409 let p = path.unwrap();
410 assert!(p.to_str().unwrap().contains("fabryk"));
411 assert!(p.to_str().unwrap().ends_with("config.toml"));
412 }
413
414 #[test]
419 fn test_fabryk_config_provider_project_name() {
420 let config = FabrykConfig {
421 project_name: "test-project".into(),
422 ..Default::default()
423 };
424 assert_eq!(config.project_name(), "test-project");
425 }
426
427 #[test]
428 fn test_fabryk_config_provider_base_path() {
429 let config = FabrykConfig {
430 base_path: Some("/my/data".into()),
431 ..Default::default()
432 };
433 assert_eq!(config.base_path().unwrap(), PathBuf::from("/my/data"));
434 }
435
436 #[test]
437 fn test_fabryk_config_provider_base_path_default() {
438 let config = FabrykConfig::default();
439 let base = config.base_path().unwrap();
440 assert_eq!(base, std::env::current_dir().unwrap());
442 }
443
444 #[test]
445 fn test_fabryk_config_provider_content_path() {
446 let config = FabrykConfig {
447 base_path: Some("/project".into()),
448 ..Default::default()
449 };
450 let path = config.content_path("concepts").unwrap();
451 assert_eq!(path, PathBuf::from("/project/concepts"));
452 }
453
454 #[test]
455 fn test_fabryk_config_provider_content_path_explicit() {
456 let config = FabrykConfig {
457 content: ContentConfig {
458 path: Some("/custom/content".into()),
459 },
460 ..Default::default()
461 };
462 let path = config.content_path("anything").unwrap();
463 assert_eq!(path, PathBuf::from("/custom/content"));
464 }
465
466 #[test]
471 fn test_fabryk_config_to_env_vars() {
472 let config = FabrykConfig::default();
473 let vars = config.to_env_vars().unwrap();
474 let map: HashMap<_, _> = vars.into_iter().collect();
475 assert_eq!(map.get("FABRYK_PROJECT_NAME").unwrap(), "fabryk");
476 assert_eq!(map.get("FABRYK_SERVER_PORT").unwrap(), "3000");
477 assert_eq!(map.get("FABRYK_SERVER_HOST").unwrap(), "127.0.0.1");
478 }
479
480 #[test]
485 fn test_fabryk_config_is_clone() {
486 let config = FabrykConfig::default();
487 let cloned = config.clone();
488 assert_eq!(config.project_name, cloned.project_name);
489 }
490
491 #[test]
492 fn test_fabryk_config_send_sync() {
493 fn assert_send_sync<T: Send + Sync>() {}
494 assert_send_sync::<FabrykConfig>();
495 }
496}