Skip to main content

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}