requiem/domain/
requirement.rs

1use 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/// A requirement is a document used to describe a system.
19///
20/// It can represent a user requirement, a specification, etc.
21/// Requirements can have dependencies between them, such that one requirement
22/// satisfies, fulfils, verifies (etc.) another requirement.
23#[derive(Debug, Clone, PartialEq)]
24pub struct Requirement {
25    content: Content,
26    metadata: Metadata,
27}
28
29/// The semantically important content of the requirement.
30///
31/// This contributes to the 'fingerprint' of the requirement
32#[derive(Debug, BorshSerialize, Clone, PartialEq)]
33struct Content {
34    content: String,
35    tags: BTreeSet<String>,
36}
37
38impl Content {
39    fn fingerprint(&self) -> String {
40        // encode using [borsh](https://borsh.io/)
41        let encoded = borsh::to_vec(self).expect("this should never fail");
42
43        // generate a SHA256 hash
44        let hash = Sha256::digest(encoded);
45
46        // Convert to hex string
47        format!("{hash:x}")
48    }
49}
50
51/// Requirement metadata.
52///
53/// Does not contribute to the requirement fingerprint.
54#[derive(Debug, Clone, PartialEq)]
55struct Metadata {
56    /// Globally unique, perpetually stable identifier
57    uuid: Uuid,
58
59    /// Globally unique, human readable identifier.
60    ///
61    /// This should in general change, however it is possible to
62    /// change it if needed.
63    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    /// Construct a new [`Requirement`] from a human-readable ID and its content.
76    ///
77    /// A new UUID is automatically generated.
78    #[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    /// The body of the requirement.
100    ///
101    /// This should be a markdown document.
102    #[must_use]
103    pub fn content(&self) -> &str {
104        &self.content.content
105    }
106
107    /// The tags on the requirement
108    #[must_use]
109    pub const fn tags(&self) -> &BTreeSet<String> {
110        &self.content.tags
111    }
112
113    /// Set the tags on the requirement.
114    ///
115    /// this replaces any existing tags.
116    pub fn set_tags(&mut self, tags: BTreeSet<String>) {
117        self.content.tags = tags;
118    }
119
120    /// Add a tag to the requirement.
121    ///
122    /// returns 'true' if a new tag was inserted, or 'false' if it was already present.
123    pub fn add_tag(&mut self, tag: String) -> bool {
124        self.content.tags.insert(tag)
125    }
126
127    /// The human-readable identifier for this requirement.
128    ///
129    /// In normal usage these should be stable
130    #[must_use]
131    pub const fn hrid(&self) -> &Hrid {
132        &self.metadata.hrid
133    }
134
135    /// The unique, stable identifier of this requirement
136    #[must_use]
137    pub const fn uuid(&self) -> Uuid {
138        self.metadata.uuid
139    }
140
141    /// When the requirement was first created
142    #[must_use]
143    pub const fn created(&self) -> DateTime<Utc> {
144        self.metadata.created
145    }
146
147    /// Returns a value generated by hashing the content of the Requirement.
148    ///
149    /// Any change to the requirement will change the fingerprint. This is used
150    /// to determine when links are 'suspect'. Meaning that because a requirement
151    /// has been modified, related or dependent requirements also need to be reviewed
152    /// to ensure consistency.
153    #[must_use]
154    pub fn fingerprint(&self) -> String {
155        self.content.fingerprint()
156    }
157
158    /// Add a parent to the requirement, keyed by UUID.
159    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    /// Return an iterator over the requirement's 'parents'
164    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    /// Return a mutable iterator over the requirement's 'parents'
172    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    /// Reads a requirement from the given file path.
180    ///
181    /// Note the path here is the path to the directory. The filename is determined by the HRID
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the file does not exist, cannot be read from, or has malformed YAML frontmatter.
186    pub fn load(path: &Path, hrid: String) -> Result<Self, LoadError> {
187        Ok(MarkdownRequirement::load(path, hrid)?.try_into()?)
188    }
189
190    /// Writes the requirement to the given file path.
191    /// Creates the file if it doesn't exist, or overwrites it if it does.
192    ///
193    /// Note the path here is the path to the directory. The filename is determined by the HRID.
194    ///
195    /// # Errors
196    ///
197    /// This method returns an error if the path cannot be written to.
198    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}