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 ADR-0001 and 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 ADR-0001 and 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 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 ADR-0001 and 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 ADR-0001
102/// and 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 {
203            value: "Beschreibung".into(),
204            language: None,
205        };
206        let cloned = d.clone();
207        assert_eq!(d, cloned);
208        assert_eq!(d.language, None);
209    }
210
211    #[test]
212    fn data_model_summary_construction_and_equality() {
213        let dm = DataModelSummary {
214            name: "beol".into(),
215            iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
216        };
217        let cloned = dm.clone();
218        assert_eq!(dm, cloned);
219        assert_eq!(dm.name, "beol");
220        assert_eq!(dm.iri, "http://api.dasch.swiss/ontology/0801/beol/v2");
221    }
222
223    #[test]
224    fn project_detail_construction_and_equality() {
225        let detail = ProjectDetail {
226            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
227            shortcode: "0801".into(),
228            shortname: "beol".into(),
229            longname: Some("Bernoulli-Euler Online".into()),
230            status: ProjectStatus::Active,
231            description: vec![ProjectDescription {
232                value: "<b>Project Metadata</b>".into(),
233                language: Some("en".into()),
234            }],
235            keywords: vec!["Bernoulli".into(), "Condorcet".into(), "Euler".into()],
236            data_models: vec![
237                DataModelSummary {
238                    name: "beol".into(),
239                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
240                },
241                DataModelSummary {
242                    name: "biblio".into(),
243                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
244                },
245            ],
246        };
247        let cloned = detail.clone();
248        assert_eq!(detail, cloned);
249        assert_eq!(detail.iri, "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF");
250        assert_eq!(detail.shortcode, "0801");
251        assert_eq!(detail.shortname, "beol");
252        assert_eq!(detail.longname.as_deref(), Some("Bernoulli-Euler Online"));
253        assert_eq!(detail.status, ProjectStatus::Active);
254        assert_eq!(detail.description.len(), 1);
255        assert_eq!(detail.keywords.len(), 3);
256        assert_eq!(detail.data_models.len(), 2);
257    }
258
259    #[test]
260    fn project_detail_empty_case() {
261        let detail = ProjectDetail {
262            iri: "http://rdfh.ch/projects/0000".into(),
263            shortcode: "0000".into(),
264            shortname: "minimal".into(),
265            longname: None,
266            status: ProjectStatus::Inactive,
267            description: vec![],
268            keywords: vec![],
269            data_models: vec![],
270        };
271        assert_eq!(detail.longname, None);
272        assert!(detail.description.is_empty());
273        assert!(detail.keywords.is_empty());
274        assert!(detail.data_models.is_empty());
275        assert_eq!(detail.status, ProjectStatus::Inactive);
276    }
277}