Skip to main content

dsp_cli/model/
project.rs

1//! Project domain shapes — shared across the client → action boundary.
2//!
3//! Types here are the dsp-cli vocabulary for project data. DSP-API wire types
4//! live inside `src/client/http.rs` and are never exposed above the client
5//! layer. See dsp-cli/ADR-0001 and dsp-cli/ADR-0008.
6
7/// Minimal project reference. Phase 4 expands this into the full Project model.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ProjectRef {
10    /// The project's IRI (e.g. `http://rdfh.ch/projects/0001`).
11    pub iri: String,
12    /// Four-hex-digit shortcode (e.g. `0001`).
13    pub shortcode: String,
14    /// Human-readable shortname (e.g. `anything`).
15    pub shortname: String,
16}
17
18/// A project's activation status (dsp-cli vocabulary for DSP-API's `status` bool).
19///
20/// **Why an enum over `active: bool`:** mirrors the [`DumpStatus`] precedent and
21/// keeps the `active`/`inactive` display words as a single source of truth — the
22/// vocabulary ADR wants dsp-cli terms defined once. Wire (bool) → enum translation
23/// happens at the client boundary (`http.rs`), never here. Deliberate — not
24/// over-engineering. See dsp-cli/ADR-0001 and dsp-cli/ADR-0008.
25///
26/// [`DumpStatus`]: crate::model::DumpStatus
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ProjectStatus {
29    Active,
30    Inactive,
31}
32
33impl ProjectStatus {
34    /// Return the canonical lower-case display string for this status.
35    ///
36    /// This is the single source of truth for the status word used in
37    /// `project list` output (human prose, JSON, tabular formats). Wire
38    /// (bool) → enum translation happens in the HTTP client layer (`http.rs`)
39    /// and is intentionally NOT delegated here.
40    pub fn as_str(self) -> &'static str {
41        match self {
42            ProjectStatus::Active => "active",
43            ProjectStatus::Inactive => "inactive",
44        }
45    }
46}
47
48/// One language-tagged description value (dsp-cli vocabulary for the API's
49/// `description: [{value, language}]`). `language` is optional on the wire.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ProjectDescription {
52    /// The description text (may contain HTML markup as returned by the server).
53    pub value: String,
54    /// BCP-47 language tag, if the server provides one (e.g. `"en"`).
55    pub language: Option<String>,
56}
57
58/// A lean reference to a child data-model, as surfaced by `project describe`.
59///
60/// Full data-model detail belongs to the future `data-model list/describe`
61/// commands. `name` is derived from the data-model's IRI by the HTTP client
62/// layer at the dsp-cli/ADR-0001 boundary — it is NOT a server-supplied field.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DataModelSummary {
65    /// Short name of the data-model (e.g. `beol`), derived from the IRI.
66    pub name: String,
67    /// Full IRI of the data-model (e.g. `http://api.dasch.swiss/ontology/0801/beol/v2`).
68    pub iri: String,
69}
70
71/// A project as shown by `dsp vre project describe` — the rich projection.
72///
73/// Contrast `Project` (the lean `list` index projection). Carries identity,
74/// status, description, keywords, and a data-models summary (count + names).
75/// No `serde` derive: wire deserialization stays in `src/client/http.rs`.
76/// See dsp-cli/ADR-0001 and dsp-cli/ADR-0008.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ProjectDetail {
79    /// The project's IRI (e.g. `http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF`).
80    pub iri: String,
81    /// Four-hex-digit shortcode (e.g. `0801`).
82    pub shortcode: String,
83    /// Human-readable shortname (e.g. `beol`).
84    pub shortname: String,
85    /// Human-readable long name, if the server supplies one.
86    pub longname: Option<String>,
87    /// Whether the project is currently active on the server.
88    pub status: ProjectStatus,
89    /// Language-tagged description values (may contain HTML markup).
90    pub description: Vec<ProjectDescription>,
91    /// Free-form keywords associated with the project.
92    pub keywords: Vec<String>,
93    /// Lean references to the project's child data-models, sorted by name.
94    pub data_models: Vec<DataModelSummary>,
95}
96
97/// A project as shown by `dsp vre project list` — the lean index projection.
98///
99/// Rich detail (description, keywords, licences, …) belongs to the future
100/// `describe` model, not here. No `serde` derive: wire deserialization stays
101/// in `src/client/http.rs` (the private `ProjectListItemDto`). See dsp-cli/ADR-0001
102/// and dsp-cli/ADR-0008.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Project {
105    /// The project's IRI (e.g. `http://rdfh.ch/projects/0001`).
106    pub iri: String,
107    /// Four-hex-digit shortcode (e.g. `0001`).
108    pub shortcode: String,
109    /// Human-readable shortname (e.g. `anything`).
110    pub shortname: String,
111    /// Human-readable long name, if the server supplies one.
112    pub longname: Option<String>,
113    /// Whether the project is currently active on the server.
114    pub status: ProjectStatus,
115    /// Number of data-models (DSP-API "ontologies") attached to the project.
116    pub data_models: usize,
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn project_ref_construction_and_equality() {
125        let a = ProjectRef {
126            iri: "http://rdfh.ch/projects/0001".into(),
127            shortcode: "0001".into(),
128            shortname: "anything".into(),
129        };
130        let b = a.clone();
131        assert_eq!(a, b);
132        assert_eq!(a.iri, "http://rdfh.ch/projects/0001");
133        assert_eq!(a.shortcode, "0001");
134        assert_eq!(a.shortname, "anything");
135    }
136
137    #[test]
138    fn project_status_as_str() {
139        assert_eq!(ProjectStatus::Active.as_str(), "active");
140        assert_eq!(ProjectStatus::Inactive.as_str(), "inactive");
141    }
142
143    #[test]
144    fn project_status_derives() {
145        // Clone, Copy, PartialEq, Eq, Debug
146        let s = ProjectStatus::Active;
147        let t = s; // Copy
148        assert_eq!(s, t);
149        assert_ne!(ProjectStatus::Active, ProjectStatus::Inactive);
150        let _ = format!("{s:?}"); // Debug
151    }
152
153    #[test]
154    fn project_construction_and_equality() {
155        let p = Project {
156            iri: "http://rdfh.ch/projects/0001".into(),
157            shortcode: "0001".into(),
158            shortname: "anything".into(),
159            longname: Some("Anything Project".into()),
160            status: ProjectStatus::Active,
161            data_models: 3,
162        };
163        let cloned = p.clone();
164        assert_eq!(p, cloned);
165        assert_eq!(p.iri, "http://rdfh.ch/projects/0001");
166        assert_eq!(p.shortcode, "0001");
167        assert_eq!(p.shortname, "anything");
168        assert_eq!(p.longname.as_deref(), Some("Anything Project"));
169        assert_eq!(p.status, ProjectStatus::Active);
170        assert_eq!(p.data_models, 3);
171    }
172
173    #[test]
174    fn project_longname_none() {
175        let p = Project {
176            iri: "http://rdfh.ch/projects/0002".into(),
177            shortcode: "0002".into(),
178            shortname: "images".into(),
179            longname: None,
180            status: ProjectStatus::Inactive,
181            data_models: 0,
182        };
183        assert_eq!(p.longname, None);
184        assert_eq!(p.status, ProjectStatus::Inactive);
185        assert_eq!(p.data_models, 0);
186    }
187
188    #[test]
189    fn project_description_with_language() {
190        let d = ProjectDescription {
191            value: "<b>Project Metadata</b>".into(),
192            language: Some("en".into()),
193        };
194        let cloned = d.clone();
195        assert_eq!(d, cloned);
196        assert_eq!(d.value, "<b>Project Metadata</b>");
197        assert_eq!(d.language.as_deref(), Some("en"));
198    }
199
200    #[test]
201    fn project_description_without_language() {
202        let d = ProjectDescription { value: "Beschreibung".into(), language: None };
203        let cloned = d.clone();
204        assert_eq!(d, cloned);
205        assert_eq!(d.language, None);
206    }
207
208    #[test]
209    fn data_model_summary_construction_and_equality() {
210        let dm = DataModelSummary {
211            name: "beol".into(),
212            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
213        };
214        let cloned = dm.clone();
215        assert_eq!(dm, cloned);
216        assert_eq!(dm.name, "beol");
217        assert_eq!(dm.iri, "http://api.dasch.swiss/ontology/0801/beol/v2");
218    }
219
220    #[test]
221    fn project_detail_construction_and_equality() {
222        let detail = ProjectDetail {
223            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
224            shortcode: "0801".into(),
225            shortname: "beol".into(),
226            longname: Some("Bernoulli-Euler Online".into()),
227            status: ProjectStatus::Active,
228            description: vec![ProjectDescription {
229                value: "<b>Project Metadata</b>".into(),
230                language: Some("en".into()),
231            }],
232            keywords: vec!["Bernoulli".into(), "Condorcet".into(), "Euler".into()],
233            data_models: vec![
234                DataModelSummary {
235                    name: "beol".into(),
236                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
237                },
238                DataModelSummary {
239                    name: "biblio".into(),
240                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
241                },
242            ],
243        };
244        let cloned = detail.clone();
245        assert_eq!(detail, cloned);
246        assert_eq!(detail.iri, "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF");
247        assert_eq!(detail.shortcode, "0801");
248        assert_eq!(detail.shortname, "beol");
249        assert_eq!(detail.longname.as_deref(), Some("Bernoulli-Euler Online"));
250        assert_eq!(detail.status, ProjectStatus::Active);
251        assert_eq!(detail.description.len(), 1);
252        assert_eq!(detail.keywords.len(), 3);
253        assert_eq!(detail.data_models.len(), 2);
254    }
255
256    #[test]
257    fn project_detail_empty_case() {
258        let detail = ProjectDetail {
259            iri: "http://rdfh.ch/projects/0000".into(),
260            shortcode: "0000".into(),
261            shortname: "minimal".into(),
262            longname: None,
263            status: ProjectStatus::Inactive,
264            description: vec![],
265            keywords: vec![],
266            data_models: vec![],
267        };
268        assert_eq!(detail.longname, None);
269        assert!(detail.description.is_empty());
270        assert!(detail.keywords.is_empty());
271        assert!(detail.data_models.is_empty());
272        assert_eq!(detail.status, ProjectStatus::Inactive);
273    }
274}