requiem/storage/
directory.rs

1//! A filesystem backed store of requirements
2//!
3//! The [`Directory`] provides a way to manage requirements stored in a directory structure.
4//! It is a wrapper around the filesystem agnostic [`Tree`].
5
6use std::{ffi::OsStr, path::PathBuf};
7
8use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
9use walkdir::WalkDir;
10
11use crate::{
12    Requirement,
13    domain::{
14        Config, Hrid,
15        requirement::{LoadError, Parent},
16    },
17};
18
19pub use crate::storage::Tree;
20
21#[derive(Debug, PartialEq)]
22pub struct Loaded(Tree);
23
24#[derive(Debug, PartialEq, Eq)]
25pub struct Unloaded;
26
27/// A filesystem backed store of requirements.
28pub struct Directory<S> {
29    /// The root of the directory requirements are stored in.
30    root: PathBuf,
31    state: S,
32}
33
34impl<S> Directory<S> {
35    /// Link two requirements together with a parent-child relationship.
36    pub fn link_requirement(&self, child: String, parent: String) {
37        let mut child = self.load_requirement(child).unwrap().unwrap();
38        let parent = self.load_requirement(parent).unwrap().unwrap();
39
40        child.add_parent(
41            parent.uuid(),
42            Parent {
43                hrid: parent.hrid().clone(),
44                fingerprint: parent.fingerprint(),
45            },
46        );
47
48        child.save(&self.root).unwrap();
49    }
50
51    fn load_requirement(&self, hrid: String) -> Option<Result<Requirement, LoadError>> {
52        match Requirement::load(&self.root, hrid) {
53            Ok(requirement) => Some(Ok(requirement)),
54            Err(LoadError::NotFound) => None,
55            Err(e) => Some(Err(e)),
56        }
57    }
58}
59
60impl Directory<Unloaded> {
61    /// Opens a directory at the given path.
62    #[must_use]
63    pub const fn new(root: PathBuf) -> Self {
64        Self {
65            root,
66            state: Unloaded,
67        }
68    }
69
70    /// Load all requirements from disk
71    pub fn load_all(self) -> Directory<Loaded> {
72        let paths: Vec<_> = WalkDir::new(&self.root)
73            .into_iter()
74            .filter_map(Result::ok)
75            .filter(|entry| entry.path().extension() == Some(OsStr::new("md")))
76            .map(walkdir::DirEntry::into_path)
77            .collect();
78
79        let requirements: Vec<Requirement> = paths
80            .par_iter()
81            .map(|path| {
82                let hrid = path.file_stem().unwrap().to_string_lossy().to_string();
83                let directory = path.parent().unwrap().to_path_buf();
84                Requirement::load(&directory, hrid).unwrap() // TODO: handle error properly
85            })
86            .collect();
87
88        let mut tree = Tree::with_capacity(requirements.len());
89
90        for req in requirements {
91            tree.insert(req);
92        }
93
94        Directory {
95            root: self.root,
96            state: Loaded(tree),
97        }
98    }
99}
100
101impl Directory<Loaded> {
102    /// Add a new requirement to the directory.
103    pub fn add_requirement(&mut self, kind: String) -> Requirement {
104        let config_path = self.root.join("config.toml");
105
106        let tree = &mut self.state.0;
107
108        let _config = Config::load(&config_path).unwrap_or_else(|e| {
109            tracing::debug!("Failed to load config: {e}");
110            Config::default()
111        });
112
113        let id = tree.next_index(&kind);
114
115        let requirement = Requirement::new(Hrid { kind, id }, String::new());
116
117        requirement.save(&self.root).unwrap();
118        tree.insert(requirement.clone());
119
120        tracing::info!("Added requirement: {}", requirement.hrid());
121
122        requirement
123    }
124
125    /// Update the human-readable IDs (HRIDs) of all 'parents' references in the requirements.
126    ///
127    /// These can become out of sync if requirement files are renamed.
128    pub fn update_hrids(&mut self) {
129        let tree = &mut self.state.0;
130        let updated: Vec<_> = tree.update_hrids().collect();
131
132        for id in updated {
133            let requirement = tree
134                .requirement(id)
135                .expect("this just got updated, so we know it exists");
136            requirement.save(&self.root).unwrap();
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::Requirement;
145    use tempfile::TempDir;
146
147    fn setup_temp_directory() -> (TempDir, Directory<Loaded>) {
148        let tmp = TempDir::new().expect("failed to create temp dir");
149        let path = tmp.path().to_path_buf();
150        (tmp, Directory::new(path).load_all())
151    }
152
153    #[test]
154    fn can_add_requirement() {
155        let (_tmp, mut dir) = setup_temp_directory();
156        let r1 = dir.add_requirement("REQ".to_string());
157
158        assert_eq!(r1.hrid().to_string(), "REQ-001");
159
160        let loaded = Requirement::load(&dir.root, r1.hrid().to_string())
161            .expect("should load saved requirement");
162        assert_eq!(loaded.uuid(), r1.uuid());
163    }
164
165    #[test]
166    fn can_add_multiple_requirements_with_incrementing_id() {
167        let (_tmp, mut dir) = setup_temp_directory();
168        let r1 = dir.add_requirement("REQ".to_string());
169        let r2 = dir.add_requirement("REQ".to_string());
170
171        assert_eq!(r1.hrid().to_string(), "REQ-001");
172        assert_eq!(r2.hrid().to_string(), "REQ-002");
173    }
174
175    #[test]
176    fn can_link_two_requirements() {
177        let (_tmp, mut dir) = setup_temp_directory();
178        let parent = dir.add_requirement("SYS".to_string());
179        let child = dir.add_requirement("USR".to_string());
180
181        Directory::new(dir.root.clone())
182            .link_requirement(child.hrid().to_string(), parent.hrid().to_string());
183
184        let updated =
185            Requirement::load(&dir.root, child.hrid().to_string()).expect("should load child");
186
187        let parents: Vec<_> = updated.parents().collect();
188        assert_eq!(parents.len(), 1);
189        assert_eq!(parents[0].0, parent.uuid());
190        assert_eq!(&parents[0].1.hrid, parent.hrid());
191    }
192
193    #[test]
194    fn update_hrids_corrects_outdated_parent_hrids() {
195        let (_tmp, mut dir) = setup_temp_directory();
196        let parent = dir.add_requirement("P".to_string());
197        let mut child = dir.add_requirement("C".to_string());
198
199        // Manually corrupt HRID in child's parent info
200        child.add_parent(
201            parent.uuid(),
202            Parent {
203                hrid: Hrid::try_from("WRONG-999").unwrap(),
204                fingerprint: parent.fingerprint(),
205            },
206        );
207        child.save(&dir.root).unwrap();
208
209        let mut loaded_dir = Directory::new(dir.root.clone()).load_all();
210        loaded_dir.update_hrids();
211
212        let updated = Requirement::load(&loaded_dir.root, child.hrid().to_string())
213            .expect("should load updated child");
214        let (_, parent_ref) = updated.parents().next().unwrap();
215
216        assert_eq!(&parent_ref.hrid, parent.hrid());
217    }
218
219    #[test]
220    fn load_all_reads_all_saved_requirements() {
221        let (_tmp, mut dir) = setup_temp_directory();
222        let r1 = dir.add_requirement("X".to_string());
223        let r2 = dir.add_requirement("X".to_string());
224
225        let loaded = Directory::new(dir.root.clone()).load_all();
226
227        let mut found = 0;
228        for i in 1..=2 {
229            let hrid = format!("X-00{i}");
230            dbg!(&hrid);
231            let req = Requirement::load(&loaded.root, hrid).unwrap();
232            if req.uuid() == r1.uuid() || req.uuid() == r2.uuid() {
233                found += 1;
234            }
235        }
236
237        assert_eq!(found, 2);
238    }
239}