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 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//! ADR-0008 (render may reference models). `InProgress`/`Completed`/`Failed` are
12//! generic task-state words — not DSP-API-divergent vocabulary (the 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`
44/// created a new in-progress dump as a probe side effect. `note` describes
45/// the probe dump id.
46/// - **(b) Foreign-slot case**: the server's single dump slot is occupied by a
47/// different project's dump. The delete is a no-op (we never remove another
48/// project's dump). `note` names the occupying project's IRI.
49///
50/// Delete *failures* are returned as `Err(Diagnostic)` from the action, never
51/// as a `DumpDeleteOutcome`.
52pub struct DumpDeleteOutcome {
53 /// `true` when an existing dump was actually removed.
54 /// `false` when no completed/failed dump existed (see doc-comment above for
55 /// the two `false` cases).
56 pub deleted: bool,
57 /// Human-readable note for the `deleted: false` cases.
58 /// `None` when `deleted: true`.
59 pub note: Option<String>,
60}
61
62/// A progress event during a dump run, passed from the action layer to the
63/// `ProgressReporter` (stderr only).
64///
65/// `DumpEvent::Done` is consumed **only** by the `ProgressReporter` (stderr);
66/// it never reaches the `Renderer`. The final byte count reaches the renderer
67/// via `DumpOutcome.bytes`.
68pub enum DumpEvent {
69 /// The dump was successfully triggered on the server.
70 Triggered {
71 /// Server-assigned dump ID.
72 id: String,
73 },
74 /// A polling tick while waiting for the dump to complete.
75 Polling {
76 /// Logical elapsed time in seconds (explicit accumulator, not wall
77 /// clock). The first `Polling` event has `elapsed_secs = 0`.
78 elapsed_secs: u64,
79 /// Current task status as reported by the server.
80 status: DumpStatus,
81 },
82 /// The dump is complete on the server and download has started.
83 Downloading,
84 /// The download finished. Consumed only by the `ProgressReporter`;
85 /// the renderer receives the byte count via `DumpOutcome.bytes` instead.
86 Done {
87 /// Number of bytes received from the server.
88 bytes: u64,
89 },
90 /// An existing completed or in-progress dump was found and is being
91 /// adopted (idempotent re-download, default mode).
92 Adopting {
93 /// The existing dump's server-assigned ID.
94 id: String,
95 },
96 /// An existing dump is being deleted before a fresh one is triggered
97 /// (`--replace` mode).
98 Deleting {
99 /// The dump ID being deleted.
100 id: String,
101 },
102 /// `--delete` was requested but no completed/failed dump existed; the
103 /// `create_project_dump` probe created a new in-progress dump. The dump
104 /// will complete server-side without being downloaded.
105 ProbeCreated {
106 /// The ID of the newly-created in-progress dump.
107 id: String,
108 },
109 /// `--replace --discard-other-project` is discarding an existing dump that
110 /// belongs to a **different** project to free the single server-wide dump slot.
111 DiscardingOtherProjectDump {
112 /// The foreign dump's id being discarded.
113 id: String,
114 /// The IRI of the project that dump belongs to (server-reported value).
115 project_iri: String,
116 },
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn dump_outcome_construction() {
125 let outcome = DumpOutcome {
126 path: std::path::PathBuf::from("./0001-20260529T120000Z.zip"),
127 bytes: 1024,
128 cleaned_up: true,
129 reused: false,
130 created_at: None,
131 };
132 assert_eq!(outcome.bytes, 1024);
133 assert!(outcome.cleaned_up);
134 assert!(!outcome.reused);
135 assert!(outcome.created_at.is_none());
136 assert_eq!(
137 outcome.path,
138 std::path::PathBuf::from("./0001-20260529T120000Z.zip")
139 );
140 }
141
142 #[test]
143 fn dump_outcome_reused_with_created_at() {
144 use chrono::TimeZone;
145 let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
146 let outcome = DumpOutcome {
147 path: std::path::PathBuf::from("./0001-20260529T120000Z.zip"),
148 bytes: 999,
149 cleaned_up: false,
150 reused: true,
151 created_at: Some(ts),
152 };
153 assert!(outcome.reused);
154 assert_eq!(outcome.created_at, Some(ts));
155 }
156
157 #[test]
158 fn dump_delete_outcome_construction() {
159 let deleted = DumpDeleteOutcome {
160 deleted: true,
161 note: None,
162 };
163 assert!(deleted.deleted);
164 assert!(deleted.note.is_none());
165
166 // Case (a): probe — create_project_dump created a new in-progress dump.
167 let probe = DumpDeleteOutcome {
168 deleted: false,
169 note: Some("no dump existed; a probe created an in-progress dump probe-id-42 that will complete server-side".to_string()),
170 };
171 assert!(!probe.deleted);
172 assert!(probe.note.is_some());
173 assert!(probe.note.unwrap().contains("probe-id-42"));
174
175 // Case (b): foreign-slot — the single dump slot is held by a different project.
176 let foreign_slot = DumpDeleteOutcome {
177 deleted: false,
178 note: Some(
179 "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(),
180 ),
181 };
182 assert!(!foreign_slot.deleted);
183 let note = foreign_slot.note.unwrap();
184 assert!(note.contains("0002"), "note must name the foreign project");
185 }
186
187 #[test]
188 fn dump_event_variants_construct() {
189 let triggered = DumpEvent::Triggered {
190 id: "dump-id-42".into(),
191 };
192 let polling = DumpEvent::Polling {
193 elapsed_secs: 0,
194 status: DumpStatus::InProgress,
195 };
196 let downloading = DumpEvent::Downloading;
197 let done = DumpEvent::Done { bytes: 2048 };
198 let adopting = DumpEvent::Adopting {
199 id: "existing-id".into(),
200 };
201 let deleting = DumpEvent::Deleting {
202 id: "del-id".into(),
203 };
204 let probe_created = DumpEvent::ProbeCreated {
205 id: "probe-id".into(),
206 };
207 let discarding_other = DumpEvent::DiscardingOtherProjectDump {
208 id: "foreign-id".into(),
209 project_iri: "http://rdfh.ch/projects/0002".into(),
210 };
211
212 // Pattern-match to verify enum arms are reachable.
213 match triggered {
214 DumpEvent::Triggered { id } => assert_eq!(id, "dump-id-42"),
215 _ => panic!("unexpected variant"),
216 }
217 match polling {
218 DumpEvent::Polling {
219 elapsed_secs,
220 status,
221 } => {
222 assert_eq!(elapsed_secs, 0);
223 assert_eq!(status, DumpStatus::InProgress);
224 }
225 _ => panic!("unexpected variant"),
226 }
227 assert!(matches!(downloading, DumpEvent::Downloading));
228 match done {
229 DumpEvent::Done { bytes } => assert_eq!(bytes, 2048),
230 _ => panic!("unexpected variant"),
231 }
232 match adopting {
233 DumpEvent::Adopting { id } => assert_eq!(id, "existing-id"),
234 _ => panic!("unexpected variant"),
235 }
236 match deleting {
237 DumpEvent::Deleting { id } => assert_eq!(id, "del-id"),
238 _ => panic!("unexpected variant"),
239 }
240 match probe_created {
241 DumpEvent::ProbeCreated { id } => assert_eq!(id, "probe-id"),
242 _ => panic!("unexpected variant"),
243 }
244 match discarding_other {
245 DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
246 assert_eq!(id, "foreign-id");
247 assert_eq!(project_iri, "http://rdfh.ch/projects/0002");
248 }
249 _ => panic!("unexpected variant"),
250 }
251 }
252}