dsp_cli/render/dump.rs
1//! Dump renderer-input shapes — typed values passed to `Renderer::project_dump`
2//! and `ProgressReporter::report`.
3//!
4//! These are render-layer types, not domain model types. They live here rather
5//! than in `src/model/` because they flow from the action layer into the
6//! renderer/reporter, not across the client→action boundary. See dsp-cli/ADR-0008 for
7//! the layer split.
8//!
9//! Note on cross-layer dependency: `DumpEvent::Polling` carries `DumpStatus`, a
10//! `model/` type, so `render/` depends on `model/`. That direction is allowed by
11//! dsp-cli/ADR-0008 (render may reference models). `InProgress`/`Completed`/`Failed` are
12//! generic task-state words — not DSP-API-divergent vocabulary (the dsp-cli/ADR-0001
13//! boundary is about `ontology`/`class`/`property` terms). Accepted as a shared
14//! model type.
15
16use chrono::{DateTime, Utc};
17
18use crate::model::DumpStatus;
19
20/// Final result of a completed dump, passed from the action layer to the
21/// `Renderer`.
22pub struct DumpOutcome {
23 /// Filesystem path where the dump file was written.
24 pub path: std::path::PathBuf,
25 /// Number of bytes written.
26 pub bytes: u64,
27 /// `true` when the server-side dump was deleted after download
28 /// (i.e. `--cleanup` succeeded).
29 pub cleaned_up: bool,
30 /// `true` when an existing server-side dump was adopted (idempotent
31 /// re-download) rather than a fresh dump being triggered.
32 pub reused: bool,
33 /// When the server-side dump was originally created, if known.
34 /// May be `None` if the server omitted `createdAt` or the timestamp
35 /// failed to parse.
36 pub created_at: Option<DateTime<Utc>>,
37}
38
39/// Final result of a delete operation, passed from the action layer to the
40/// `Renderer` via `Renderer::project_dump_deleted`.
41///
42/// `deleted: false` has two distinct meanings, discriminated by `note`:
43/// - **(a) Probe case**: no existing dump was found — `create_project_dump` created a new
44/// in-progress dump as a probe side effect. `note` describes the probe dump id.
45/// - **(b) Foreign-slot case**: the server's single dump slot is occupied by a different project's
46/// dump. The delete is a no-op (we never remove another project's dump). `note` names the
47/// occupying project's IRI.
48///
49/// Delete *failures* are returned as `Err(Diagnostic)` from the action, never
50/// as a `DumpDeleteOutcome`.
51pub struct DumpDeleteOutcome {
52 /// `true` when an existing dump was actually removed.
53 /// `false` when no completed/failed dump existed (see doc-comment above for
54 /// the two `false` cases).
55 pub deleted: bool,
56 /// Human-readable note for the `deleted: false` cases.
57 /// `None` when `deleted: true`.
58 pub note: Option<String>,
59}
60
61/// A progress event during a dump run, passed from the action layer to the
62/// `ProgressReporter` (stderr only).
63///
64/// `DumpEvent::Done` is consumed **only** by the `ProgressReporter` (stderr);
65/// it never reaches the `Renderer`. The final byte count reaches the renderer
66/// via `DumpOutcome.bytes`.
67pub enum DumpEvent {
68 /// The dump was successfully triggered on the server.
69 Triggered {
70 /// Server-assigned dump ID.
71 id: String,
72 },
73 /// A polling tick while waiting for the dump to complete.
74 Polling {
75 /// Logical elapsed time in seconds (explicit accumulator, not wall
76 /// clock). The first `Polling` event has `elapsed_secs = 0`.
77 elapsed_secs: u64,
78 /// Current task status as reported by the server.
79 status: DumpStatus,
80 },
81 /// The dump is complete on the server and download has started.
82 Downloading,
83 /// The download finished. Consumed only by the `ProgressReporter`;
84 /// the renderer receives the byte count via `DumpOutcome.bytes` instead.
85 Done {
86 /// Number of bytes received from the server.
87 bytes: u64,
88 },
89 /// An existing completed or in-progress dump was found and is being
90 /// adopted (idempotent re-download, default mode).
91 Adopting {
92 /// The existing dump's server-assigned ID.
93 id: String,
94 },
95 /// An existing dump is being deleted before a fresh one is triggered
96 /// (`--replace` mode).
97 Deleting {
98 /// The dump ID being deleted.
99 id: String,
100 },
101 /// `--delete` was requested but no completed/failed dump existed; the
102 /// `create_project_dump` probe created a new in-progress dump. The dump
103 /// will complete server-side without being downloaded.
104 ProbeCreated {
105 /// The ID of the newly-created in-progress dump.
106 id: String,
107 },
108 /// `--replace --discard-other-project` is discarding an existing dump that
109 /// belongs to a **different** project to free the single server-wide dump slot.
110 DiscardingOtherProjectDump {
111 /// The foreign dump's id being discarded.
112 id: String,
113 /// The IRI of the project that dump belongs to (server-reported value).
114 project_iri: String,
115 },
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn dump_outcome_construction() {
124 let outcome = DumpOutcome {
125 path: std::path::PathBuf::from("./0001-20260529T120000Z.zip"),
126 bytes: 1024,
127 cleaned_up: true,
128 reused: false,
129 created_at: None,
130 };
131 assert_eq!(outcome.bytes, 1024);
132 assert!(outcome.cleaned_up);
133 assert!(!outcome.reused);
134 assert!(outcome.created_at.is_none());
135 assert_eq!(outcome.path, std::path::PathBuf::from("./0001-20260529T120000Z.zip"));
136 }
137
138 #[test]
139 fn dump_outcome_reused_with_created_at() {
140 use chrono::TimeZone;
141 let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
142 let outcome = DumpOutcome {
143 path: std::path::PathBuf::from("./0001-20260529T120000Z.zip"),
144 bytes: 999,
145 cleaned_up: false,
146 reused: true,
147 created_at: Some(ts),
148 };
149 assert!(outcome.reused);
150 assert_eq!(outcome.created_at, Some(ts));
151 }
152
153 #[test]
154 fn dump_delete_outcome_construction() {
155 let deleted = DumpDeleteOutcome { deleted: true, note: None };
156 assert!(deleted.deleted);
157 assert!(deleted.note.is_none());
158
159 // Case (a): probe — create_project_dump created a new in-progress dump.
160 let probe = DumpDeleteOutcome {
161 deleted: false,
162 note: Some(
163 "no dump existed; a probe created an in-progress dump probe-id-42 that will complete server-side"
164 .to_string(),
165 ),
166 };
167 assert!(!probe.deleted);
168 assert!(probe.note.is_some());
169 assert!(probe.note.unwrap().contains("probe-id-42"));
170
171 // Case (b): foreign-slot — the single dump slot is held by a different project.
172 let foreign_slot = DumpDeleteOutcome {
173 deleted: false,
174 note: Some(
175 "no dump for the requested project to delete; the server's single dump slot is held by a different project (http://rdfh.ch/projects/0002)".to_string(),
176 ),
177 };
178 assert!(!foreign_slot.deleted);
179 let note = foreign_slot.note.unwrap();
180 assert!(note.contains("0002"), "note must name the foreign project");
181 }
182
183 #[test]
184 fn dump_event_variants_construct() {
185 let triggered = DumpEvent::Triggered { id: "dump-id-42".into() };
186 let polling = DumpEvent::Polling { elapsed_secs: 0, status: DumpStatus::InProgress };
187 let downloading = DumpEvent::Downloading;
188 let done = DumpEvent::Done { bytes: 2048 };
189 let adopting = DumpEvent::Adopting { id: "existing-id".into() };
190 let deleting = DumpEvent::Deleting { id: "del-id".into() };
191 let probe_created = DumpEvent::ProbeCreated { id: "probe-id".into() };
192 let discarding_other = DumpEvent::DiscardingOtherProjectDump {
193 id: "foreign-id".into(),
194 project_iri: "http://rdfh.ch/projects/0002".into(),
195 };
196
197 // Pattern-match to verify enum arms are reachable.
198 match triggered {
199 DumpEvent::Triggered { id } => assert_eq!(id, "dump-id-42"),
200 _ => panic!("unexpected variant"),
201 }
202 match polling {
203 DumpEvent::Polling { elapsed_secs, status } => {
204 assert_eq!(elapsed_secs, 0);
205 assert_eq!(status, DumpStatus::InProgress);
206 }
207 _ => panic!("unexpected variant"),
208 }
209 assert!(matches!(downloading, DumpEvent::Downloading));
210 match done {
211 DumpEvent::Done { bytes } => assert_eq!(bytes, 2048),
212 _ => panic!("unexpected variant"),
213 }
214 match adopting {
215 DumpEvent::Adopting { id } => assert_eq!(id, "existing-id"),
216 _ => panic!("unexpected variant"),
217 }
218 match deleting {
219 DumpEvent::Deleting { id } => assert_eq!(id, "del-id"),
220 _ => panic!("unexpected variant"),
221 }
222 match probe_created {
223 DumpEvent::ProbeCreated { id } => assert_eq!(id, "probe-id"),
224 _ => panic!("unexpected variant"),
225 }
226 match discarding_other {
227 DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
228 assert_eq!(id, "foreign-id");
229 assert_eq!(project_iri, "http://rdfh.ch/projects/0002");
230 }
231 _ => panic!("unexpected variant"),
232 }
233 }
234}