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