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