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}