requiem/domain/
requirement.rs1use std::collections::BTreeSet;
2use std::collections::HashMap;
3use std::io;
4use std::path::Path;
5
6use borsh::BorshSerialize;
7use chrono::{DateTime, Utc};
8use sha2::Digest;
9use sha2::Sha256;
10use uuid::Uuid;
11
12use crate::domain::Hrid;
13pub use crate::domain::requirement::storage::LoadError;
14use crate::domain::requirement::storage::MarkdownRequirement;
15
16mod storage;
17
18#[derive(Debug, Clone, PartialEq)]
24pub struct Requirement {
25 content: Content,
26 metadata: Metadata,
27}
28
29#[derive(Debug, BorshSerialize, Clone, PartialEq)]
33struct Content {
34 content: String,
35 tags: BTreeSet<String>,
36}
37
38impl Content {
39 fn fingerprint(&self) -> String {
40 let encoded = borsh::to_vec(self).expect("this should never fail");
42
43 let hash = Sha256::digest(encoded);
45
46 format!("{hash:x}")
48 }
49}
50
51#[derive(Debug, Clone, PartialEq)]
55struct Metadata {
56 uuid: Uuid,
58
59 hrid: Hrid,
64 created: DateTime<Utc>,
65 parents: HashMap<Uuid, Parent>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Parent {
70 pub hrid: Hrid,
71 pub fingerprint: String,
72}
73
74impl Requirement {
75 #[must_use]
79 pub fn new(hrid: Hrid, content: String) -> Self {
80 Self::new_with_uuid(hrid, content, Uuid::new_v4())
81 }
82
83 pub(crate) fn new_with_uuid(hrid: Hrid, content: String, uuid: Uuid) -> Self {
84 let content = Content {
85 content,
86 tags: BTreeSet::default(),
87 };
88
89 let metadata = Metadata {
90 uuid,
91 hrid,
92 created: Utc::now(),
93 parents: HashMap::new(),
94 };
95
96 Self { content, metadata }
97 }
98
99 #[must_use]
103 pub fn content(&self) -> &str {
104 &self.content.content
105 }
106
107 #[must_use]
109 pub const fn tags(&self) -> &BTreeSet<String> {
110 &self.content.tags
111 }
112
113 pub fn set_tags(&mut self, tags: BTreeSet<String>) {
117 self.content.tags = tags;
118 }
119
120 pub fn add_tag(&mut self, tag: String) -> bool {
124 self.content.tags.insert(tag)
125 }
126
127 #[must_use]
131 pub const fn hrid(&self) -> &Hrid {
132 &self.metadata.hrid
133 }
134
135 #[must_use]
137 pub const fn uuid(&self) -> Uuid {
138 self.metadata.uuid
139 }
140
141 #[must_use]
143 pub const fn created(&self) -> DateTime<Utc> {
144 self.metadata.created
145 }
146
147 #[must_use]
154 pub fn fingerprint(&self) -> String {
155 self.content.fingerprint()
156 }
157
158 pub fn add_parent(&mut self, parent_id: Uuid, parent_info: Parent) -> Option<Parent> {
160 self.metadata.parents.insert(parent_id, parent_info)
161 }
162
163 pub fn parents(&self) -> impl Iterator<Item = (Uuid, &Parent)> {
165 self.metadata
166 .parents
167 .iter()
168 .map(|(&id, parent)| (id, parent))
169 }
170
171 pub fn parents_mut(&mut self) -> impl Iterator<Item = (Uuid, &mut Parent)> {
173 self.metadata
174 .parents
175 .iter_mut()
176 .map(|(&id, parent)| (id, parent))
177 }
178
179 pub fn load(path: &Path, hrid: String) -> Result<Self, LoadError> {
187 Ok(MarkdownRequirement::load(path, hrid)?.try_into()?)
188 }
189
190 pub fn save(&self, path: &Path) -> io::Result<()> {
199 MarkdownRequirement::from(self.clone()).save(path)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use std::collections::BTreeSet;
206
207 use super::Content;
208
209 #[test]
210 fn fingerprint_does_not_panic() {
211 let content = Content {
212 content: "Some string".to_string(),
213 tags: ["tag1".to_string(), "tag2".to_string()].into(),
214 };
215 content.fingerprint();
216 }
217
218 #[test]
219 fn fingerprint_is_stable_with_tag_order() {
220 let content1 = Content {
221 content: "Some string".to_string(),
222 tags: ["tag1".to_string(), "tag2".to_string()].into(),
223 };
224 let content2 = Content {
225 content: "Some string".to_string(),
226 tags: ["tag2".to_string(), "tag1".to_string()].into(),
227 };
228 assert_eq!(content1.fingerprint(), content2.fingerprint());
229 }
230
231 #[test]
232 fn tags_affect_fingerprint() {
233 let content1 = Content {
234 content: "Some string".to_string(),
235 tags: ["tag1".to_string()].into(),
236 };
237 let content2 = Content {
238 content: "Some string".to_string(),
239 tags: ["tag1".to_string(), "tag2".to_string()].into(),
240 };
241 assert_ne!(content1.fingerprint(), content2.fingerprint());
242 }
243
244 #[test]
245 fn content_affects_fingerprint() {
246 let content1 = Content {
247 content: "Some string".to_string(),
248 tags: BTreeSet::default(),
249 };
250 let content2 = Content {
251 content: "Other string".to_string(),
252 tags: BTreeSet::default(),
253 };
254 assert_ne!(content1.fingerprint(), content2.fingerprint());
255 }
256}