1use std::io::{self, Write};
15
16use crate::diagnostic::Diagnostic;
17use crate::render::DumpEvent;
18
19pub trait ProgressReporter {
25 fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic>;
31}
32
33pub struct HumanProgress {
42 err: Box<dyn Write>,
43}
44
45impl HumanProgress {
46 pub fn new() -> Self {
48 Self {
49 err: Box::new(io::stderr()),
50 }
51 }
52
53 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 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
110pub struct JsonProgress {
128 err: Box<dyn Write>,
129}
130
131impl JsonProgress {
132 pub fn new() -> Self {
134 Self {
135 err: Box::new(io::stderr()),
136 }
137 }
138
139 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#[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 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 #[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 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 #[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 #[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 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 assert!(
448 v.get("projectIri").is_none(),
449 "JSON must not use camelCase 'projectIri'"
450 );
451 }
452}