Skip to main content

fabryk_core/
traits.rs

1//! Core traits for Fabryk domain abstraction.
2//!
3//! These traits define the extension points that domain applications implement
4//! to customise Fabryk's behaviour. The primary trait is [`ConfigProvider`],
5//! which abstracts domain-specific configuration.
6
7use std::path::PathBuf;
8
9use crate::Result;
10
11/// Trait for domain-specific configuration.
12///
13/// Every Fabryk-based application implements this trait to provide
14/// the configuration that Fabryk crates need: paths to content,
15/// project identity, and domain-specific settings.
16///
17/// # Bounds
18///
19/// - `Send + Sync`: Configuration must be shareable across threads
20/// - `Clone`: Configuration can be duplicated for passing to subsystems
21/// - `'static`: Configuration lifetime is not borrowed
22///
23/// # Example
24///
25/// ```
26/// use std::path::PathBuf;
27/// use fabryk_core::traits::ConfigProvider;
28/// use fabryk_core::Result;
29///
30/// #[derive(Clone)]
31/// struct MusicTheoryConfig {
32///     data_dir: PathBuf,
33/// }
34///
35/// impl ConfigProvider for MusicTheoryConfig {
36///     fn project_name(&self) -> &str {
37///         "music-theory"
38///     }
39///
40///     fn base_path(&self) -> Result<PathBuf> {
41///         Ok(self.data_dir.clone())
42///     }
43///
44///     fn content_path(&self, content_type: &str) -> Result<PathBuf> {
45///         Ok(self.data_dir.join(content_type))
46///     }
47/// }
48/// ```
49pub trait ConfigProvider: Send + Sync + Clone + 'static {
50    /// The project name, used for env var prefixes and default paths.
51    ///
52    /// This name is used by [`PathResolver`](crate::PathResolver) to generate
53    /// environment variable names. For example, `"music-theory"` produces
54    /// env vars like `MUSIC_THEORY_CONFIG_DIR`.
55    ///
56    /// # Example
57    ///
58    /// ```
59    /// # use fabryk_core::traits::ConfigProvider;
60    /// # #[derive(Clone)]
61    /// # struct Config;
62    /// # impl ConfigProvider for Config {
63    /// fn project_name(&self) -> &str {
64    ///     "music-theory"
65    /// }
66    /// #     fn base_path(&self) -> fabryk_core::Result<std::path::PathBuf> { todo!() }
67    /// #     fn content_path(&self, _: &str) -> fabryk_core::Result<std::path::PathBuf> { todo!() }
68    /// # }
69    /// ```
70    fn project_name(&self) -> &str;
71
72    /// Base path for all project data.
73    ///
74    /// This is the root directory under which all content, caches,
75    /// and generated files are stored.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the path cannot be determined (e.g., missing
80    /// environment variable or invalid configuration).
81    fn base_path(&self) -> Result<PathBuf>;
82
83    /// Path for a specific content type.
84    ///
85    /// `content_type` is a domain-defined key like `"concepts"`,
86    /// `"sources"`, `"guides"`. The implementation decides how to
87    /// map these to actual filesystem paths.
88    ///
89    /// # Arguments
90    ///
91    /// * `content_type` — A domain-specific content category identifier
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the content type is unknown or the path
96    /// cannot be resolved.
97    ///
98    /// # Example
99    ///
100    /// ```
101    /// # use fabryk_core::traits::ConfigProvider;
102    /// # use std::path::PathBuf;
103    /// # #[derive(Clone)]
104    /// # struct Config { base: PathBuf }
105    /// # impl ConfigProvider for Config {
106    /// #     fn project_name(&self) -> &str { "test" }
107    /// #     fn base_path(&self) -> fabryk_core::Result<PathBuf> { Ok(self.base.clone()) }
108    /// fn content_path(&self, content_type: &str) -> fabryk_core::Result<PathBuf> {
109    ///     match content_type {
110    ///         "concepts" => Ok(self.base.join("data/concepts")),
111    ///         "sources" => Ok(self.base.join("data/sources")),
112    ///         "guides" => Ok(self.base.join("guides")),
113    ///         _ => Err(fabryk_core::Error::config(
114    ///             format!("Unknown content type: {}", content_type)
115    ///         )),
116    ///     }
117    /// }
118    /// # }
119    /// ```
120    fn content_path(&self, content_type: &str) -> Result<PathBuf>;
121
122    /// Path for a specific cache type.
123    ///
124    /// `cache_type` is a framework-defined key like `"graph"`, `"fts"`,
125    /// `"vector"`. The default implementation derives cache paths from
126    /// [`base_path()`](Self::base_path) as `{base}/.cache/{cache_type}`.
127    ///
128    /// Products can override this to customise cache locations.
129    ///
130    /// # Standard cache types
131    ///
132    /// - `"graph"` — Knowledge graph cache (rkyv/JSON serialized)
133    /// - `"fts"` — Full-text search index (Tantivy)
134    /// - `"vector"` — Vector embedding index
135    ///
136    /// # Example
137    ///
138    /// ```
139    /// # use fabryk_core::traits::ConfigProvider;
140    /// # use std::path::PathBuf;
141    /// # #[derive(Clone)]
142    /// # struct Config { base: PathBuf }
143    /// # impl ConfigProvider for Config {
144    /// #     fn project_name(&self) -> &str { "test" }
145    /// #     fn base_path(&self) -> fabryk_core::Result<PathBuf> { Ok(self.base.clone()) }
146    /// #     fn content_path(&self, _: &str) -> fabryk_core::Result<PathBuf> { todo!() }
147    /// # }
148    /// let config = Config { base: PathBuf::from("/project") };
149    /// // Default: /project/.cache/graph
150    /// assert_eq!(
151    ///     config.cache_path("graph").unwrap(),
152    ///     PathBuf::from("/project/.cache/graph")
153    /// );
154    /// ```
155    fn cache_path(&self, cache_type: &str) -> Result<PathBuf> {
156        Ok(self.base_path()?.join(".cache").join(cache_type))
157    }
158}
159
160/// Trait for configuration types that support CLI config management.
161///
162/// Provides the operations needed by generic config handlers:
163/// loading, resolving paths, serializing to TOML, and exporting as env vars.
164///
165/// # Bounds
166///
167/// - `Serialize + DeserializeOwned`: For TOML round-tripping
168/// - `Default`: For `config init` default generation
169/// - `Send + Sync + 'static`: For thread-safe sharing
170pub trait ConfigManager:
171    serde::Serialize + serde::de::DeserializeOwned + Default + Send + Sync + 'static
172{
173    /// Load configuration from file/env, with an optional explicit file path.
174    fn load(config_path: Option<&str>) -> Result<Self>;
175
176    /// Resolve which config file path to use.
177    ///
178    /// Precedence: explicit path > env var > XDG default.
179    fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf>;
180
181    /// The default config file path (XDG config dir / project / config.toml).
182    fn default_config_path() -> Option<PathBuf>;
183
184    /// The project name, used in CLI output messages.
185    fn project_name() -> &'static str;
186
187    /// Serialize this config to a TOML string.
188    fn to_toml_string(&self) -> Result<String>;
189
190    /// Export configuration as `(KEY, VALUE)` environment variable pairs.
191    fn to_env_vars(&self) -> Result<Vec<(String, String)>>;
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[derive(Clone)]
199    struct TestConfig {
200        name: String,
201        base: PathBuf,
202    }
203
204    impl ConfigProvider for TestConfig {
205        fn project_name(&self) -> &str {
206            &self.name
207        }
208
209        fn base_path(&self) -> Result<PathBuf> {
210            Ok(self.base.clone())
211        }
212
213        fn content_path(&self, content_type: &str) -> Result<PathBuf> {
214            Ok(self.base.join(content_type))
215        }
216    }
217
218    #[test]
219    fn test_config_provider_project_name() {
220        let config = TestConfig {
221            name: "test-project".into(),
222            base: PathBuf::from("/tmp/test"),
223        };
224        assert_eq!(config.project_name(), "test-project");
225    }
226
227    #[test]
228    fn test_config_provider_base_path() {
229        let config = TestConfig {
230            name: "test".into(),
231            base: PathBuf::from("/data"),
232        };
233        let path = config.base_path().unwrap();
234        assert_eq!(path, PathBuf::from("/data"));
235    }
236
237    #[test]
238    fn test_config_provider_content_path() {
239        let config = TestConfig {
240            name: "test".into(),
241            base: PathBuf::from("/data"),
242        };
243        let path = config.content_path("concepts").unwrap();
244        assert_eq!(path, PathBuf::from("/data/concepts"));
245    }
246
247    #[test]
248    fn test_config_provider_content_path_multiple() {
249        let config = TestConfig {
250            name: "test".into(),
251            base: PathBuf::from("/project"),
252        };
253
254        assert_eq!(
255            config.content_path("sources").unwrap(),
256            PathBuf::from("/project/sources")
257        );
258        assert_eq!(
259            config.content_path("guides").unwrap(),
260            PathBuf::from("/project/guides")
261        );
262        assert_eq!(
263            config.content_path("graphs").unwrap(),
264            PathBuf::from("/project/graphs")
265        );
266    }
267
268    #[test]
269    fn test_config_provider_cache_path_default() {
270        let config = TestConfig {
271            name: "test".into(),
272            base: PathBuf::from("/project"),
273        };
274        assert_eq!(
275            config.cache_path("graph").unwrap(),
276            PathBuf::from("/project/.cache/graph")
277        );
278        assert_eq!(
279            config.cache_path("fts").unwrap(),
280            PathBuf::from("/project/.cache/fts")
281        );
282        assert_eq!(
283            config.cache_path("vector").unwrap(),
284            PathBuf::from("/project/.cache/vector")
285        );
286    }
287
288    #[test]
289    fn test_config_provider_cache_path_override() {
290        #[derive(Clone)]
291        struct CustomCacheConfig;
292
293        impl ConfigProvider for CustomCacheConfig {
294            fn project_name(&self) -> &str {
295                "custom"
296            }
297            fn base_path(&self) -> Result<PathBuf> {
298                Ok(PathBuf::from("/data"))
299            }
300            fn content_path(&self, _content_type: &str) -> Result<PathBuf> {
301                Ok(PathBuf::from("/data/content"))
302            }
303            fn cache_path(&self, cache_type: &str) -> Result<PathBuf> {
304                Ok(PathBuf::from("/var/cache/custom").join(cache_type))
305            }
306        }
307
308        let config = CustomCacheConfig;
309        assert_eq!(
310            config.cache_path("graph").unwrap(),
311            PathBuf::from("/var/cache/custom/graph")
312        );
313    }
314
315    #[test]
316    fn test_config_provider_is_clone() {
317        let config = TestConfig {
318            name: "test".into(),
319            base: PathBuf::from("/data"),
320        };
321        let cloned = config.clone();
322        assert_eq!(config.project_name(), cloned.project_name());
323    }
324
325    #[test]
326    fn test_config_provider_send_sync() {
327        fn assert_send_sync<T: Send + Sync>() {}
328        assert_send_sync::<TestConfig>();
329    }
330}