Skip to main content

dsp_cli/render/
progress.rs

1//! Progress reporter layer — stderr progress lines during a dump run.
2//!
3//! `ProgressReporter` is a separate trait from `Renderer` because:
4//! - The renderer owns a single stdout sink; progress events belong on stderr.
5//! - Two impls (Human, JSON) vs five renderers keeps it right-sized — stderr is
6//!   non-data output so prose/lines/csv/tsv all share the human reporter.
7//! - Isolates the novel "multi-line polling progress to stderr" concern from the
8//!   renderer's stdout concerns (endorsed in the Step 6 rationale, ADR-0008).
9//!
10//! Returning `Result` is intentional: stderr writes can fail (e.g. the other
11//! end of a pipe closed unexpectedly). The action treats a stderr-write failure
12//! as fatal, consistent with the renderer's own `Write` calls.
13
14use std::io::{self, Write};
15
16use crate::diagnostic::Diagnostic;
17use crate::render::DumpEvent;
18
19/// Reports dump progress events to stderr (or any injected `Write` sink).
20///
21/// `report` is called once per event during a dump run. The action treats any
22/// returned `Err` as fatal (stderr-write failure is not silently ignored, for
23/// the same reason the renderer's `writeln!` failures are not ignored).
24pub trait ProgressReporter {
25    /// Emit a progress event.
26    ///
27    /// Returning `Result` is intentional — stderr writes can fail and the
28    /// action treats a stderr-write failure as fatal, consistent with the
29    /// renderer's own `Write` calls.
30    fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic>;
31}
32
33// ── HumanProgress ─────────────────────────────────────────────────────────────
34
35/// Human-readable progress reporter — one line per event to stderr.
36///
37/// `DumpEvent::Done` emits **nothing** because the renderer owns the final
38/// stdout line (the path + byte count). This mirrors the design decision in
39/// `render/dump.rs`: `Done` is consumed by the reporter only to let it flush;
40/// the visible confirmation is `Renderer::project_dump`.
41pub struct HumanProgress {
42    err: Box<dyn Write>,
43}
44
45impl HumanProgress {
46    /// Creates a reporter writing to `io::stderr()`.
47    pub fn new() -> Self {
48        Self {
49            err: Box::new(io::stderr()),
50        }
51    }
52
53    /// Creates a reporter writing to an arbitrary `Write` sink (used in tests).
54    pub fn with_writer(w: impl Write + 'static) -> Self {
55        Self { err: Box::new(w) }
56    }
57}
58
59impl Default for HumanProgress {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl ProgressReporter for HumanProgress {
66    fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
67        match event {
68            DumpEvent::Triggered { id } => {
69                writeln!(self.err, "Triggered dump {id}.")?;
70            }
71            DumpEvent::Polling {
72                elapsed_secs,
73                status,
74            } => {
75                writeln!(
76                    self.err,
77                    "polling… {elapsed_secs}s elapsed ({status})",
78                    status = status.as_str(),
79                )?;
80            }
81            DumpEvent::Downloading => {
82                writeln!(self.err, "Downloading…")?;
83            }
84            // Done emits nothing — the renderer owns the final stdout line.
85            DumpEvent::Done { .. } => {}
86            DumpEvent::Adopting { id } => {
87                writeln!(self.err, "Found existing dump {id}; adopting it.")?;
88            }
89            DumpEvent::Deleting { id } => {
90                writeln!(self.err, "Deleting dump {id}…")?;
91            }
92            DumpEvent::ProbeCreated { id } => {
93                writeln!(
94                    self.err,
95                    "No dump existed; a probe created a new in-progress dump {id} (it will complete server-side)."
96                )?;
97            }
98            DumpEvent::DiscardingOtherProjectDump { project_iri, .. } => {
99                writeln!(
100                    self.err,
101                    "\u{26a0} Discarding the existing dump for a different project ({project_iri}) \
102\u{2014} the DSP-API holds one dump server-wide."
103                )?;
104            }
105        }
106        Ok(())
107    }
108}
109
110// ── JsonProgress ──────────────────────────────────────────────────────────────
111
112/// JSON progress reporter — one compact JSON object per event to stderr.
113///
114/// Each event is serialised with `serde_json` and written as a single
115/// newline-terminated line:
116///
117/// ```text
118/// {"event":"triggered","id":"abc123"}
119/// {"event":"polling","elapsed_s":12,"status":"in_progress"}
120/// {"event":"downloading"}
121/// {"event":"done","bytes":12345}
122/// ```
123///
124/// The `Done` event is emitted (unlike `HumanProgress`) so JSON consumers
125/// can parse a complete event stream without relying solely on the final
126/// stdout object from the renderer.
127pub struct JsonProgress {
128    err: Box<dyn Write>,
129}
130
131impl JsonProgress {
132    /// Creates a reporter writing to `io::stderr()`.
133    pub fn new() -> Self {
134        Self {
135            err: Box::new(io::stderr()),
136        }
137    }
138
139    /// Creates a reporter writing to an arbitrary `Write` sink (used in tests).
140    pub fn with_writer(w: impl Write + 'static) -> Self {
141        Self { err: Box::new(w) }
142    }
143}
144
145impl Default for JsonProgress {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl ProgressReporter for JsonProgress {
152    fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
153        let obj = match event {
154            DumpEvent::Triggered { id } => {
155                serde_json::json!({"event": "triggered", "id": id})
156            }
157            DumpEvent::Polling {
158                elapsed_secs,
159                status,
160            } => {
161                serde_json::json!({
162                    "event": "polling",
163                    "elapsed_s": elapsed_secs,
164                    "status": status.as_str(),
165                })
166            }
167            DumpEvent::Downloading => {
168                serde_json::json!({"event": "downloading"})
169            }
170            DumpEvent::Done { bytes } => {
171                serde_json::json!({"event": "done", "bytes": bytes})
172            }
173            DumpEvent::Adopting { id } => {
174                serde_json::json!({"event": "adopting", "id": id})
175            }
176            DumpEvent::Deleting { id } => {
177                serde_json::json!({"event": "deleting", "id": id})
178            }
179            DumpEvent::ProbeCreated { id } => {
180                serde_json::json!({"event": "probe_created", "id": id})
181            }
182            DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
183                serde_json::json!({
184                    "event": "discarding_other_project_dump",
185                    "id": id,
186                    "project_iri": project_iri,
187                })
188            }
189        };
190        let line = serde_json::to_string(&obj)
191            .map_err(|e| Diagnostic::Internal(format!("json serialisation error: {e}")))?;
192        writeln!(self.err, "{line}")?;
193        Ok(())
194    }
195}
196
197// ── tests ─────────────────────────────────────────────────────────────────────
198
199#[cfg(test)]
200mod tests {
201    use std::cell::RefCell;
202    use std::rc::Rc;
203
204    use super::*;
205    use crate::model::DumpStatus;
206
207    // ── shared buffer ─────────────────────────────────────────────────────────
208
209    /// `Write` wrapper around a shared `Rc<RefCell<Vec<u8>>>` so we can read
210    /// the captured bytes after the reporter is dropped. Mirrors the pattern
211    /// used in `tests/auth_snapshots.rs`.
212    struct SharedBuf(Rc<RefCell<Vec<u8>>>);
213
214    impl Write for SharedBuf {
215        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
216            self.0.borrow_mut().write(buf)
217        }
218        fn flush(&mut self) -> std::io::Result<()> {
219            self.0.borrow_mut().flush()
220        }
221    }
222
223    fn shared_buf() -> (Rc<RefCell<Vec<u8>>>, SharedBuf) {
224        let buf = Rc::new(RefCell::new(Vec::<u8>::new()));
225        let writer = SharedBuf(Rc::clone(&buf));
226        (buf, writer)
227    }
228
229    fn buf_to_string(buf: &Rc<RefCell<Vec<u8>>>) -> String {
230        String::from_utf8(buf.borrow().clone()).expect("output must be valid UTF-8")
231    }
232
233    fn collect_human(events: &[DumpEvent]) -> String {
234        let (buf, w) = shared_buf();
235        let mut r = HumanProgress::with_writer(w);
236        for e in events {
237            r.report(e).expect("report must not fail in tests");
238        }
239        buf_to_string(&buf)
240    }
241
242    fn collect_json(events: &[DumpEvent]) -> String {
243        let (buf, w) = shared_buf();
244        let mut r = JsonProgress::with_writer(w);
245        for e in events {
246            r.report(e).expect("report must not fail in tests");
247        }
248        buf_to_string(&buf)
249    }
250
251    // ── HumanProgress ─────────────────────────────────────────────────────────
252
253    #[test]
254    fn human_triggered_formats_correctly() {
255        let out = collect_human(&[DumpEvent::Triggered {
256            id: "abc123".into(),
257        }]);
258        assert_eq!(out.trim(), "Triggered dump abc123.");
259    }
260
261    #[test]
262    fn human_polling_in_progress_formats_correctly() {
263        let out = collect_human(&[DumpEvent::Polling {
264            elapsed_secs: 12,
265            status: DumpStatus::InProgress,
266        }]);
267        assert_eq!(out.trim(), "polling… 12s elapsed (in_progress)");
268    }
269
270    #[test]
271    fn human_polling_completed_formats_correctly() {
272        let out = collect_human(&[DumpEvent::Polling {
273            elapsed_secs: 45,
274            status: DumpStatus::Completed,
275        }]);
276        assert_eq!(out.trim(), "polling… 45s elapsed (completed)");
277    }
278
279    #[test]
280    fn human_polling_failed_formats_correctly() {
281        let out = collect_human(&[DumpEvent::Polling {
282            elapsed_secs: 99,
283            status: DumpStatus::Failed,
284        }]);
285        assert_eq!(out.trim(), "polling… 99s elapsed (failed)");
286    }
287
288    #[test]
289    fn human_downloading_formats_correctly() {
290        let out = collect_human(&[DumpEvent::Downloading]);
291        assert_eq!(out.trim(), "Downloading…");
292    }
293
294    #[test]
295    fn human_done_emits_nothing() {
296        let out = collect_human(&[DumpEvent::Done { bytes: 12345 }]);
297        assert_eq!(out, "", "HumanProgress must emit nothing for Done");
298    }
299
300    // ── JsonProgress ──────────────────────────────────────────────────────────
301
302    fn parse_json_line(line: &str) -> serde_json::Value {
303        serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"))
304    }
305
306    #[test]
307    fn json_triggered_fields_correct() {
308        let out = collect_json(&[DumpEvent::Triggered {
309            id: "abc123".into(),
310        }]);
311        let v = parse_json_line(out.trim());
312        assert_eq!(v["event"], "triggered");
313        assert_eq!(v["id"], "abc123");
314    }
315
316    #[test]
317    fn json_polling_in_progress_fields_correct() {
318        let out = collect_json(&[DumpEvent::Polling {
319            elapsed_secs: 12,
320            status: DumpStatus::InProgress,
321        }]);
322        let v = parse_json_line(out.trim());
323        assert_eq!(v["event"], "polling");
324        assert_eq!(v["elapsed_s"], 12);
325        assert_eq!(v["status"], "in_progress");
326    }
327
328    #[test]
329    fn json_polling_completed_fields_correct() {
330        let out = collect_json(&[DumpEvent::Polling {
331            elapsed_secs: 28,
332            status: DumpStatus::Completed,
333        }]);
334        let v = parse_json_line(out.trim());
335        assert_eq!(v["event"], "polling");
336        assert_eq!(v["elapsed_s"], 28);
337        assert_eq!(v["status"], "completed");
338    }
339
340    #[test]
341    fn json_downloading_fields_correct() {
342        let out = collect_json(&[DumpEvent::Downloading]);
343        let v = parse_json_line(out.trim());
344        assert_eq!(v["event"], "downloading");
345    }
346
347    #[test]
348    fn json_done_fields_correct() {
349        let out = collect_json(&[DumpEvent::Done { bytes: 12345 }]);
350        let v = parse_json_line(out.trim());
351        assert_eq!(v["event"], "done");
352        assert_eq!(v["bytes"], 12345);
353    }
354
355    // ── new events: Adopting, Deleting, ProbeCreated ──────────────────────────
356
357    #[test]
358    fn human_adopting_formats_correctly() {
359        let out = collect_human(&[DumpEvent::Adopting {
360            id: "existing-dump-id".into(),
361        }]);
362        assert_eq!(
363            out.trim(),
364            "Found existing dump existing-dump-id; adopting it."
365        );
366    }
367
368    #[test]
369    fn human_deleting_formats_correctly() {
370        let out = collect_human(&[DumpEvent::Deleting {
371            id: "del-dump-id".into(),
372        }]);
373        assert_eq!(out.trim(), "Deleting dump del-dump-id\u{2026}");
374    }
375
376    #[test]
377    fn human_probe_created_formats_correctly() {
378        let out = collect_human(&[DumpEvent::ProbeCreated {
379            id: "probe-id-99".into(),
380        }]);
381        assert_eq!(
382            out.trim(),
383            "No dump existed; a probe created a new in-progress dump probe-id-99 (it will complete server-side)."
384        );
385    }
386
387    #[test]
388    fn json_adopting_fields_correct() {
389        let out = collect_json(&[DumpEvent::Adopting {
390            id: "existing-dump-id".into(),
391        }]);
392        let v = parse_json_line(out.trim());
393        assert_eq!(v["event"], "adopting");
394        assert_eq!(v["id"], "existing-dump-id");
395    }
396
397    #[test]
398    fn json_deleting_fields_correct() {
399        let out = collect_json(&[DumpEvent::Deleting {
400            id: "del-dump-id".into(),
401        }]);
402        let v = parse_json_line(out.trim());
403        assert_eq!(v["event"], "deleting");
404        assert_eq!(v["id"], "del-dump-id");
405    }
406
407    #[test]
408    fn json_probe_created_fields_correct() {
409        let out = collect_json(&[DumpEvent::ProbeCreated {
410            id: "probe-id-99".into(),
411        }]);
412        let v = parse_json_line(out.trim());
413        assert_eq!(v["event"], "probe_created");
414        assert_eq!(v["id"], "probe-id-99");
415    }
416
417    // ── new event: DiscardingOtherProjectDump ─────────────────────────────────
418
419    #[test]
420    fn human_discarding_other_project_dump_formats_correctly() {
421        let out = collect_human(&[DumpEvent::DiscardingOtherProjectDump {
422            id: "foreign-dump-id".into(),
423            project_iri: "http://rdfh.ch/projects/0002".into(),
424        }]);
425        // Must mention the foreign project IRI and that it's the server-wide slot.
426        assert!(
427            out.contains("http://rdfh.ch/projects/0002"),
428            "output must contain the foreign project IRI: {out:?}"
429        );
430        assert!(
431            out.contains("different project"),
432            "output must mention 'different project': {out:?}"
433        );
434    }
435
436    #[test]
437    fn json_discarding_other_project_dump_fields_correct() {
438        let out = collect_json(&[DumpEvent::DiscardingOtherProjectDump {
439            id: "foreign-dump-id".into(),
440            project_iri: "http://rdfh.ch/projects/0002".into(),
441        }]);
442        let v = parse_json_line(out.trim());
443        assert_eq!(v["event"], "discarding_other_project_dump");
444        assert_eq!(v["id"], "foreign-dump-id");
445        assert_eq!(v["project_iri"], "http://rdfh.ch/projects/0002");
446        // Must NOT use camelCase "projectIri" key (ADR-0001).
447        assert!(
448            v.get("projectIri").is_none(),
449            "JSON must not use camelCase 'projectIri'"
450        );
451    }
452}