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}