1use std::{
8 collections::{HashMap, HashSet},
9 ffi::OsStr,
10 fmt, io,
11 path::{Path, PathBuf},
12};
13
14use nonempty::NonEmpty;
15use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
16use uuid::Uuid;
17use walkdir::WalkDir;
18
19use crate::{
20 domain::{hrid::KindString, requirement::LoadError, Config, Hrid, RequirementView, Tree},
21 storage::markdown::trim_empty_lines,
22 Requirement,
23};
24
25pub struct Directory {
27 root: PathBuf,
29 tree: Tree,
30 config: Config,
31 dirty: HashSet<Uuid>,
32 paths: HashMap<Uuid, PathBuf>,
35}
36
37impl Directory {
38 fn mark_dirty(&mut self, uuid: Uuid) {
40 self.dirty.insert(uuid);
41 }
42
43 pub fn link_requirement(
53 &mut self,
54 child: &Hrid,
55 parent: &Hrid,
56 ) -> Result<RequirementView<'_>, LoadError> {
57 let outcome = self.tree.link_requirement(child, parent)?;
58 self.mark_dirty(outcome.child_uuid);
59
60 if !outcome.already_linked {
61 tracing::info!("Linked {} ← {}", outcome.child_hrid, outcome.parent_hrid);
62 }
63
64 self.tree
65 .requirement(outcome.child_uuid)
66 .ok_or(LoadError::NotFound)
67 }
68
69 pub fn new(root: PathBuf) -> Result<Self, DirectoryLoadError> {
76 let config = load_config(&root);
77 let md_paths = collect_markdown_paths(&root);
78
79 let (requirements, unrecognised_paths): (Vec<_>, Vec<_>) = md_paths
80 .par_iter()
81 .map(|path| try_load_requirement(path, &root, &config))
82 .partition(Result::is_ok);
83
84 let requirements: Vec<(Requirement, PathBuf)> =
85 requirements.into_iter().map(Result::unwrap).collect();
86 let unrecognised_paths: Vec<_> = unrecognised_paths
87 .into_iter()
88 .map(Result::unwrap_err)
89 .collect();
90
91 if !config.allow_unrecognised && !unrecognised_paths.is_empty() {
92 return Err(DirectoryLoadError::UnrecognisedFiles(unrecognised_paths));
93 }
94
95 let mut tree = Tree::with_capacity(requirements.len());
96 let mut paths = HashMap::with_capacity(requirements.len());
97 for (req, path) in requirements {
98 let uuid = req.uuid();
99 tree.insert(req);
100 paths.insert(uuid, path);
101 }
102
103 Ok(Self {
108 root,
109 tree,
110 config,
111 dirty: HashSet::new(),
112 paths,
113 })
114 }
115}
116
117#[derive(Debug, thiserror::Error)]
119pub enum DirectoryLoadError {
120 UnrecognisedFiles(Vec<PathBuf>),
123}
124
125impl fmt::Display for DirectoryLoadError {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 match self {
128 Self::UnrecognisedFiles(paths) => {
129 write!(f, "Unrecognised files: ")?;
130 for (i, path) in paths.iter().enumerate() {
131 if i > 0 {
132 write!(f, ", ")?;
133 }
134 write!(f, "{}", path.display())?;
135 }
136 Ok(())
137 }
138 }
139 }
140}
141
142fn load_config(root: &Path) -> Config {
143 let path = root.join("config.toml");
144 Config::load(&path).unwrap_or_else(|e| {
145 tracing::debug!("Failed to load config: {e}");
146 Config::default()
147 })
148}
149
150fn load_template(root: &Path, hrid: &Hrid) -> String {
158 let templates_dir = root.join(".req").join("templates");
159
160 let full_prefix = hrid.prefix();
162 let full_path = templates_dir.join(format!("{full_prefix}.md"));
163
164 if full_path.exists() {
165 if let Ok(content) = std::fs::read_to_string(&full_path) {
166 tracing::debug!("Loaded template from {}", full_path.display());
167 return content;
168 }
169 }
170
171 let kind = hrid.kind();
173 let kind_path = templates_dir.join(format!("{kind}.md"));
174
175 if kind_path.exists() {
176 if let Ok(content) = std::fs::read_to_string(&kind_path) {
177 tracing::debug!("Loaded template from {}", kind_path.display());
178 return content;
179 }
180 }
181
182 tracing::debug!(
183 "No template found for HRID {}, checked {} and {}",
184 hrid,
185 full_path.display(),
186 kind_path.display()
187 );
188 String::new()
189}
190
191fn collect_markdown_paths(root: &PathBuf) -> Vec<PathBuf> {
192 WalkDir::new(root)
193 .into_iter()
194 .filter_map(Result::ok)
195 .filter(|entry| {
196 !entry.path().components().any(|c| c.as_os_str() == ".req")
198 })
199 .filter(|entry| entry.path().extension() == Some(OsStr::new("md")))
200 .map(walkdir::DirEntry::into_path)
201 .collect()
202}
203
204fn try_load_requirement(
205 path: &Path,
206 _root: &Path,
207 config: &Config,
208) -> Result<(Requirement, PathBuf), PathBuf> {
209 match load_requirement_from_file(path, config) {
212 Ok(req) => Ok((req, path.to_path_buf())),
213 Err(e) => {
214 tracing::debug!(
215 "Failed to load requirement from {}: {:?}",
216 path.display(),
217 e
218 );
219 Err(path.to_path_buf())
220 }
221 }
222}
223
224fn load_requirement_from_file(path: &Path, _config: &Config) -> Result<Requirement, LoadError> {
225 use std::{fs::File, io::BufReader};
228
229 use crate::storage::markdown::MarkdownRequirement;
230
231 let file = File::open(path).map_err(|io_error| match io_error.kind() {
232 std::io::ErrorKind::NotFound => LoadError::NotFound,
233 _ => LoadError::Io(io_error),
234 })?;
235
236 let mut reader = BufReader::new(file);
237 let md_req = MarkdownRequirement::read(&mut reader)?;
238 Ok(md_req.try_into()?)
239}
240
241impl Directory {
242 #[must_use]
244 pub fn root(&self) -> &Path {
245 &self.root
246 }
247
248 #[must_use]
251 pub fn path_for(&self, hrid: &Hrid) -> PathBuf {
252 crate::storage::construct_path_from_hrid(
253 &self.root,
254 hrid,
255 self.config.subfolders_are_namespaces,
256 self.config.digits(),
257 )
258 }
259
260 pub fn requirements(&'_ self) -> impl Iterator<Item = RequirementView<'_>> + '_ {
262 self.tree.iter()
263 }
264
265 #[must_use]
267 pub const fn config(&self) -> &Config {
268 &self.config
269 }
270
271 #[must_use]
273 pub fn requirement_by_hrid(&self, hrid: &Hrid) -> Option<Requirement> {
274 self.tree
275 .find_by_hrid(hrid)
276 .map(|view| view.to_requirement())
277 }
278
279 pub fn add_requirement(
292 &mut self,
293 kind: &str,
294 content: String,
295 ) -> Result<Requirement, AddRequirementError> {
296 let tree = &mut self.tree;
297
298 let kind_string =
300 KindString::new(kind.to_string()).map_err(crate::domain::hrid::Error::from)?;
301
302 let id = tree.next_index(&kind_string);
303 let hrid = Hrid::new(kind_string, id);
304
305 let (title, body) = if content.is_empty() {
308 let template_content = load_template(&self.root, &hrid);
310 (String::new(), template_content)
311 } else {
312 if let Some(first_line_end) = content.find('\n') {
314 let first_line = &content[..first_line_end];
315 if first_line.trim_start().starts_with('#') {
316 let after_hashes = first_line.trim_start_matches('#').trim();
318 let title = after_hashes.to_string();
319 let body = content[first_line_end + 1..].to_string();
321 let body = trim_empty_lines(&body);
323 (title, body)
324 } else {
325 (String::new(), content)
327 }
328 } else {
329 let trimmed = content.trim();
331 if trimmed.starts_with('#') {
332 let after_hashes = trimmed.trim_start_matches('#').trim();
333 (after_hashes.to_string(), String::new())
334 } else {
335 (String::new(), content)
336 }
337 }
338 };
339
340 let requirement = Requirement::new(hrid, title, body);
341
342 tree.insert(requirement.clone());
343 self.mark_dirty(requirement.uuid());
344
345 tracing::info!("Added requirement: {}", requirement.hrid());
346
347 Ok(requirement)
348 }
349
350 pub fn update_hrids(&mut self) -> Vec<Hrid> {
361 let updated: Vec<_> = self.tree.update_hrids().collect();
362
363 for &uuid in &updated {
364 self.mark_dirty(uuid);
365 }
366
367 updated
370 .into_iter()
371 .filter_map(|uuid| self.tree.hrid(uuid))
372 .cloned()
373 .collect()
374 }
375
376 #[must_use]
381 pub fn suspect_links(&self) -> Vec<crate::domain::SuspectLink> {
382 self.tree.suspect_links()
383 }
384
385 pub fn accept_suspect_link(
394 &mut self,
395 child: Hrid,
396 parent: Hrid,
397 ) -> Result<AcceptResult, AcceptSuspectLinkError> {
398 let (child_uuid, child_hrid) = match self.tree.find_by_hrid(&child) {
399 Some(view) => (*view.uuid, view.hrid.clone()),
400 None => return Err(AcceptSuspectLinkError::ChildNotFound(child)),
401 };
402
403 let (parent_uuid, parent_hrid) = match self.tree.find_by_hrid(&parent) {
404 Some(view) => (*view.uuid, view.hrid.clone()),
405 None => return Err(AcceptSuspectLinkError::ParentNotFound(LoadError::NotFound)),
406 };
407
408 let has_link = self
409 .tree
410 .parents(child_uuid)
411 .into_iter()
412 .any(|(uuid, _)| uuid == parent_uuid);
413
414 if !has_link {
415 return Err(AcceptSuspectLinkError::LinkNotFound { child, parent });
416 }
417
418 let was_updated = self.tree.accept_suspect_link(child_uuid, parent_uuid);
419
420 if !was_updated {
421 return Ok(AcceptResult::AlreadyUpToDate);
422 }
423
424 self.mark_dirty(child_uuid);
425 tracing::info!("Accepted suspect link {} ← {}", child_hrid, parent_hrid);
426
427 Ok(AcceptResult::Updated)
428 }
429
430 pub fn accept_all_suspect_links(&mut self) -> Vec<(Hrid, Hrid)> {
438 let updated = self.tree.accept_all_suspect_links();
439
440 let mut collected = Vec::new();
441 for &(child_uuid, parent_uuid) in &updated {
442 if let (Some(child), Some(parent)) = (
443 self.tree.requirement(child_uuid),
444 self.tree.requirement(parent_uuid),
445 ) {
446 collected.push((child_uuid, child.hrid.clone(), parent.hrid.clone()));
447 }
448 }
449
450 for (child_uuid, _, _) in &collected {
451 self.mark_dirty(*child_uuid);
452 }
453
454 collected
455 .into_iter()
456 .map(|(_, child_hrid, parent_hrid)| (child_hrid, parent_hrid))
457 .collect()
458 }
459
460 pub fn flush(&mut self) -> Result<Vec<Hrid>, FlushError> {
469 use crate::storage::path_parser::construct_path_from_hrid;
470
471 let dirty: Vec<_> = self.dirty.iter().copied().collect();
472 let mut flushed = Vec::new();
473 let mut failures = Vec::new();
474
475 for uuid in dirty {
476 let Some(requirement) = self.tree.get_requirement(uuid) else {
477 self.dirty.remove(&uuid);
479 continue;
480 };
481
482 let hrid = requirement.hrid().clone();
483
484 let path = self.paths.get(&uuid).map_or_else(
486 || {
487 construct_path_from_hrid(
488 &self.root,
489 &hrid,
490 self.config.subfolders_are_namespaces,
491 self.config.digits(),
492 )
493 },
494 PathBuf::clone,
495 );
496
497 match requirement.save_to_path(&path) {
498 Ok(()) => {
499 self.dirty.remove(&uuid);
500 flushed.push(hrid);
501 }
502 Err(err) => {
503 failures.push((path, err));
504 }
505 }
506 }
507
508 if let Some(failures) = NonEmpty::from_vec(failures) {
509 return Err(FlushError { failures });
510 }
511
512 Ok(flushed)
513 }
514}
515
516#[derive(Debug, thiserror::Error)]
518pub enum AddRequirementError {
519 #[error("failed to add requirement: {0}")]
521 Hrid(#[from] crate::domain::HridError),
522}
523
524#[derive(Debug, thiserror::Error)]
526pub struct FlushError {
527 failures: NonEmpty<(PathBuf, io::Error)>,
528}
529
530impl fmt::Display for FlushError {
531 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532 const MAX_DISPLAY: usize = 5;
533
534 write!(f, "failed to flush requirements: ")?;
535
536 let total = self.failures.len();
537
538 let displayed_paths: Vec<String> = self
539 .failures
540 .iter()
541 .take(MAX_DISPLAY)
542 .map(|(p, _e)| p.display().to_string())
543 .collect();
544
545 let msg = displayed_paths.join(", ");
546
547 if total <= MAX_DISPLAY {
548 write!(f, "{msg}")
549 } else {
550 write!(f, "{msg}... (and {} more)", total - MAX_DISPLAY)
551 }
552 }
553}
554
555#[derive(Debug)]
557pub enum AcceptResult {
558 Updated,
560 AlreadyUpToDate,
562}
563
564#[derive(Debug, thiserror::Error)]
566pub enum AcceptSuspectLinkError {
567 #[error("child requirement {0} not found")]
569 ChildNotFound(Hrid),
570 #[error("parent requirement not found")]
572 ParentNotFound(#[from] LoadError),
573 #[error("link from {child} to {parent} not found")]
575 LinkNotFound {
576 child: Hrid,
578 parent: Hrid,
580 },
581}
582
583#[cfg(test)]
584mod tests {
585 use tempfile::TempDir;
586
587 use super::*;
588 use crate::{domain::requirement::Parent, Requirement};
589
590 fn setup_temp_directory() -> (TempDir, Directory) {
591 let tmp = TempDir::new().expect("failed to create temp dir");
592 let path = tmp.path().to_path_buf();
593 (tmp, Directory::new(path).unwrap())
594 }
595
596 #[test]
597 fn can_add_requirement() {
598 let (_tmp, mut dir) = setup_temp_directory();
599 let r1 = dir.add_requirement("REQ", String::new()).unwrap();
600
601 dir.flush().expect("flush should succeed");
602
603 assert_eq!(r1.hrid().to_string(), "REQ-001");
604
605 let loaded = Requirement::load(&dir.root, r1.hrid(), &dir.config)
606 .expect("should load saved requirement");
607 assert_eq!(loaded.uuid(), r1.uuid());
608 }
609
610 #[test]
611 fn can_add_multiple_requirements_with_incrementing_id() {
612 let (_tmp, mut dir) = setup_temp_directory();
613 let r1 = dir.add_requirement("REQ", String::new()).unwrap();
614 let r2 = dir.add_requirement("REQ", String::new()).unwrap();
615
616 dir.flush().expect("flush should succeed");
617
618 assert_eq!(r1.hrid().to_string(), "REQ-001");
619 assert_eq!(r2.hrid().to_string(), "REQ-002");
620 }
621
622 #[test]
623 fn can_link_two_requirements() {
624 let (_tmp, mut dir) = setup_temp_directory();
625 let parent = dir.add_requirement("SYS", String::new()).unwrap();
626 let child = dir.add_requirement("USR", String::new()).unwrap();
627 dir.flush().expect("flush should succeed");
628
629 let mut reloaded = Directory::new(dir.root.clone()).unwrap();
630 reloaded
631 .link_requirement(child.hrid(), parent.hrid())
632 .unwrap();
633 reloaded.flush().unwrap();
634
635 let config = load_config(&dir.root);
636 let updated =
637 Requirement::load(&dir.root, child.hrid(), &config).expect("should load child");
638
639 let parents: Vec<_> = updated.parents().collect();
640 assert_eq!(parents.len(), 1);
641 assert_eq!(parents[0].0, parent.uuid());
642 assert_eq!(&parents[0].1.hrid, parent.hrid());
643 }
644
645 #[test]
646 fn update_hrids_corrects_outdated_parent_hrids() {
647 let (_tmp, mut dir) = setup_temp_directory();
648 let parent = dir.add_requirement("P", String::new()).unwrap();
649 let mut child = dir.add_requirement("C", String::new()).unwrap();
650
651 dir.flush().expect("flush should succeed");
652
653 child.add_parent(
655 parent.uuid(),
656 Parent {
657 hrid: Hrid::try_from("WRONG-999").unwrap(),
658 fingerprint: parent.fingerprint(),
659 },
660 );
661 child.save(&dir.root, &dir.config).unwrap();
662
663 let mut loaded_dir = Directory::new(dir.root.clone()).unwrap();
664 loaded_dir.update_hrids();
665 loaded_dir.flush().unwrap();
666
667 let updated = Requirement::load(&loaded_dir.root, child.hrid(), &loaded_dir.config)
668 .expect("should load updated child");
669 let (_, parent_ref) = updated.parents().next().unwrap();
670
671 assert_eq!(&parent_ref.hrid, parent.hrid());
672 }
673
674 #[test]
675 fn load_all_reads_all_saved_requirements() {
676 use std::str::FromStr;
677 let (_tmp, mut dir) = setup_temp_directory();
678 let r1 = dir.add_requirement("X", String::new()).unwrap();
679 let r2 = dir.add_requirement("X", String::new()).unwrap();
680
681 dir.flush().expect("flush should succeed");
682
683 let loaded = Directory::new(dir.root.clone()).unwrap();
684
685 let mut found = 0;
686 for i in 1..=2 {
687 let hrid = Hrid::from_str(&format!("X-00{i}")).unwrap();
688 let req = Requirement::load(&loaded.root, &hrid, &loaded.config).unwrap();
689 if req.uuid() == r1.uuid() || req.uuid() == r2.uuid() {
690 found += 1;
691 }
692 }
693
694 assert_eq!(found, 2);
695 }
696
697 #[test]
698 fn path_based_mode_kind_in_filename() {
699 let tmp = TempDir::new().expect("failed to create temp dir");
700 let root = tmp.path();
701
702 std::fs::write(
704 root.join("config.toml"),
705 "_version = \"1\"\nsubfolders_are_namespaces = true\n",
706 )
707 .unwrap();
708
709 std::fs::create_dir_all(root.join("system/auth")).unwrap();
711
712 std::fs::create_dir_all(root.join("SYSTEM/AUTH")).unwrap();
714
715 std::fs::write(
716 root.join("SYSTEM/AUTH/REQ-001.md"),
717 r"---
718_version: '1'
719uuid: 12345678-1234-1234-1234-123456789012
720created: 2025-01-01T00:00:00Z
721---
722# SYSTEM-AUTH-REQ-001 Test requirement
723",
724 )
725 .unwrap();
726
727 let dir = Directory::new(root.to_path_buf()).unwrap();
729
730 let hrid = Hrid::try_from("SYSTEM-AUTH-REQ-001").unwrap();
732 let req = Requirement::load(root, &hrid, &dir.config).unwrap();
733 assert_eq!(req.hrid(), &hrid);
734 }
735
736 #[test]
737 fn path_based_mode_kind_in_parent_folder() {
738 let tmp = TempDir::new().expect("failed to create temp dir");
739 let root = tmp.path();
740
741 std::fs::write(
743 root.join("config.toml"),
744 "_version = \"1\"\nsubfolders_are_namespaces = true\n",
745 )
746 .unwrap();
747
748 std::fs::create_dir_all(root.join("SYSTEM/AUTH/USR")).unwrap();
750
751 std::fs::write(
753 root.join("SYSTEM/AUTH/USR/001.md"),
754 r"---
755_version: '1'
756uuid: 12345678-1234-1234-1234-123456789013
757created: 2025-01-01T00:00:00Z
758---
759# SYSTEM-AUTH-USR-001 Test requirement
760",
761 )
762 .unwrap();
763
764 let _dir = Directory::new(root.to_path_buf()).unwrap();
766
767 let hrid = Hrid::try_from("SYSTEM-AUTH-USR-001").unwrap();
769 let loaded_path = root.join("SYSTEM/AUTH/USR/001.md");
772 assert!(loaded_path.exists());
773
774 {
776 use std::{fs::File, io::BufReader};
777
778 use crate::storage::markdown::MarkdownRequirement;
779 let file = File::open(&loaded_path).unwrap();
780 let mut reader = BufReader::new(file);
781 let md_req = MarkdownRequirement::read(&mut reader).unwrap();
782 let req: Requirement = md_req.try_into().unwrap();
783 assert_eq!(req.hrid(), &hrid);
784 }
785 }
786
787 #[test]
788 fn path_based_mode_saves_in_subdirectories() {
789 use std::num::NonZeroUsize;
790
791 use crate::domain::hrid::KindString;
792
793 let tmp = TempDir::new().expect("failed to create temp dir");
794 let root = tmp.path();
795
796 std::fs::write(
798 root.join("config.toml"),
799 "_version = \"1\"\nsubfolders_are_namespaces = true\n",
800 )
801 .unwrap();
802
803 let dir = Directory::new(root.to_path_buf()).unwrap();
805
806 let hrid = Hrid::new_with_namespace(
808 vec![
809 KindString::new("SYSTEM".to_string()).unwrap(),
810 KindString::new("AUTH".to_string()).unwrap(),
811 ],
812 KindString::new("REQ".to_string()).unwrap(),
813 NonZeroUsize::new(1).unwrap(),
814 );
815 let req = Requirement::new(
816 hrid.clone(),
817 "Test Title".to_string(),
818 "Test content".to_string(),
819 );
820
821 req.save(root, &dir.config).unwrap();
823
824 assert!(root.join("SYSTEM/AUTH/REQ-001.md").exists());
826
827 let loaded = Requirement::load(root, &hrid, &dir.config).unwrap();
829 assert_eq!(loaded.hrid(), &hrid);
830 }
831
832 #[test]
833 fn filename_based_mode_ignores_folder_structure() {
834 let tmp = TempDir::new().expect("failed to create temp dir");
835 let root = tmp.path();
836
837 std::fs::write(root.join("config.toml"), "_version = \"1\"\n").unwrap();
839
840 std::fs::create_dir_all(root.join("some/random/path")).unwrap();
842
843 std::fs::write(
845 root.join("some/random/path/system-auth-REQ-001.md"),
846 r"---
847_version: '1'
848uuid: 12345678-1234-1234-1234-123456789014
849created: 2025-01-01T00:00:00Z
850---
851# SYSTEM-AUTH-REQ-001 Test requirement
852",
853 )
854 .unwrap();
855
856 let _dir = Directory::new(root.to_path_buf()).unwrap();
858
859 let hrid = Hrid::try_from("SYSTEM-AUTH-REQ-001").unwrap();
862 let loaded_path = root.join("some/random/path/system-auth-REQ-001.md");
865 assert!(loaded_path.exists());
866
867 {
869 use std::{fs::File, io::BufReader};
870
871 use crate::storage::markdown::MarkdownRequirement;
872 let file = File::open(&loaded_path).unwrap();
873 let mut reader = BufReader::new(file);
874 let md_req = MarkdownRequirement::read(&mut reader).unwrap();
875 let req: Requirement = md_req.try_into().unwrap();
876 assert_eq!(req.hrid(), &hrid);
877 }
878 }
879
880 #[test]
881 fn filename_based_mode_saves_in_root() {
882 use std::num::NonZeroUsize;
883
884 use crate::domain::hrid::KindString;
885
886 let tmp = TempDir::new().expect("failed to create temp dir");
887 let root = tmp.path();
888
889 std::fs::write(root.join("config.toml"), "_version = \"1\"\n").unwrap();
891
892 let dir = Directory::new(root.to_path_buf()).unwrap();
894
895 let hrid = Hrid::new_with_namespace(
897 vec![
898 KindString::new("SYSTEM".to_string()).unwrap(),
899 KindString::new("AUTH".to_string()).unwrap(),
900 ],
901 KindString::new("REQ".to_string()).unwrap(),
902 NonZeroUsize::new(1).unwrap(),
903 );
904 let req = Requirement::new(hrid, "Test Title".to_string(), "Test content".to_string());
905
906 req.save(root, &dir.config).unwrap();
908
909 assert!(root.join("SYSTEM-AUTH-REQ-001.md").exists());
911 assert!(!root.join("system/auth/REQ-001.md").exists());
912 }
913}