Skip to main content

ceres_core/
progress.rs

1//! Progress reporting for harvest operations.
2//!
3//! This module provides a trait-based abstraction for reporting progress during
4//! harvest operations, enabling decoupled logging and UI updates.
5
6use std::time::Duration;
7
8use crate::{BatchHarvestSummary, SyncStats};
9
10/// Events emitted during harvesting operations.
11///
12/// These events provide fine-grained progress information that consumers
13/// can use for logging, UI updates, or metrics collection.
14#[derive(Debug, Clone)]
15pub enum HarvestEvent<'a> {
16    /// Batch harvest starting.
17    BatchStarted {
18        /// Total number of portals to harvest.
19        total_portals: usize,
20    },
21
22    /// Single portal harvest starting.
23    PortalStarted {
24        /// Zero-based index of the current portal.
25        portal_index: usize,
26        /// Total number of portals in batch.
27        total_portals: usize,
28        /// Portal name identifier.
29        portal_name: &'a str,
30        /// Portal URL.
31        portal_url: &'a str,
32    },
33
34    /// Found existing datasets in database for portal.
35    ExistingDatasetsFound {
36        /// Number of existing datasets.
37        count: usize,
38    },
39
40    /// Found datasets on the portal.
41    PortalDatasetsFound {
42        /// Number of datasets found.
43        count: usize,
44    },
45
46    /// Progress update during dataset processing.
47    DatasetProcessed {
48        /// Number of datasets processed so far.
49        current: usize,
50        /// Total number of datasets to process.
51        total: usize,
52        /// Counts by outcome type.
53        created: usize,
54        /// Number of updated datasets.
55        updated: usize,
56        /// Number of unchanged datasets.
57        unchanged: usize,
58        /// Number of failed datasets.
59        failed: usize,
60        /// Number of datasets skipped due to circuit breaker.
61        skipped: usize,
62    },
63
64    /// Single portal harvest completed successfully.
65    PortalCompleted {
66        /// Zero-based index of the current portal.
67        portal_index: usize,
68        /// Total number of portals in batch.
69        total_portals: usize,
70        /// Portal name identifier.
71        portal_name: &'a str,
72        /// Final statistics.
73        stats: &'a SyncStats,
74    },
75
76    /// Single portal harvest failed.
77    PortalFailed {
78        /// Zero-based index of the current portal.
79        portal_index: usize,
80        /// Total number of portals in batch.
81        total_portals: usize,
82        /// Portal name identifier.
83        portal_name: &'a str,
84        /// Error description.
85        error: &'a str,
86    },
87
88    /// Batch harvest completed.
89    BatchCompleted {
90        /// Aggregated summary of all portal results.
91        summary: &'a BatchHarvestSummary,
92    },
93
94    /// Single portal harvest was cancelled.
95    PortalCancelled {
96        /// Zero-based index of the current portal.
97        portal_index: usize,
98        /// Total number of portals in batch.
99        total_portals: usize,
100        /// Portal name identifier.
101        portal_name: &'a str,
102        /// Partial statistics at cancellation time.
103        stats: &'a SyncStats,
104    },
105
106    /// Batch harvest was cancelled.
107    BatchCancelled {
108        /// Number of portals completed before cancellation.
109        completed_portals: usize,
110        /// Total number of portals in batch.
111        total_portals: usize,
112    },
113
114    /// Circuit breaker is open, harvest pausing/failing.
115    CircuitBreakerOpen {
116        /// Service name.
117        service: &'a str,
118        /// Time until recovery attempt.
119        retry_after: Duration,
120    },
121}
122
123/// Trait for reporting harvest progress.
124///
125/// Implementors can provide CLI output, server event streams, metrics,
126/// or any other form of progress reporting.
127///
128/// The default implementation does nothing (silent mode), which is
129/// appropriate for library usage where the caller doesn't need progress updates.
130///
131/// # Example
132///
133/// ```
134/// use ceres_core::progress::{ProgressReporter, HarvestEvent};
135///
136/// struct MyReporter;
137///
138/// impl ProgressReporter for MyReporter {
139///     fn report(&self, event: HarvestEvent<'_>) {
140///         match event {
141///             HarvestEvent::PortalStarted { portal_name, .. } => {
142///                 println!("Starting: {}", portal_name);
143///             }
144///             _ => {}
145///         }
146///     }
147/// }
148/// ```
149pub trait ProgressReporter: Send + Sync {
150    /// Called when a harvest event occurs.
151    ///
152    /// The default implementation does nothing (silent mode).
153    fn report(&self, event: HarvestEvent<'_>) {
154        // Default: do nothing (silent mode for library usage)
155        let _ = event;
156    }
157}
158
159/// A no-op reporter that ignores all events.
160///
161/// Use this when you don't need progress reporting (library mode).
162#[derive(Debug, Default, Clone, Copy)]
163pub struct SilentReporter;
164
165impl ProgressReporter for SilentReporter {}
166
167/// A reporter that logs events using the `tracing` crate.
168///
169/// This is suitable for CLI applications that want structured logging.
170#[derive(Debug, Default, Clone, Copy)]
171pub struct TracingReporter;
172
173impl ProgressReporter for TracingReporter {
174    fn report(&self, event: HarvestEvent<'_>) {
175        use tracing::{error, info};
176
177        match event {
178            HarvestEvent::BatchStarted { total_portals } => {
179                info!("Starting batch harvest of {} portal(s)", total_portals);
180            }
181            HarvestEvent::PortalStarted {
182                portal_index,
183                total_portals,
184                portal_name,
185                portal_url,
186            } => {
187                info!(
188                    "[Portal {}/{}] {} ({})",
189                    portal_index + 1,
190                    total_portals,
191                    portal_name,
192                    portal_url
193                );
194            }
195            HarvestEvent::ExistingDatasetsFound { count } => {
196                info!("Found {} existing dataset(s) in database", count);
197            }
198            HarvestEvent::PortalDatasetsFound { count } => {
199                info!("Found {} dataset(s) on portal", count);
200            }
201            HarvestEvent::DatasetProcessed {
202                current,
203                total,
204                created,
205                updated,
206                unchanged,
207                failed,
208                skipped,
209            } => {
210                let pct = (current as f64 / total as f64 * 100.0) as u8;
211                if skipped > 0 {
212                    info!(
213                        "Progress: {}/{} ({}%) - {} new, {} updated, {} unchanged, {} failed, {} skipped",
214                        current, total, pct, created, updated, unchanged, failed, skipped
215                    );
216                } else {
217                    info!(
218                        "Progress: {}/{} ({}%) - {} new, {} updated, {} unchanged, {} failed",
219                        current, total, pct, created, updated, unchanged, failed
220                    );
221                }
222            }
223            HarvestEvent::PortalCompleted {
224                portal_index,
225                total_portals,
226                portal_name,
227                stats,
228            } => {
229                info!(
230                    "[Portal {}/{}] {} completed: {} dataset(s) ({} created, {} updated, {} unchanged)",
231                    portal_index + 1,
232                    total_portals,
233                    portal_name,
234                    stats.total(),
235                    stats.created,
236                    stats.updated,
237                    stats.unchanged
238                );
239            }
240            HarvestEvent::PortalFailed {
241                portal_index,
242                total_portals,
243                portal_name,
244                error,
245            } => {
246                error!(
247                    "[Portal {}/{}] {} failed: {}",
248                    portal_index + 1,
249                    total_portals,
250                    portal_name,
251                    error
252                );
253            }
254            HarvestEvent::BatchCompleted { summary } => {
255                info!(
256                    "Batch complete: {} portal(s), {} dataset(s) ({} successful, {} failed)",
257                    summary.total_portals(),
258                    summary.total_datasets(),
259                    summary.successful_count(),
260                    summary.failed_count()
261                );
262            }
263            HarvestEvent::PortalCancelled {
264                portal_index,
265                total_portals,
266                portal_name,
267                stats,
268            } => {
269                info!(
270                    "[Portal {}/{}] {} cancelled: {} dataset(s) processed ({} created, {} updated, {} unchanged)",
271                    portal_index + 1,
272                    total_portals,
273                    portal_name,
274                    stats.total(),
275                    stats.created,
276                    stats.updated,
277                    stats.unchanged
278                );
279            }
280            HarvestEvent::BatchCancelled {
281                completed_portals,
282                total_portals,
283            } => {
284                info!(
285                    "Batch cancelled: {}/{} portal(s) completed before cancellation",
286                    completed_portals, total_portals
287                );
288            }
289            HarvestEvent::CircuitBreakerOpen {
290                service,
291                retry_after,
292            } => {
293                use tracing::warn;
294                warn!(
295                    "Circuit breaker '{}' is open. Retry after {} seconds.",
296                    service,
297                    retry_after.as_secs()
298                );
299            }
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_silent_reporter_does_nothing() {
310        let reporter = SilentReporter;
311        // Should not panic
312        reporter.report(HarvestEvent::BatchStarted { total_portals: 5 });
313    }
314
315    #[test]
316    fn test_tracing_reporter_handles_all_events() {
317        let reporter = TracingReporter;
318
319        // Test all event variants don't panic
320        reporter.report(HarvestEvent::BatchStarted { total_portals: 2 });
321        reporter.report(HarvestEvent::PortalStarted {
322            portal_index: 0,
323            total_portals: 2,
324            portal_name: "test",
325            portal_url: "https://example.com",
326        });
327        reporter.report(HarvestEvent::ExistingDatasetsFound { count: 10 });
328        reporter.report(HarvestEvent::PortalDatasetsFound { count: 20 });
329        reporter.report(HarvestEvent::DatasetProcessed {
330            current: 10,
331            total: 20,
332            created: 2,
333            updated: 3,
334            unchanged: 5,
335            failed: 0,
336            skipped: 0,
337        });
338
339        let stats = SyncStats {
340            unchanged: 5,
341            updated: 3,
342            created: 2,
343            failed: 0,
344            skipped: 0,
345        };
346        reporter.report(HarvestEvent::PortalCompleted {
347            portal_index: 0,
348            total_portals: 2,
349            portal_name: "test",
350            stats: &stats,
351        });
352        reporter.report(HarvestEvent::PortalFailed {
353            portal_index: 1,
354            total_portals: 2,
355            portal_name: "test2",
356            error: "connection failed",
357        });
358
359        let summary = BatchHarvestSummary::new();
360        reporter.report(HarvestEvent::BatchCompleted { summary: &summary });
361
362        // Test cancellation events
363        reporter.report(HarvestEvent::PortalCancelled {
364            portal_index: 0,
365            total_portals: 2,
366            portal_name: "test",
367            stats: &stats,
368        });
369        reporter.report(HarvestEvent::BatchCancelled {
370            completed_portals: 1,
371            total_portals: 3,
372        });
373
374        // Test circuit breaker events
375        reporter.report(HarvestEvent::CircuitBreakerOpen {
376            service: "gemini",
377            retry_after: Duration::from_secs(30),
378        });
379    }
380
381    #[test]
382    fn test_default_implementations() {
383        let silent = SilentReporter;
384        silent.report(HarvestEvent::BatchStarted { total_portals: 1 });
385
386        let tracing = TracingReporter;
387        tracing.report(HarvestEvent::BatchStarted { total_portals: 1 });
388    }
389}