Skip to main content

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}