Skip to main content

datasynth_core/traits/
plugin.rs

1//! Plugin trait definitions for extensible generation and output.
2//!
3//! Provides stable trait interfaces for custom generators, output sinks,
4//! and transform plugins. Plugins are in-process Rust trait objects.
5
6use crate::error::SynthError;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Context provided to generator plugins during data generation.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GenerationContext {
13    /// RNG seed for reproducibility.
14    pub seed: u64,
15    /// Fiscal year being generated.
16    pub fiscal_year: u32,
17    /// Company code being generated for.
18    pub company_code: String,
19    /// Industry sector.
20    pub industry: String,
21    /// Additional context key-value pairs.
22    #[serde(default)]
23    pub extra: HashMap<String, String>,
24}
25
26impl GenerationContext {
27    /// Create a new generation context.
28    pub fn new(seed: u64, fiscal_year: u32, company_code: impl Into<String>) -> Self {
29        Self {
30            seed,
31            fiscal_year,
32            company_code: company_code.into(),
33            industry: String::new(),
34            extra: HashMap::new(),
35        }
36    }
37
38    /// Set the industry.
39    pub fn with_industry(mut self, industry: impl Into<String>) -> Self {
40        self.industry = industry.into();
41        self
42    }
43
44    /// Add an extra context value.
45    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
46        self.extra.insert(key.into(), value.into());
47        self
48    }
49}
50
51/// A single generated record from a plugin.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct GeneratedRecord {
54    /// Record type identifier (e.g., "journal_entry", "vendor", "custom_report").
55    pub record_type: String,
56    /// Record fields as key-value pairs.
57    pub fields: HashMap<String, serde_json::Value>,
58}
59
60impl GeneratedRecord {
61    /// Create a new generated record.
62    pub fn new(record_type: impl Into<String>) -> Self {
63        Self {
64            record_type: record_type.into(),
65            fields: HashMap::new(),
66        }
67    }
68
69    /// Add a field to the record.
70    pub fn with_field(
71        mut self,
72        key: impl Into<String>,
73        value: impl Into<serde_json::Value>,
74    ) -> Self {
75        self.fields.insert(key.into(), value.into());
76        self
77    }
78
79    /// Get a field value.
80    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
81        self.fields.get(key)
82    }
83
84    /// Get a field as a string.
85    pub fn get_str(&self, key: &str) -> Option<&str> {
86        self.fields.get(key).and_then(|v| v.as_str())
87    }
88}
89
90/// Summary returned by a sink plugin after finalization.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SinkSummary {
93    /// Total records written.
94    pub records_written: usize,
95    /// Total bytes written (if tracked).
96    pub bytes_written: Option<u64>,
97    /// Paths of files written (if applicable).
98    pub file_paths: Vec<String>,
99    /// Additional summary metadata.
100    #[serde(default)]
101    pub metadata: HashMap<String, String>,
102}
103
104impl SinkSummary {
105    /// Create a new sink summary.
106    pub fn new(records_written: usize) -> Self {
107        Self {
108            records_written,
109            bytes_written: None,
110            file_paths: Vec::new(),
111            metadata: HashMap::new(),
112        }
113    }
114}
115
116/// Information about a registered plugin.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct PluginInfo {
119    /// Plugin name.
120    pub name: String,
121    /// Plugin version.
122    pub version: String,
123    /// Plugin description.
124    pub description: String,
125    /// Plugin type (generator, sink, transform).
126    pub plugin_type: PluginType,
127}
128
129/// Type of plugin.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "snake_case")]
132pub enum PluginType {
133    /// Data generator plugin.
134    Generator,
135    /// Output sink plugin.
136    Sink,
137    /// Data transform plugin.
138    Transform,
139}
140
141/// Trait for custom data generator plugins.
142///
143/// Generator plugins produce records based on configuration and context.
144///
145/// # Example
146///
147/// ```rust
148/// use datasynth_core::traits::plugin::*;
149/// use datasynth_core::error::SynthError;
150///
151/// struct MyGenerator;
152///
153/// impl GeneratorPlugin for MyGenerator {
154///     fn name(&self) -> &str { "my_generator" }
155///     fn version(&self) -> &str { "1.0.0" }
156///     fn description(&self) -> &str { "Generates custom records" }
157///     fn config_schema(&self) -> Option<serde_json::Value> { None }
158///     fn generate(
159///         &self,
160///         _config: &serde_json::Value,
161///         _context: &GenerationContext,
162///     ) -> Result<Vec<GeneratedRecord>, SynthError> {
163///         Ok(vec![GeneratedRecord::new("custom").with_field("key", "value")])
164///     }
165/// }
166/// ```
167pub trait GeneratorPlugin: Send + Sync {
168    /// Unique name identifying this generator.
169    fn name(&self) -> &str;
170    /// Semantic version of this plugin.
171    fn version(&self) -> &str;
172    /// Human-readable description.
173    fn description(&self) -> &str;
174    /// Optional JSON Schema for plugin configuration.
175    fn config_schema(&self) -> Option<serde_json::Value>;
176    /// Generate records given configuration and context.
177    fn generate(
178        &self,
179        config: &serde_json::Value,
180        context: &GenerationContext,
181    ) -> Result<Vec<GeneratedRecord>, SynthError>;
182}
183
184/// Trait for custom output sink plugins.
185///
186/// Sink plugins write generated records to external destinations.
187///
188/// # Lifecycle
189///
190/// 1. `initialize()` — set up the sink (open files, connections)
191/// 2. `write_records()` — write batches of records (called multiple times)
192/// 3. `finalize()` — flush and close the sink, return summary
193pub trait SinkPlugin: Send + Sync {
194    /// Unique name identifying this sink.
195    fn name(&self) -> &str;
196    /// Initialize the sink with configuration.
197    fn initialize(&mut self, config: &serde_json::Value) -> Result<(), SynthError>;
198    /// Write a batch of records. Returns number of records written.
199    fn write_records(&mut self, records: &[GeneratedRecord]) -> Result<usize, SynthError>;
200    /// Finalize the sink and return a summary.
201    fn finalize(&mut self) -> Result<SinkSummary, SynthError>;
202}
203
204/// Trait for data transform plugins.
205///
206/// Transform plugins modify or enrich records in-place.
207pub trait TransformPlugin: Send + Sync {
208    /// Unique name identifying this transform.
209    fn name(&self) -> &str;
210    /// Transform a batch of records.
211    fn transform(&self, records: Vec<GeneratedRecord>) -> Result<Vec<GeneratedRecord>, SynthError>;
212}
213
214#[cfg(test)]
215#[allow(clippy::unwrap_used)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_generation_context_creation() {
221        let ctx = GenerationContext::new(42, 2024, "C001")
222            .with_industry("manufacturing")
223            .with_extra("region", "EU");
224        assert_eq!(ctx.seed, 42);
225        assert_eq!(ctx.fiscal_year, 2024);
226        assert_eq!(ctx.company_code, "C001");
227        assert_eq!(ctx.industry, "manufacturing");
228        assert_eq!(ctx.extra.get("region").map(|s| s.as_str()), Some("EU"));
229    }
230
231    #[test]
232    fn test_generated_record_creation() {
233        let record = GeneratedRecord::new("test_record")
234            .with_field("name", serde_json::Value::String("Test".to_string()))
235            .with_field("amount", serde_json::json!(100.0));
236        assert_eq!(record.record_type, "test_record");
237        assert_eq!(record.get_str("name"), Some("Test"));
238        assert!(record.get("amount").is_some());
239    }
240
241    #[test]
242    fn test_generated_record_serialization() {
243        let record = GeneratedRecord::new("vendor")
244            .with_field("id", serde_json::json!("V001"))
245            .with_field("name", serde_json::json!("Acme Corp"));
246        let json = serde_json::to_string(&record).expect("should serialize");
247        let deser: GeneratedRecord = serde_json::from_str(&json).expect("should deserialize");
248        assert_eq!(deser.record_type, "vendor");
249        assert_eq!(deser.get_str("id"), Some("V001"));
250    }
251
252    #[test]
253    fn test_sink_summary_creation() {
254        let summary = SinkSummary::new(100);
255        assert_eq!(summary.records_written, 100);
256        assert!(summary.bytes_written.is_none());
257        assert!(summary.file_paths.is_empty());
258    }
259
260    #[test]
261    fn test_plugin_info_serialization() {
262        let info = PluginInfo {
263            name: "test_plugin".to_string(),
264            version: "1.0.0".to_string(),
265            description: "A test plugin".to_string(),
266            plugin_type: PluginType::Generator,
267        };
268        let json = serde_json::to_string(&info).expect("should serialize");
269        assert!(json.contains("test_plugin"));
270        assert!(json.contains("generator"));
271    }
272}