requiem/domain/requirement.rs
1use std::{
2 collections::{BTreeSet, HashMap},
3 io,
4 path::Path,
5};
6
7use borsh::BorshSerialize;
8use chrono::{DateTime, Utc};
9use sha2::{Digest, Sha256};
10use uuid::Uuid;
11
12pub use crate::storage::markdown::LoadError;
13use crate::{domain::Hrid, storage::markdown::MarkdownRequirement};
14
15/// A requirement is a document used to describe a system.
16///
17/// It can represent a user requirement, a specification, etc.
18/// Requirements can have dependencies between them, such that one requirement
19/// satisfies, fulfils, verifies (etc.) another requirement.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Requirement {
22 /// The requirement's content (markdown text and tags).
23 pub content: Content,
24 /// The requirement's metadata (UUID, HRID, creation time, parents).
25 pub metadata: Metadata,
26}
27
28/// The semantically important content of the requirement.
29///
30/// This contributes to the 'fingerprint' of the requirement
31#[derive(Debug, BorshSerialize, Clone, PartialEq, Eq)]
32pub struct Content {
33 /// Title of the requirement (without HRID or markdown heading markers).
34 pub title: String,
35 /// Body content of the requirement (markdown text after the heading).
36 pub body: String,
37 /// Set of tags associated with the requirement.
38 pub tags: BTreeSet<String>,
39}
40
41impl Content {
42 /// Creates a borrowed reference to this content.
43 ///
44 /// This is useful for computing fingerprints without cloning data.
45 #[must_use]
46 pub fn as_ref(&self) -> ContentRef<'_> {
47 ContentRef {
48 title: &self.title,
49 body: &self.body,
50 tags: &self.tags,
51 }
52 }
53
54 fn fingerprint(&self) -> String {
55 self.as_ref().fingerprint()
56 }
57}
58
59/// A borrowed reference to requirement content.
60///
61/// This type represents the semantically important content of a requirement
62/// using borrowed data. It is used for computing fingerprints without cloning.
63#[derive(Debug, Clone, Copy)]
64pub struct ContentRef<'a> {
65 /// The title of the requirement.
66 pub title: &'a str,
67 /// The body content of the requirement.
68 pub body: &'a str,
69 /// Tags associated with the requirement.
70 pub tags: &'a BTreeSet<String>,
71}
72
73impl ContentRef<'_> {
74 /// Calculate the fingerprint of this content.
75 ///
76 /// The fingerprint is a SHA256 hash of the Borsh-serialized body and tags.
77 /// The title and HRID are excluded so that renaming requirements or
78 /// updating titles doesn't invalidate child requirement links.
79 ///
80 /// # Panics
81 ///
82 /// Panics if borsh serialization fails (which should never happen for this
83 /// data structure).
84 #[must_use]
85 pub fn fingerprint(&self) -> String {
86 #[derive(BorshSerialize)]
87 struct FingerprintData<'a> {
88 body: &'a str,
89 tags: &'a BTreeSet<String>,
90 }
91
92 let data = FingerprintData {
93 body: self.body,
94 tags: self.tags,
95 };
96
97 // encode using [borsh](https://borsh.io/)
98 let encoded = borsh::to_vec(&data).expect("this should never fail");
99
100 // generate a SHA256 hash
101 let hash = Sha256::digest(encoded);
102
103 // Convert to hex string
104 format!("{hash:x}")
105 }
106}
107
108/// Requirement metadata.
109///
110/// Does not contribute to the requirement fingerprint.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Metadata {
113 /// Globally unique, perpetually stable identifier
114 pub uuid: Uuid,
115
116 /// Globally unique, human readable identifier.
117 ///
118 /// This should in general change, however it is possible to
119 /// change it if needed.
120 pub hrid: Hrid,
121 /// Timestamp recording when the requirement was created.
122 pub created: DateTime<Utc>,
123 /// Parent requirements keyed by UUID.
124 pub parents: HashMap<Uuid, Parent>,
125}
126
127/// Parent requirement metadata stored alongside a requirement.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Parent {
130 /// Human-readable identifier of the parent requirement.
131 pub hrid: Hrid,
132 /// Fingerprint snapshot of the parent requirement.
133 pub fingerprint: String,
134}
135
136impl Requirement {
137 /// Construct a new [`Requirement`] from a human-readable ID, title, and
138 /// body.
139 ///
140 /// A new UUID is automatically generated.
141 #[must_use]
142 pub fn new(hrid: Hrid, title: String, body: String) -> Self {
143 Self::new_with_uuid(hrid, title, body, Uuid::new_v4())
144 }
145
146 pub(crate) fn new_with_uuid(hrid: Hrid, title: String, body: String, uuid: Uuid) -> Self {
147 let content = Content {
148 title,
149 body,
150 tags: BTreeSet::default(),
151 };
152
153 let metadata = Metadata {
154 uuid,
155 hrid,
156 created: Utc::now(),
157 parents: HashMap::new(),
158 };
159
160 Self { content, metadata }
161 }
162
163 /// The title of the requirement.
164 #[must_use]
165 pub fn title(&self) -> &str {
166 &self.content.title
167 }
168
169 /// The body of the requirement.
170 ///
171 /// This is the markdown content after the heading.
172 #[must_use]
173 pub fn body(&self) -> &str {
174 &self.content.body
175 }
176
177 /// The tags on the requirement
178 #[must_use]
179 pub const fn tags(&self) -> &BTreeSet<String> {
180 &self.content.tags
181 }
182
183 /// Set the tags on the requirement.
184 ///
185 /// this replaces any existing tags.
186 pub fn set_tags(&mut self, tags: BTreeSet<String>) {
187 self.content.tags = tags;
188 }
189
190 /// Add a tag to the requirement.
191 ///
192 /// returns 'true' if a new tag was inserted, or 'false' if it was already
193 /// present.
194 pub fn add_tag(&mut self, tag: String) -> bool {
195 self.content.tags.insert(tag)
196 }
197
198 /// The human-readable identifier for this requirement.
199 ///
200 /// In normal usage these should be stable
201 #[must_use]
202 pub const fn hrid(&self) -> &Hrid {
203 &self.metadata.hrid
204 }
205
206 /// The unique, stable identifier of this requirement
207 #[must_use]
208 pub const fn uuid(&self) -> Uuid {
209 self.metadata.uuid
210 }
211
212 /// When the requirement was first created
213 #[must_use]
214 pub const fn created(&self) -> DateTime<Utc> {
215 self.metadata.created
216 }
217
218 /// Returns a value generated by hashing the content of the Requirement.
219 ///
220 /// Any change to the requirement will change the fingerprint. This is used
221 /// to determine when links are 'suspect'. Meaning that because a
222 /// requirement has been modified, related or dependent requirements
223 /// also need to be reviewed to ensure consistency.
224 #[must_use]
225 pub fn fingerprint(&self) -> String {
226 self.content.fingerprint()
227 }
228
229 /// Add a parent to the requirement, keyed by UUID.
230 pub fn add_parent(&mut self, parent_id: Uuid, parent_info: Parent) -> Option<Parent> {
231 self.metadata.parents.insert(parent_id, parent_info)
232 }
233
234 /// Return an iterator over the requirement's 'parents'
235 pub fn parents(&self) -> impl Iterator<Item = (Uuid, &Parent)> {
236 self.metadata
237 .parents
238 .iter()
239 .map(|(&id, parent)| (id, parent))
240 }
241
242 /// Return a mutable iterator over the requirement's 'parents'
243 pub fn parents_mut(&mut self) -> impl Iterator<Item = (Uuid, &mut Parent)> {
244 self.metadata
245 .parents
246 .iter_mut()
247 .map(|(&id, parent)| (id, parent))
248 }
249
250 /// Reads a requirement using the given configuration.
251 ///
252 /// The path construction respects the `subfolders_are_namespaces` setting:
253 /// - If `false`: loads from `root/FULL-HRID.md`
254 /// - If `true`: loads from `root/namespace/folders/KIND-ID.md`
255 ///
256 /// # Errors
257 ///
258 /// Returns an error if the file does not exist, cannot be read from, or has
259 /// malformed YAML frontmatter.
260 pub fn load(
261 root: &Path,
262 hrid: &Hrid,
263 config: &crate::domain::Config,
264 ) -> Result<Self, LoadError> {
265 Ok(MarkdownRequirement::load(root, hrid, config)?.try_into()?)
266 }
267
268 /// Writes the requirement using the given configuration.
269 ///
270 /// The path construction respects the `subfolders_are_namespaces` setting:
271 /// - If `false`: file is saved as `root/FULL-HRID.md`
272 /// - If `true`: file is saved as `root/namespace/folders/KIND-ID.md`
273 ///
274 /// Parent directories are created automatically if they don't exist.
275 ///
276 /// # Errors
277 ///
278 /// This method returns an error if the path cannot be written to.
279 pub fn save(&self, root: &Path, config: &crate::domain::Config) -> io::Result<()> {
280 MarkdownRequirement::from(self.clone()).save(root, config)
281 }
282
283 /// Save this requirement to a specific file path.
284 ///
285 /// # Errors
286 ///
287 /// Returns an error if the file cannot be written.
288 pub fn save_to_path(&self, path: &Path) -> io::Result<()> {
289 MarkdownRequirement::from(self.clone()).save_to_path(path)
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use std::collections::BTreeSet;
296
297 use super::Content;
298
299 #[test]
300 fn fingerprint_does_not_panic() {
301 let content = Content {
302 title: "Title".to_string(),
303 body: "Some string".to_string(),
304 tags: ["tag1".to_string(), "tag2".to_string()].into(),
305 };
306 content.fingerprint();
307 }
308
309 #[test]
310 fn fingerprint_is_stable_with_tag_order() {
311 let content1 = Content {
312 title: "Title".to_string(),
313 body: "Some string".to_string(),
314 tags: ["tag1".to_string(), "tag2".to_string()].into(),
315 };
316 let content2 = Content {
317 title: "Title".to_string(),
318 body: "Some string".to_string(),
319 tags: ["tag2".to_string(), "tag1".to_string()].into(),
320 };
321 assert_eq!(content1.fingerprint(), content2.fingerprint());
322 }
323
324 #[test]
325 fn tags_affect_fingerprint() {
326 let content1 = Content {
327 title: "Title".to_string(),
328 body: "Some string".to_string(),
329 tags: ["tag1".to_string()].into(),
330 };
331 let content2 = Content {
332 title: "Title".to_string(),
333 body: "Some string".to_string(),
334 tags: ["tag1".to_string(), "tag2".to_string()].into(),
335 };
336 assert_ne!(content1.fingerprint(), content2.fingerprint());
337 }
338
339 #[test]
340 fn body_affects_fingerprint() {
341 let content1 = Content {
342 title: "Title".to_string(),
343 body: "Some string".to_string(),
344 tags: BTreeSet::default(),
345 };
346 let content2 = Content {
347 title: "Title".to_string(),
348 body: "Other string".to_string(),
349 tags: BTreeSet::default(),
350 };
351 assert_ne!(content1.fingerprint(), content2.fingerprint());
352 }
353
354 #[test]
355 fn title_does_not_affect_fingerprint() {
356 let content1 = Content {
357 title: "Title One".to_string(),
358 body: "Some string".to_string(),
359 tags: BTreeSet::default(),
360 };
361 let content2 = Content {
362 title: "Title Two".to_string(),
363 body: "Some string".to_string(),
364 tags: BTreeSet::default(),
365 };
366 // Title changes should NOT affect fingerprint
367 assert_eq!(content1.fingerprint(), content2.fingerprint());
368 }
369}