1use chrono::Utc;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FileType {
15 Pdf,
17 Csv,
19 Json,
21 Epcis,
23}
24
25impl FileType {
26 pub fn extension(&self) -> &'static str {
28 match self {
29 FileType::Pdf => "pdf",
30 FileType::Csv => "csv",
31 FileType::Json => "json",
32 FileType::Epcis => "xml",
33 }
34 }
35
36 pub fn mime_type(&self) -> &'static str {
38 match self {
39 FileType::Pdf => "application/pdf",
40 FileType::Csv => "text/csv",
41 FileType::Json => "application/json",
42 FileType::Epcis => "application/xml",
43 }
44 }
45
46 pub fn from_str(s: &str) -> Option<Self> {
48 match s.to_lowercase().as_str() {
49 "pdf" => Some(FileType::Pdf),
50 "csv" => Some(FileType::Csv),
51 "json" => Some(FileType::Json),
52 "epcis" | "xml" => Some(FileType::Epcis),
53 _ => None,
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct GenerationContext {
61 pub route_id: String,
63 pub file_type: FileType,
65 pub request_id: Option<String>,
67 pub metadata: serde_json::Value,
69}
70
71#[derive(Debug, Clone)]
73pub struct FileGenerator {
74 base_dir: PathBuf,
76 stats: Arc<RwLock<GenerationStats>>,
78}
79
80#[derive(Debug, Default)]
82struct GenerationStats {
83 files_generated: u64,
84 total_bytes: u64,
85}
86
87impl FileGenerator {
88 pub fn new(base_dir: impl AsRef<Path>) -> Self {
90 let base_dir = base_dir.as_ref().to_path_buf();
91 Self {
92 base_dir,
93 stats: Arc::new(RwLock::new(GenerationStats::default())),
94 }
95 }
96
97 pub async fn generate_file(&self, context: GenerationContext) -> anyhow::Result<PathBuf> {
99 let route_dir = self.base_dir.join(&context.route_id);
101 tokio::fs::create_dir_all(&route_dir).await?;
102
103 let filename = format!(
105 "{}_{}.{}",
106 context.request_id.as_ref().unwrap_or(&Uuid::new_v4().to_string()),
107 Utc::now().timestamp(),
108 context.file_type.extension()
109 );
110 let file_path = route_dir.join(&filename);
111
112 let content = match context.file_type {
114 FileType::Pdf => self.generate_pdf(&context)?,
115 FileType::Csv => self.generate_csv(&context)?,
116 FileType::Json => self.generate_json(&context)?,
117 FileType::Epcis => self.generate_epcis(&context)?,
118 };
119
120 tokio::fs::write(&file_path, content).await?;
122
123 {
125 let mut stats = self.stats.write().await;
126 stats.files_generated += 1;
127 stats.total_bytes += file_path.metadata()?.len();
128 }
129
130 tracing::debug!("Generated file: {:?}", file_path);
131 Ok(file_path)
132 }
133
134 fn generate_pdf(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
136 let title = format!("Mock {} Document", context.route_id);
139 let content = format!(
140 "MockForge Generated Document\n\
141 Route: {}\n\
142 Generated: {}\n\
143 Request ID: {}\n\
144 \n\
145 This is a mock document generated by MockForge.\n\
146 In a real implementation, this would contain actual data.\n",
147 context.route_id,
148 Utc::now().to_rfc3339(),
149 context.request_id.as_deref().unwrap_or("N/A")
150 );
151
152 let pdf_content = format!(
155 "%PDF-1.4\n\
156 1 0 obj\n\
157 << /Type /Catalog /Pages 2 0 R >>\n\
158 endobj\n\
159 2 0 obj\n\
160 << /Type /Pages /Kids [3 0 R] /Count 1 >>\n\
161 endobj\n\
162 3 0 obj\n\
163 << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>\n\
164 endobj\n\
165 4 0 obj\n\
166 << /Length {} >>\n\
167 stream\n\
168 BT\n\
169 /F1 12 Tf\n\
170 100 700 Td\n\
171 ({}) Tj\n\
172 ET\n\
173 endstream\n\
174 endobj\n\
175 xref\n\
176 0 5\n\
177 0000000000 65535 f\n\
178 0000000009 00000 n\n\
179 0000000058 00000 n\n\
180 0000000115 00000 n\n\
181 0000000215 00000 n\n\
182 trailer\n\
183 << /Size 5 /Root 1 0 R >>\n\
184 startxref\n\
185 {}\n\
186 %%EOF",
187 content.len(),
188 content.replace("(", "\\(").replace(")", "\\)"),
189 0 );
191
192 Ok(pdf_content.into_bytes())
193 }
194
195 fn generate_csv(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
197 let mut csv = String::new();
198
199 csv.push_str("ID,Route,Generated At,Request ID\n");
201
202 csv.push_str(&format!(
204 "{},{},{},{}\n",
205 Uuid::new_v4(),
206 context.route_id,
207 Utc::now().to_rfc3339(),
208 context.request_id.as_deref().unwrap_or("N/A")
209 ));
210
211 if let Some(metadata_array) = context.metadata.as_array() {
213 for item in metadata_array {
214 if let Some(obj) = item.as_object() {
215 let row: Vec<String> =
216 obj.values().map(|v| v.to_string().trim_matches('"').to_string()).collect();
217 csv.push_str(&row.join(","));
218 csv.push('\n');
219 }
220 }
221 }
222
223 Ok(csv.into_bytes())
224 }
225
226 fn generate_json(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
228 let json = serde_json::json!({
229 "route_id": context.route_id,
230 "generated_at": Utc::now().to_rfc3339(),
231 "request_id": context.request_id,
232 "metadata": context.metadata,
233 "mockforge_version": env!("CARGO_PKG_VERSION"),
234 });
235
236 Ok(serde_json::to_vec_pretty(&json)?)
237 }
238
239 fn generate_epcis(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
241 let xml = format!(
242 r#"<?xml version="1.0" encoding="UTF-8"?>
243<epcis:EPCISDocument xmlns:epcis="urn:epcglobal:epcis:xsd:1" xmlns:cbv="urn:epcglobal:cbv:mda" xmlns:gdst="https://ref.gs1.org/cbv/">
244 <EPCISHeader>
245 <epcis:version>2.0</epcis:version>
246 </EPCISHeader>
247 <EPCISBody>
248 <EventList>
249 <ObjectEvent>
250 <eventTime>{}</eventTime>
251 <eventTimeZoneOffset>+00:00</eventTimeZoneOffset>
252 <epcList>
253 <epc>{}</epc>
254 </epcList>
255 <action>OBSERVE</action>
256 <bizStep>urn:epcglobal:cbv:bizstep:receiving</bizStep>
257 <disposition>urn:epcglobal:cbv:disp:in_transit</disposition>
258 </ObjectEvent>
259 </EventList>
260 </EPCISBody>
261</epcis:EPCISDocument>"#,
262 Utc::now().to_rfc3339(),
263 context.request_id.as_deref().unwrap_or(&Uuid::new_v4().to_string())
264 );
265
266 Ok(xml.into_bytes())
267 }
268
269 pub fn get_file_path(&self, route_id: &str, filename: &str) -> PathBuf {
271 self.base_dir.join(route_id).join(filename)
272 }
273
274 pub async fn get_stats(&self) -> (u64, u64) {
276 let stats = self.stats.read().await;
277 (stats.files_generated, stats.total_bytes)
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use tempfile::TempDir;
285
286 #[tokio::test]
287 async fn test_generate_pdf() {
288 let temp_dir = TempDir::new().unwrap();
289 let generator = FileGenerator::new(temp_dir.path());
290
291 let context = GenerationContext {
292 route_id: "labels".to_string(),
293 file_type: FileType::Pdf,
294 request_id: Some("test-123".to_string()),
295 metadata: serde_json::json!({}),
296 };
297
298 let path = generator.generate_file(context).await.unwrap();
299 assert!(path.exists());
300 assert!(path.extension().unwrap() == "pdf");
301 }
302
303 #[tokio::test]
304 async fn test_generate_csv() {
305 let temp_dir = TempDir::new().unwrap();
306 let generator = FileGenerator::new(temp_dir.path());
307
308 let context = GenerationContext {
309 route_id: "invoices".to_string(),
310 file_type: FileType::Csv,
311 request_id: Some("invoice-456".to_string()),
312 metadata: serde_json::json!([]),
313 };
314
315 let path = generator.generate_file(context).await.unwrap();
316 assert!(path.exists());
317 assert!(path.extension().unwrap() == "csv");
318 }
319
320 #[tokio::test]
321 async fn test_generate_json() {
322 let temp_dir = TempDir::new().unwrap();
323 let generator = FileGenerator::new(temp_dir.path());
324
325 let context = GenerationContext {
326 route_id: "provenance".to_string(),
327 file_type: FileType::Json,
328 request_id: Some("provenance-789".to_string()),
329 metadata: serde_json::json!({"test": "data"}),
330 };
331
332 let path = generator.generate_file(context).await.unwrap();
333 assert!(path.exists());
334 assert!(path.extension().unwrap() == "json");
335
336 let content = tokio::fs::read_to_string(&path).await.unwrap();
337 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
338 assert!(json.get("route_id").is_some());
339 }
340}