requiem/storage/
directory.rs

1//! A filesystem backed store of requirements
2//!
3//! The [`Directory`] provides a way to manage requirements stored in a
4//! directory structure. It is a wrapper around the filesystem agnostic
5//! [`Tree`].
6
7use 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
25/// A filesystem backed store of requirements.
26pub struct Directory {
27    /// The root of the directory requirements are stored in.
28    root: PathBuf,
29    tree: Tree,
30    config: Config,
31    dirty: HashSet<Uuid>,
32    /// Source paths for requirements that were loaded from disk.
33    /// Used to save requirements back to their original location.
34    paths: HashMap<Uuid, PathBuf>,
35}
36
37impl Directory {
38    /// Mark a requirement as needing to be flushed to disk.
39    fn mark_dirty(&mut self, uuid: Uuid) {
40        self.dirty.insert(uuid);
41    }
42
43    /// Link two requirements together with a parent-child relationship.
44    ///
45    /// # Errors
46    ///
47    /// This method can fail if:
48    ///
49    /// - either the child or parent requirement file cannot be found
50    /// - either the child or parent requirement file cannot be parsed
51    /// - the child requirement file cannot be written to
52    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    /// Opens a directory at the given path.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if unrecognised files are found when
74    /// `allow_unrecognised` is false in the configuration.
75    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        // Note: No need to rebuild edges - DiGraphMap::add_edge() automatically
104        // creates nodes if they don't exist, so edges are created correctly even
105        // when children are inserted before their parents.
106
107        Ok(Self {
108            root,
109            tree,
110            config,
111            dirty: HashSet::new(),
112            paths,
113        })
114    }
115}
116
117/// Error type for directory loading operations.
118#[derive(Debug, thiserror::Error)]
119pub enum DirectoryLoadError {
120    /// One or more files in the directory could not be recognized as valid
121    /// requirements.
122    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
150/// Load a template for the given HRID from the `.req/templates/` directory.
151///
152/// This checks for templates in order of specificity:
153/// 1. Full HRID prefix with namespace (e.g., `.req/templates/AUTH-USR.md`)
154/// 2. KIND only (e.g., `.req/templates/USR.md`)
155///
156/// Returns an empty string if no template is found.
157fn load_template(root: &Path, hrid: &Hrid) -> String {
158    let templates_dir = root.join(".req").join("templates");
159
160    // Try full prefix first (e.g., "AUTH-USR.md")
161    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    // Fall back to KIND only (e.g., "USR.md")
172    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            // Skip the .req directory (used for templates and other metadata)
197            !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    // Load the requirement from the file
210    // The HRID is now read from the frontmatter, not parsed from the path
211    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    // Load directly from the file path we found during directory scanning
226    // The HRID is read from the frontmatter within the file
227    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    /// Returns the filesystem root backing this directory.
243    #[must_use]
244    pub fn root(&self) -> &Path {
245        &self.root
246    }
247
248    /// Returns the filesystem path for a requirement HRID using directory
249    /// configuration.
250    #[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    /// Returns an iterator over all requirements stored in the directory.
261    pub fn requirements(&'_ self) -> impl Iterator<Item = RequirementView<'_>> + '_ {
262        self.tree.iter()
263    }
264
265    /// Returns the configuration used when loading this directory.
266    #[must_use]
267    pub const fn config(&self) -> &Config {
268        &self.config
269    }
270
271    /// Retrieves a requirement by its human-readable identifier.
272    #[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    /// Add a new requirement to the directory.
280    ///
281    /// # Errors
282    ///
283    /// This method can fail if:
284    ///
285    /// - the provided `kind` is an empty string or invalid
286    /// - the requirement file cannot be written to
287    ///
288    /// # Panics
289    ///
290    /// Panics if the tree returns an invalid ID (should never happen).
291    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        // Validate kind (CLI already normalized to uppercase)
299        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        // Parse content to extract title and body
306        // If no content is provided via CLI, check for a template
307        let (title, body) = if content.is_empty() {
308            // Template content - treat as raw body, don't parse
309            let template_content = load_template(&self.root, &hrid);
310            (String::new(), template_content)
311        } else {
312            // User-provided content - parse if it has a heading
313            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                    // Has a heading - extract title and body
317                    let after_hashes = first_line.trim_start_matches('#').trim();
318                    let title = after_hashes.to_string();
319                    // Skip newline after heading but preserve indentation in body
320                    let body = content[first_line_end + 1..].to_string();
321                    // Trim only empty lines from start/end, preserve indentation
322                    let body = trim_empty_lines(&body);
323                    (title, body)
324                } else {
325                    // No heading
326                    (String::new(), content)
327                }
328            } else {
329                // Single line - check if it's a heading
330                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    /// Update the human-readable IDs (HRIDs) of all 'parents' references in the
351    /// requirements.
352    ///
353    /// These can become out of sync if requirement files are renamed.
354    ///
355    /// # Errors
356    ///
357    /// This method returns an error if some of the requirements cannot be saved
358    /// to disk. This method does *not* fail fast. That is, it will attempt
359    /// to save all the requirements before returning the error.
360    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        // Directly access HRIDs from the tree instead of constructing full
368        // RequirementViews
369        updated
370            .into_iter()
371            .filter_map(|uuid| self.tree.hrid(uuid))
372            .cloned()
373            .collect()
374    }
375
376    /// Find all suspect links in the requirement graph.
377    ///
378    /// A link is suspect when the fingerprint stored in a child requirement
379    /// does not match the current fingerprint of the parent requirement.
380    #[must_use]
381    pub fn suspect_links(&self) -> Vec<crate::domain::SuspectLink> {
382        self.tree.suspect_links()
383    }
384
385    /// Accept a specific suspect link by updating its fingerprint.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if:
390    /// - The child or parent requirement doesn't exist
391    /// - The parent link doesn't exist in the child
392    /// - The requirement file cannot be saved
393    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    /// Accept all suspect links by updating all fingerprints.
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if any requirement file cannot be saved.
435    /// This method does not fail fast - it will attempt to save all
436    /// requirements before returning the error.
437    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    /// Persist all dirty requirements to disk.
461    ///
462    /// Returns the HRIDs of the requirements that were written.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error containing the paths that failed to flush alongside the
467    /// underlying IO error.
468    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                // Requirement may have been removed; drop from dirty set.
478                self.dirty.remove(&uuid);
479                continue;
480            };
481
482            let hrid = requirement.hrid().clone();
483
484            // Use the stored path if available, otherwise calculate a canonical path
485            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/// Error type for adding requirements.
517#[derive(Debug, thiserror::Error)]
518pub enum AddRequirementError {
519    /// The requirement kind or ID was invalid.
520    #[error("failed to add requirement: {0}")]
521    Hrid(#[from] crate::domain::HridError),
522}
523
524/// Error type for flush failures.
525#[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/// Result of accepting a suspect link.
556#[derive(Debug)]
557pub enum AcceptResult {
558    /// The fingerprint was updated.
559    Updated,
560    /// The fingerprint was already up to date.
561    AlreadyUpToDate,
562}
563
564/// Error type for accepting suspect links.
565#[derive(Debug, thiserror::Error)]
566pub enum AcceptSuspectLinkError {
567    /// The child requirement was not found.
568    #[error("child requirement {0} not found")]
569    ChildNotFound(Hrid),
570    /// The parent requirement was not found.
571    #[error("parent requirement not found")]
572    ParentNotFound(#[from] LoadError),
573    /// The link between child and parent was not found.
574    #[error("link from {child} to {parent} not found")]
575    LinkNotFound {
576        /// The child requirement HRID.
577        child: Hrid,
578        /// The parent requirement HRID.
579        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        // Manually corrupt HRID in child's parent info
654        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        // Create config with subfolders_are_namespaces = true
703        std::fs::write(
704            root.join("config.toml"),
705            "_version = \"1\"\nsubfolders_are_namespaces = true\n",
706        )
707        .unwrap();
708
709        // Create directory structure
710        std::fs::create_dir_all(root.join("system/auth")).unwrap();
711
712        // Create a requirement file in path-based format
713        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        // Load all requirements
728        let dir = Directory::new(root.to_path_buf()).unwrap();
729
730        // Should be able to load the requirement with the correct HRID using config
731        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        // Create config with subfolders_are_namespaces = true
742        std::fs::write(
743            root.join("config.toml"),
744            "_version = \"1\"\nsubfolders_are_namespaces = true\n",
745        )
746        .unwrap();
747
748        // Create directory structure with KIND as parent folder
749        std::fs::create_dir_all(root.join("SYSTEM/AUTH/USR")).unwrap();
750
751        // Create a requirement file with numeric filename
752        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        // Load all requirements
765        let _dir = Directory::new(root.to_path_buf()).unwrap();
766
767        // Verify the requirement was loaded with correct HRID (KIND from parent folder)
768        let hrid = Hrid::try_from("SYSTEM-AUTH-USR-001").unwrap();
769        // The requirement should have been loaded from system/auth/USR/001.md during
770        // load_all We verify it exists by checking the file was found
771        let loaded_path = root.join("SYSTEM/AUTH/USR/001.md");
772        assert!(loaded_path.exists());
773
774        // Verify the requirement can be read directly from the file
775        {
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        // Create config with subfolders_are_namespaces = true
797        std::fs::write(
798            root.join("config.toml"),
799            "_version = \"1\"\nsubfolders_are_namespaces = true\n",
800        )
801        .unwrap();
802
803        // Load directory
804        let dir = Directory::new(root.to_path_buf()).unwrap();
805
806        // Add a requirement with namespace
807        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        // Save using config
822        req.save(root, &dir.config).unwrap();
823
824        // File should be created at system/auth/REQ-001.md
825        assert!(root.join("SYSTEM/AUTH/REQ-001.md").exists());
826
827        // Should be able to reload it using config
828        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        // Create config with subfolders_are_namespaces = false (default)
838        std::fs::write(root.join("config.toml"), "_version = \"1\"\n").unwrap();
839
840        // Create nested directory structure
841        std::fs::create_dir_all(root.join("some/random/path")).unwrap();
842
843        // Create a requirement with full HRID in filename
844        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        // Load all requirements
857        let _dir = Directory::new(root.to_path_buf()).unwrap();
858
859        // Verify the requirement was loaded with HRID from filename, not path
860        // (The file is in some/random/path/ but the HRID comes from the filename)
861        let hrid = Hrid::try_from("SYSTEM-AUTH-REQ-001").unwrap();
862        // The requirement should have been loaded from the nested path during load_all
863        // We verify it exists by checking it can be found in the directory structure
864        let loaded_path = root.join("some/random/path/system-auth-REQ-001.md");
865        assert!(loaded_path.exists());
866
867        // Verify the requirement can be read directly from the file
868        {
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        // Create default config (filename-based)
890        std::fs::write(root.join("config.toml"), "_version = \"1\"\n").unwrap();
891
892        // Load directory
893        let dir = Directory::new(root.to_path_buf()).unwrap();
894
895        // Add a requirement with namespace
896        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        // Save using config
907        req.save(root, &dir.config).unwrap();
908
909        // File should be created in root with full HRID
910        assert!(root.join("SYSTEM-AUTH-REQ-001.md").exists());
911        assert!(!root.join("system/auth/REQ-001.md").exists());
912    }
913}