dsp_cli/model/dump.rs
1//! Dump domain shapes — shared across the client → action boundary.
2//!
3//! Types here are the dsp-cli vocabulary for dump tasks. DSP-API wire types
4//! (`DataTaskStatusApiResponse` etc.) live inside `src/client/http.rs` and are
5//! never exposed above the client layer. See ADR-0001 and ADR-0008.
6
7/// The status of a server-side dump task.
8///
9/// Does **not** derive `serde::Deserialize` — wire deserialization
10/// (`"in_progress"` / `"completed"` / `"failed"` strings) happens via the
11/// private wire DTO in `src/client/http.rs` (Step 5), keeping serde strictly at
12/// the client boundary. See ADR-0001.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DumpStatus {
15 InProgress,
16 Completed,
17 Failed,
18}
19
20impl DumpStatus {
21 /// Return the canonical lower-case wire string for this status.
22 ///
23 /// This is the single source of truth for the status string form used in
24 /// progress reporting (human and JSON). Parsing wire→enum happens in the
25 /// HTTP client layer (`http.rs`) and is intentionally NOT delegated here.
26 pub fn as_str(self) -> &'static str {
27 match self {
28 DumpStatus::InProgress => "in_progress",
29 DumpStatus::Completed => "completed",
30 DumpStatus::Failed => "failed",
31 }
32 }
33}
34
35/// A server-side dump task (boundary-translated from DSP-API's DataTask).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DumpTask {
38 /// Unique identifier for the dump job (URL-safe, used verbatim in
39 /// subsequent status/download/delete calls).
40 pub id: String,
41 pub status: DumpStatus,
42 /// Only meaningful when `status == Failed`. The value is pre-truncated to
43 /// ≤500 chars at the client boundary (the single truncation point in
44 /// `HttpDspClient`); the Step 8 poll-loop `Failed` branch relies on this
45 /// invariant — do not store un-truncated messages here.
46 pub error_message: Option<String>,
47 /// When the dump was created on the server, if known.
48 ///
49 /// Parsed best-effort from the `createdAt` field in the server response
50 /// (RFC 3339). `None` if the field is absent or cannot be parsed —
51 /// `created_at` is display-only, never load-bearing.
52 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
53}
54
55/// The outcome of `create_project_dump`, crossing the client→action boundary.
56///
57/// Decision matrix (per ADR-0001, ADR-0008, ADR-0012):
58///
59/// | Variant | Meaning | Typical action response |
60/// |---|---|---|
61/// | `Created(task)` | Fresh dump started; poll until done. | Poll → download. |
62/// | `Exists { id }` | A dump for **this** project already exists. | Adopt / replace / error, depending on mode. |
63/// | `ExistsForOtherProject { id, project_iri }` | The server's single dump slot is occupied by a **different** project's dump. | Refuse (default/delete) or discard + recreate (replace + `--discard-other-project`). |
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum CreateDumpOutcome {
66 /// A fresh dump was successfully triggered; `task.status` is `InProgress`.
67 Created(DumpTask),
68 /// A dump already exists; `id` is its server-assigned identifier.
69 Exists {
70 /// The ID of the pre-existing dump (URL-safe base64, verified).
71 id: String,
72 },
73 /// A dump already exists, but it belongs to a **different** project than the
74 /// one requested. The DSP-API holds a single dump server-wide; this signals
75 /// that the slot is occupied by another project's dump.
76 ///
77 /// `project_iri` is the occupying project's IRI **as reported by the server**
78 /// in the 409 conflict body — not user-supplied or domain-derived. Carrying an
79 /// IRI *value* across layers is allowed under ADR-0001 (only the wire term
80 /// "projectIri" and the word "export" are banned above `src/client/`).
81 ExistsForOtherProject {
82 /// The existing (foreign) dump's server-assigned id.
83 id: String,
84 /// The IRI of the project the existing dump belongs to.
85 project_iri: String,
86 },
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn dump_status_equality() {
95 assert_eq!(DumpStatus::InProgress, DumpStatus::InProgress);
96 assert_ne!(DumpStatus::InProgress, DumpStatus::Completed);
97 assert_ne!(DumpStatus::InProgress, DumpStatus::Failed);
98 assert_ne!(DumpStatus::Completed, DumpStatus::Failed);
99 }
100
101 #[test]
102 fn dump_task_construction_and_equality() {
103 let task = DumpTask {
104 id: "abc-123".into(),
105 status: DumpStatus::InProgress,
106 error_message: None,
107 created_at: None,
108 };
109 let cloned = task.clone();
110 assert_eq!(task, cloned);
111
112 let failed = DumpTask {
113 id: "def-456".into(),
114 status: DumpStatus::Failed,
115 error_message: Some("disk full".into()),
116 created_at: None,
117 };
118 assert_ne!(task, failed);
119 assert_eq!(failed.error_message.as_deref(), Some("disk full"));
120 }
121
122 #[test]
123 fn create_dump_outcome_variants() {
124 use chrono::TimeZone;
125 let ts = chrono::Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
126 let task = DumpTask {
127 id: "task-1".into(),
128 status: DumpStatus::InProgress,
129 error_message: None,
130 created_at: Some(ts),
131 };
132 let created = CreateDumpOutcome::Created(task.clone());
133 assert!(matches!(created, CreateDumpOutcome::Created(_)));
134 if let CreateDumpOutcome::Created(t) = &created {
135 assert_eq!(t.id, "task-1");
136 assert_eq!(t.created_at, Some(ts));
137 }
138
139 let exists = CreateDumpOutcome::Exists {
140 id: "existing-id".into(),
141 };
142 assert!(matches!(exists, CreateDumpOutcome::Exists { .. }));
143 if let CreateDumpOutcome::Exists { id } = &exists {
144 assert_eq!(id, "existing-id");
145 }
146
147 // Equality
148 assert_ne!(created, exists);
149 }
150
151 #[test]
152 fn create_dump_outcome_exists_for_other_project_construction() {
153 let other = CreateDumpOutcome::ExistsForOtherProject {
154 id: "foreign-dump-id".into(),
155 project_iri: "http://rdfh.ch/projects/0002".into(),
156 };
157 assert!(matches!(
158 other,
159 CreateDumpOutcome::ExistsForOtherProject { .. }
160 ));
161 if let CreateDumpOutcome::ExistsForOtherProject { id, project_iri } = &other {
162 assert_eq!(id, "foreign-dump-id");
163 assert_eq!(project_iri, "http://rdfh.ch/projects/0002");
164 }
165
166 // Verify derives: Clone, PartialEq, Eq, Debug
167 let cloned = other.clone();
168 assert_eq!(other, cloned);
169 let different = CreateDumpOutcome::ExistsForOtherProject {
170 id: "other-id".into(),
171 project_iri: "http://rdfh.ch/projects/0003".into(),
172 };
173 assert_ne!(other, different);
174 // Debug
175 let s = format!("{other:?}");
176 assert!(s.contains("ExistsForOtherProject"));
177 }
178}