centraldogma/
model.rs

1//! Data models of CentralDogma
2use serde::{de::DeserializeOwned, Deserialize, Serialize};
3
4/// A revision number of a [`Commit`].
5///
6/// A revision number is an integer which refers to a specific point of repository history.
7/// When a repository is created, it starts with an initial commit whose revision is 1.
8/// As new commits are added, each commit gets its own revision number,
9/// monotonically increasing from the previous commit's revision. i.e. 1, 2, 3, ...
10///
11/// A revision number can also be represented as a negative integer.
12/// When a revision number is negative, we start from -1 which refers to the latest commit in repository history,
13/// which is often called 'HEAD' of the repository.
14/// A smaller revision number refers to the older commit.
15/// e.g. -2 refers to the commit before the latest commit, and so on.
16///
17/// A revision with a negative integer is called 'relative revision'.
18/// By contrast, a revision with a positive integer is called 'absolute revision'.
19#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
20pub struct Revision(Option<i64>);
21
22impl Revision {
23    pub fn as_i64(&self) -> Option<i64> {
24        self.0
25    }
26}
27
28impl std::fmt::Display for Revision {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self.0 {
31            Some(n) => write!(f, "{}", n),
32            None => write!(f, ""),
33        }
34    }
35}
36
37impl AsRef<Option<i64>> for Revision {
38    fn as_ref(&self) -> &Option<i64> {
39        &self.0
40    }
41}
42
43impl Revision {
44    /// Revision `-1`, also known as `HEAD`.
45    pub const HEAD: Revision = Revision(Some(-1));
46    /// Revision `1`, also known as `INIT`.
47    pub const INIT: Revision = Revision(Some(1));
48    /// Omitted revision, behavior is decided on server side, usually [`HEAD`]
49    pub const DEFAULT: Revision = Revision(None);
50
51    /// Create a new instance with the specified revision number.
52    pub fn from(i: i64) -> Self {
53        Revision(Some(i))
54    }
55}
56
57/// Creator of a project or repository or commit
58#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase")]
60pub struct Author {
61    /// Name of this author.
62    pub name: String,
63    /// Email of this author.
64    pub email: String,
65}
66
67/// A top-level element in Central Dogma storage model.
68/// A project has "dogma" and "meta" repositories by default which contain project configuration
69/// files accessible by administrators and project owners respectively.
70#[derive(Debug, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct Project {
73    /// Name of this project.
74    pub name: String,
75    /// The author who initially created this project.
76    pub creator: Author,
77    /// Url of this project
78    pub url: Option<String>,
79    /// When the project was created
80    pub created_at: Option<String>,
81}
82
83/// Repository information
84#[derive(Debug, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct Repository {
87    /// Name of this repository.
88    pub name: String,
89    /// The author who initially created this repository.
90    pub creator: Author,
91    /// Head [`Revision`] of the repository.
92    pub head_revision: Revision,
93    /// Url of this repository.
94    pub url: Option<String>,
95    /// When the repository was created.
96    pub created_at: Option<String>,
97}
98
99/// The content of an [`Entry`]
100#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
102#[serde(tag = "type", content = "content")]
103pub enum EntryContent {
104    /// Content as a JSON Value.
105    Json(serde_json::Value),
106    /// Content as a String.
107    Text(String),
108    /// This Entry is a directory.
109    Directory,
110}
111
112/// A file or a directory in a repository.
113#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "camelCase")]
115pub struct Entry {
116    /// Path of this entry.
117    pub path: String,
118    /// Content of this entry.
119    #[serde(flatten)]
120    pub content: EntryContent,
121    /// Revision of this entry.
122    pub revision: Revision,
123    /// Url of this entry.
124    pub url: String,
125    /// When this entry was last modified.
126    pub modified_at: Option<String>,
127}
128
129impl Entry {
130    pub fn entry_type(&self) -> EntryType {
131        match self.content {
132            EntryContent::Json(_) => EntryType::Json,
133            EntryContent::Text(_) => EntryType::Text,
134            EntryContent::Directory => EntryType::Directory,
135        }
136    }
137}
138
139/// The type of a [`ListEntry`]
140#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
142pub enum EntryType {
143    /// A UTF-8 encoded JSON file.
144    Json,
145    /// A UTF-8 encoded text file.
146    Text,
147    /// A directory.
148    Directory,
149}
150
151/// A metadata of a file or a directory in a repository.
152/// ListEntry has no content.
153#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
154#[serde(rename_all = "camelCase")]
155pub struct ListEntry {
156    pub path: String,
157    pub r#type: EntryType,
158}
159
160/// Type of a [`Query`]
161#[derive(Debug, PartialEq, Eq)]
162pub enum QueryType {
163    Identity,
164    IdentityJson,
165    IdentityText,
166    JsonPath(Vec<String>),
167}
168
169/// A Query on a file
170#[derive(Debug)]
171pub struct Query {
172    pub(crate) path: String,
173    pub(crate) r#type: QueryType,
174}
175
176impl Query {
177    fn normalize_path(path: &str) -> String {
178        if path.starts_with('/') {
179            path.to_owned()
180        } else {
181            format!("/{}", path)
182        }
183    }
184
185    /// Returns a newly-created [`Query`] that retrieves the content as it is.
186    /// Returns `None` if path is empty
187    pub fn identity(path: &str) -> Option<Self> {
188        if path.is_empty() {
189            return None;
190        }
191        Some(Query {
192            path: Self::normalize_path(path),
193            r#type: QueryType::Identity,
194        })
195    }
196
197    /// Returns a newly-created [`Query`] that retrieves the textual content as it is.
198    /// Returns `None` if path is empty
199    pub fn of_text(path: &str) -> Option<Self> {
200        if path.is_empty() {
201            return None;
202        }
203        Some(Query {
204            path: Self::normalize_path(path),
205            r#type: QueryType::IdentityText,
206        })
207    }
208
209    /// Returns a newly-created [`Query`] that retrieves the JSON content as it is.
210    /// Returns `None` if path is empty
211    pub fn of_json(path: &str) -> Option<Self> {
212        if path.is_empty() {
213            return None;
214        }
215        Some(Query {
216            path: Self::normalize_path(path),
217            r#type: QueryType::IdentityJson,
218        })
219    }
220
221    /// Returns a newly-created [`Query`] that applies a series of
222    /// [JSON path expressions](https://github.com/json-path/JsonPath/blob/master/README.md)
223    /// to the content.
224    /// Returns `None` if path is empty or does not end with `.json`.
225    /// Returns `None` if any of the path expression provided is empty.
226    pub fn of_json_path(path: &str, exprs: Vec<String>) -> Option<Self> {
227        if !path.to_lowercase().ends_with("json") {
228            return None;
229        }
230        if exprs.iter().any(|expr| expr.is_empty()) {
231            return None;
232        }
233        Some(Query {
234            path: Self::normalize_path(path),
235            r#type: QueryType::JsonPath(exprs),
236        })
237    }
238}
239
240/// Typed content of a [`CommitMessage`]
241#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
242#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
243#[serde(tag = "markup", content = "detail")]
244pub enum CommitDetail {
245    /// Commit details as markdown
246    Markdown(String),
247    /// Commit details as plaintext
248    Plaintext(String),
249}
250
251/// Description of a [`Commit`]
252#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
253#[serde(rename_all = "camelCase")]
254pub struct CommitMessage {
255    /// Summary of this commit message
256    pub summary: String,
257    #[serde(flatten, skip_serializing_if = "Option::is_none")]
258    /// Detailed description of this commit message
259    pub detail: Option<CommitDetail>,
260}
261
262impl CommitMessage {
263    pub fn only_summary(summary: &str) -> Self {
264        CommitMessage {
265            summary: summary.to_owned(),
266            detail: None,
267        }
268    }
269}
270
271/// Result of a [push](trait@crate::ContentService#tymethod.push) operation.
272#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
273#[serde(rename_all = "camelCase")]
274pub struct PushResult {
275    /// Revision of this commit.
276    pub revision: Revision,
277    /// When this commit was pushed.
278    pub pushed_at: Option<String>,
279}
280
281/// A set of Changes and its metadata.
282#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(rename_all = "camelCase")]
284pub struct Commit {
285    /// Revision of this commit.
286    pub revision: Revision,
287    /// Author of this commit.
288    pub author: Author,
289    /// Description of this commit.
290    pub commit_message: CommitMessage,
291    /// When this commit was pushed.
292    pub pushed_at: Option<String>,
293}
294
295/// Typed content of a [`Change`].
296#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
297#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
298#[serde(tag = "type", content = "content")]
299pub enum ChangeContent {
300    /// Adds a new JSON file or replaces an existing file with the provided json.
301    UpsertJson(serde_json::Value),
302
303    /// Adds a new text file or replaces an existing file with the provided content.
304    UpsertText(String),
305
306    /// Removes an existing file.
307    Remove,
308
309    /// Renames an existsing file to this provided path.
310    Rename(String),
311
312    /// Applies a JSON patch to a JSON file with the provided JSON patch object,
313    /// as defined in [RFC 6902](https://tools.ietf.org/html/rfc6902).
314    ApplyJsonPatch(serde_json::Value),
315
316    /// Applies a textual patch to a text file with the provided
317    /// [unified format](https://en.wikipedia.org/wiki/Diff_utility#Unified_format) string.
318    ApplyTextPatch(String),
319}
320
321/// A modification of an individual [`Entry`]
322#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
323#[serde(rename_all = "camelCase")]
324pub struct Change {
325    /// Path of the file change.
326    pub path: String,
327    /// Content of the file change.
328    #[serde(flatten)]
329    pub content: ChangeContent,
330}
331
332/// A change result from a
333/// [watch_file](trait@crate::WatchService#tymethod.watch_file_stream) operation.
334#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
335#[serde(rename_all = "camelCase")]
336pub struct WatchFileResult {
337    /// Revision of the change.
338    pub revision: Revision,
339    /// Content of the change.
340    pub entry: Entry,
341}
342
343/// A change result from a
344/// [watch_repo](trait@crate::WatchService#tymethod.watch_repo_stream) operation.
345#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
346#[serde(rename_all = "camelCase")]
347pub struct WatchRepoResult {
348    /// Revision of the change.
349    pub revision: Revision,
350}
351
352/// A resource that is watchable
353/// Currently supported [`WatchFileResult`] and [`WatchRepoResult`]
354pub(crate) trait Watchable: DeserializeOwned + Send {
355    fn revision(&self) -> Revision;
356}
357
358impl Watchable for WatchFileResult {
359    fn revision(&self) -> Revision {
360        self.revision
361    }
362}
363
364impl Watchable for WatchRepoResult {
365    fn revision(&self) -> Revision {
366        self.revision
367    }
368}
369
370#[cfg(test)]
371mod test {
372    use super::*;
373
374    #[test]
375    fn test_query_identity() {
376        let query = Query::identity("/a.json").unwrap();
377
378        assert_eq!(query.path, "/a.json");
379        assert_eq!(query.r#type, QueryType::Identity);
380    }
381
382    #[test]
383    fn test_query_identity_auto_fix_path() {
384        let query = Query::identity("a.json").unwrap();
385
386        assert_eq!(query.path, "/a.json");
387        assert_eq!(query.r#type, QueryType::Identity);
388    }
389
390    #[test]
391    fn test_query_reject_empty_path() {
392        let query = Query::identity("");
393
394        assert!(query.is_none());
395    }
396}