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 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}