1use std::sync::Arc;
6
7use serde::Serialize;
8
9#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct NodeInfo {
18 pub id: String,
19 pub node_type: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ProgressOutputFile {
31 pub name: String,
32 #[serde(skip)]
33 pub data: crate::processor::FileData,
34 pub mime_type: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
42#[serde(tag = "type")]
43pub enum PipelineEvent {
44 #[serde(rename_all = "camelCase")]
47 PipelineStarted {
48 total_nodes: usize,
50 total_files: usize,
52 nodes: Vec<NodeInfo>,
55 },
56
57 #[serde(rename_all = "camelCase")]
60 NodeStarted {
61 node_id: String,
63 node_index: usize,
67 total_nodes: usize,
70 node_type: String,
72 parent_node_id: Option<String>,
75 },
76
77 #[serde(rename_all = "camelCase")]
80 IterationStarted {
81 node_id: String,
83 iteration: usize,
85 total_iterations: usize,
87 },
88
89 #[serde(rename_all = "camelCase")]
92 IterationCompleted {
93 node_id: String,
95 iteration: usize,
97 total_iterations: usize,
99 duration_ms: u64,
101 files_produced: usize,
103 #[serde(skip)]
107 output_files: Vec<ProgressOutputFile>,
108 },
109
110 #[serde(rename_all = "camelCase")]
113 IterationFailed {
114 node_id: String,
116 iteration: usize,
118 total_iterations: usize,
120 error: String,
122 },
123
124 #[serde(rename_all = "camelCase")]
127 FileProgress {
128 node_id: String,
130 file_index: usize,
132 total_files: usize,
134 percent: u32,
136 message: String,
138 },
139
140 #[serde(rename_all = "camelCase")]
143 NodeCompleted {
144 node_id: String,
146 duration_ms: u64,
148 files_processed: usize,
150 parent_node_id: Option<String>,
152 },
153
154 #[serde(rename_all = "camelCase")]
157 NodeFailed {
158 node_id: String,
160 error: String,
162 parent_node_id: Option<String>,
164 },
165
166 #[serde(rename_all = "camelCase")]
168 PipelineCompleted {
169 duration_ms: u64,
171 total_files_processed: usize,
173 },
174
175 #[serde(rename_all = "camelCase")]
177 PipelineFailed {
178 node_id: String,
180 error: String,
182 },
183
184 #[serde(rename_all = "camelCase")]
187 CommandOutput {
188 node_id: String,
190 line: String,
192 },
193}
194
195pub struct PipelineReporter {
204 callback: Option<Arc<dyn Fn(PipelineEvent)>>,
206}
207
208impl Clone for PipelineReporter {
209 fn clone(&self) -> Self {
210 Self {
211 callback: self.callback.clone(),
212 }
213 }
214}
215
216impl PipelineReporter {
217 pub fn new(callback: impl Fn(PipelineEvent) + 'static) -> Self {
219 Self {
220 callback: Some(Arc::new(callback)),
221 }
222 }
223
224 pub fn new_noop() -> Self {
227 Self { callback: None }
228 }
229
230 pub fn emit(&self, event: PipelineEvent) {
233 if let Some(cb) = &self.callback {
234 cb(event);
235 }
236 }
237}
238
239#[cfg(test)]
247pub struct RecordingReporter {
248 events: std::sync::Arc<std::sync::Mutex<Vec<PipelineEvent>>>,
250}
251
252#[cfg(test)]
253impl Default for RecordingReporter {
254 fn default() -> Self {
255 Self::new()
256 }
257}
258
259#[cfg(test)]
260impl RecordingReporter {
261 pub fn new() -> Self {
263 Self {
264 events: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
265 }
266 }
267
268 pub fn reporter(&self) -> PipelineReporter {
270 let events = std::sync::Arc::clone(&self.events);
271 PipelineReporter::new(move |event| {
272 events.lock().unwrap().push(event);
273 })
274 }
275
276 pub fn events(&self) -> Vec<PipelineEvent> {
278 self.events.lock().unwrap().clone()
279 }
280}
281
282#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
294 fn test_pipeline_started_serializes_correctly() {
295 let event = PipelineEvent::PipelineStarted {
296 total_nodes: 3,
297 total_files: 10,
298 nodes: vec![
299 NodeInfo {
300 id: "n1".into(),
301 node_type: "image-compress".into(),
302 },
303 NodeInfo {
304 id: "n2".into(),
305 node_type: "file-rename".into(),
306 },
307 NodeInfo {
308 id: "n3".into(),
309 node_type: "image-resize".into(),
310 },
311 ],
312 };
313 let json = serde_json::to_value(&event).unwrap();
314
315 assert_eq!(json["type"], "PipelineStarted");
316 assert_eq!(json["totalNodes"], 3);
317 assert_eq!(json["totalFiles"], 10);
318 let nodes = json["nodes"].as_array().unwrap();
319 assert_eq!(nodes.len(), 3);
320 assert_eq!(nodes[0]["id"], "n1");
321 assert_eq!(nodes[0]["nodeType"], "image-compress");
322 }
323
324 #[test]
325 fn test_node_started_serializes_correctly() {
326 let event = PipelineEvent::NodeStarted {
327 node_id: "node-1".to_string(),
328 node_index: 0,
329 total_nodes: 3,
330 node_type: "image-compress".to_string(),
331 parent_node_id: None,
332 };
333 let json = serde_json::to_value(&event).unwrap();
334
335 assert_eq!(json["type"], "NodeStarted");
336 assert_eq!(json["nodeId"], "node-1");
337 assert_eq!(json["nodeIndex"], 0);
338 assert_eq!(json["totalNodes"], 3);
339 assert_eq!(json["nodeType"], "image-compress");
340 assert_eq!(json["parentNodeId"], serde_json::Value::Null);
341 }
342
343 #[test]
344 fn test_node_started_with_parent_serializes_correctly() {
345 let event = PipelineEvent::NodeStarted {
346 node_id: "child-1".to_string(),
347 node_index: 0,
348 total_nodes: 1,
349 node_type: "shell-command".to_string(),
350 parent_node_id: Some("loop-1".to_string()),
351 };
352 let json = serde_json::to_value(&event).unwrap();
353
354 assert_eq!(json["type"], "NodeStarted");
355 assert_eq!(json["parentNodeId"], "loop-1");
356 }
357
358 #[test]
359 fn test_iteration_started_serializes_correctly() {
360 let event = PipelineEvent::IterationStarted {
361 node_id: "loop-1".to_string(),
362 iteration: 2,
363 total_iterations: 15,
364 };
365 let json = serde_json::to_value(&event).unwrap();
366
367 assert_eq!(json["type"], "IterationStarted");
368 assert_eq!(json["nodeId"], "loop-1");
369 assert_eq!(json["iteration"], 2);
370 assert_eq!(json["totalIterations"], 15);
371 }
372
373 #[test]
374 fn test_iteration_completed_serializes_correctly() {
375 let event = PipelineEvent::IterationCompleted {
376 node_id: "loop-1".to_string(),
377 iteration: 2,
378 total_iterations: 7,
379 duration_ms: 1500,
380 files_produced: 1,
381 output_files: Vec::new(),
382 };
383 let json = serde_json::to_value(&event).unwrap();
384
385 assert_eq!(json["type"], "IterationCompleted");
386 assert_eq!(json["nodeId"], "loop-1");
387 assert_eq!(json["iteration"], 2);
388 assert_eq!(json["totalIterations"], 7);
389 assert_eq!(json["durationMs"], 1500);
390 assert_eq!(json["filesProduced"], 1);
391 }
392
393 #[test]
394 fn test_iteration_completed_skips_output_files_in_json() {
395 let event = PipelineEvent::IterationCompleted {
396 node_id: "loop-1".to_string(),
397 iteration: 0,
398 total_iterations: 1,
399 duration_ms: 100,
400 files_produced: 1,
401 output_files: vec![ProgressOutputFile {
402 name: "video.mp4".to_string(),
403 data: crate::processor::FileData::Bytes(vec![0u8; 100]),
404 mime_type: "video/mp4".to_string(),
405 }],
406 };
407 let json = serde_json::to_value(&event).unwrap();
408
409 assert!(json.get("outputFiles").is_none());
411 assert_eq!(json["filesProduced"], 1);
412 }
413
414 #[test]
415 fn test_progress_output_file_serializes_without_data() {
416 let file = ProgressOutputFile {
417 name: "video.mp4".to_string(),
418 data: crate::processor::FileData::Bytes(vec![0u8; 1024]),
419 mime_type: "video/mp4".to_string(),
420 };
421 let json = serde_json::to_value(&file).unwrap();
422
423 assert_eq!(json["name"], "video.mp4");
424 assert_eq!(json["mimeType"], "video/mp4");
425 assert!(json.get("data").is_none());
427 }
428
429 #[test]
430 fn test_iteration_failed_serializes_correctly() {
431 let event = PipelineEvent::IterationFailed {
432 node_id: "loop-1".to_string(),
433 iteration: 3,
434 total_iterations: 10,
435 error: "Download timed out".to_string(),
436 };
437 let json = serde_json::to_value(&event).unwrap();
438
439 assert_eq!(json["type"], "IterationFailed");
440 assert_eq!(json["nodeId"], "loop-1");
441 assert_eq!(json["iteration"], 3);
442 assert_eq!(json["totalIterations"], 10);
443 assert_eq!(json["error"], "Download timed out");
444 }
445
446 #[test]
447 fn test_file_progress_serializes_correctly() {
448 let event = PipelineEvent::FileProgress {
449 node_id: "node-2".to_string(),
450 file_index: 2,
451 total_files: 5,
452 percent: 75,
453 message: "Compressing photo.jpg...".to_string(),
454 };
455 let json = serde_json::to_value(&event).unwrap();
456
457 assert_eq!(json["type"], "FileProgress");
458 assert_eq!(json["nodeId"], "node-2");
459 assert_eq!(json["fileIndex"], 2);
460 assert_eq!(json["totalFiles"], 5);
461 assert_eq!(json["percent"], 75);
462 assert_eq!(json["message"], "Compressing photo.jpg...");
463 }
464
465 #[test]
466 fn test_node_completed_serializes_correctly() {
467 let event = PipelineEvent::NodeCompleted {
468 node_id: "node-1".to_string(),
469 duration_ms: 1234,
470 files_processed: 5,
471 parent_node_id: None,
472 };
473 let json = serde_json::to_value(&event).unwrap();
474
475 assert_eq!(json["type"], "NodeCompleted");
476 assert_eq!(json["nodeId"], "node-1");
477 assert_eq!(json["durationMs"], 1234);
478 assert_eq!(json["filesProcessed"], 5);
479 }
480
481 #[test]
482 fn test_node_failed_serializes_correctly() {
483 let event = PipelineEvent::NodeFailed {
484 node_id: "node-3".to_string(),
485 error: "Unsupported format: BMP".to_string(),
486 parent_node_id: None,
487 };
488 let json = serde_json::to_value(&event).unwrap();
489
490 assert_eq!(json["type"], "NodeFailed");
491 assert_eq!(json["nodeId"], "node-3");
492 assert_eq!(json["error"], "Unsupported format: BMP");
493 }
494
495 #[test]
496 fn test_pipeline_completed_serializes_correctly() {
497 let event = PipelineEvent::PipelineCompleted {
498 duration_ms: 5678,
499 total_files_processed: 10,
500 };
501 let json = serde_json::to_value(&event).unwrap();
502
503 assert_eq!(json["type"], "PipelineCompleted");
504 assert_eq!(json["durationMs"], 5678);
505 assert_eq!(json["totalFilesProcessed"], 10);
506 }
507
508 #[test]
509 fn test_pipeline_failed_serializes_correctly() {
510 let event = PipelineEvent::PipelineFailed {
511 node_id: "node-2".to_string(),
512 error: "Processing failed: out of memory".to_string(),
513 };
514 let json = serde_json::to_value(&event).unwrap();
515
516 assert_eq!(json["type"], "PipelineFailed");
517 assert_eq!(json["nodeId"], "node-2");
518 assert_eq!(json["error"], "Processing failed: out of memory");
519 }
520
521 #[test]
522 fn test_command_output_serializes_correctly() {
523 let event = PipelineEvent::CommandOutput {
524 node_id: "node-0".to_string(),
525 line: "[download] 34.2% of ~150MiB".to_string(),
526 };
527 let json = serde_json::to_value(&event).unwrap();
528 assert_eq!(json["type"], "CommandOutput");
529 assert_eq!(json["nodeId"], "node-0");
530 assert_eq!(json["line"], "[download] 34.2% of ~150MiB");
531 }
532
533 #[test]
534 fn test_pipeline_reporter_is_cloneable() {
535 let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
536 let received_clone = std::sync::Arc::clone(&received);
537
538 let reporter = PipelineReporter::new(move |event| {
539 received_clone.lock().unwrap().push(event);
540 });
541 let reporter2 = reporter.clone();
542
543 reporter.emit(PipelineEvent::PipelineStarted {
544 total_nodes: 1,
545 total_files: 1,
546 nodes: vec![],
547 });
548 reporter2.emit(PipelineEvent::CommandOutput {
549 node_id: "n".to_string(),
550 line: "hello".to_string(),
551 });
552
553 let events = received.lock().unwrap();
554 assert_eq!(events.len(), 2);
555 }
556
557 #[test]
560 fn test_noop_reporter_doesnt_panic() {
561 let reporter = PipelineReporter::new_noop();
563 reporter.emit(PipelineEvent::PipelineStarted {
564 total_nodes: 1,
565 total_files: 1,
566 nodes: vec![],
567 });
568 reporter.emit(PipelineEvent::PipelineCompleted {
569 duration_ms: 100,
570 total_files_processed: 1,
571 });
572 }
574
575 #[test]
576 fn test_recording_reporter_captures_events() {
577 let recorder = RecordingReporter::new();
579 let reporter = recorder.reporter();
580
581 reporter.emit(PipelineEvent::PipelineStarted {
582 total_nodes: 2,
583 total_files: 3,
584 nodes: vec![],
585 });
586 reporter.emit(PipelineEvent::NodeStarted {
587 node_id: "n1".to_string(),
588 node_index: 0,
589 total_nodes: 2,
590 node_type: "image-compress".to_string(),
591 parent_node_id: None,
592 });
593 reporter.emit(PipelineEvent::PipelineCompleted {
594 duration_ms: 500,
595 total_files_processed: 3,
596 });
597
598 let events = recorder.events();
600 assert_eq!(events.len(), 3);
601
602 assert!(matches!(events[0], PipelineEvent::PipelineStarted { .. }));
604 assert!(matches!(events[1], PipelineEvent::NodeStarted { .. }));
605 assert!(matches!(events[2], PipelineEvent::PipelineCompleted { .. }));
606 }
607
608 #[test]
609 fn test_reporter_calls_callback() {
610 let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
612 let received_clone = std::sync::Arc::clone(&received);
613
614 let reporter = PipelineReporter::new(move |event| {
615 received_clone.lock().unwrap().push(event);
616 });
617
618 reporter.emit(PipelineEvent::PipelineStarted {
619 total_nodes: 1,
620 total_files: 1,
621 nodes: vec![],
622 });
623
624 let events = received.lock().unwrap();
625 assert_eq!(events.len(), 1);
626 if let PipelineEvent::PipelineStarted {
627 total_nodes,
628 total_files,
629 ..
630 } = &events[0]
631 {
632 assert_eq!(*total_nodes, 1);
633 assert_eq!(*total_files, 1);
634 } else {
635 panic!("Expected PipelineStarted event");
636 }
637 }
638}