dsp_cli/render/
progress.rs1use 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 { err: Box::new(io::stderr()) }
49 }
50
51 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 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
105pub struct JsonProgress {
123 err: Box<dyn Write>,
124}
125
126impl JsonProgress {
127 pub fn new() -> Self {
129 Self { err: Box::new(io::stderr()) }
130 }
131
132 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#[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 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 #[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 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 #[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 #[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 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 assert!(v.get("projectIri").is_none(), "JSON must not use camelCase 'projectIri'");
404 }
405}