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