requiem/domain/
config.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5/// Configuration for requirements management.
6///
7/// This struct holds settings that control how requirements are managed,
8/// including HRID formatting, directory structure modes, and validation rules.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(from = "Versions", into = "Versions")]
11pub struct Config {
12    /// The kinds of requirements that are allowed.
13    ///
14    /// This is the first component of the HRID.
15    /// For example, 'USR' or 'SYS'.
16    ///
17    /// If this is empty, all kinds are allowed.
18    allowed_kinds: Vec<String>,
19
20    /// The number of digits in the HRID.
21    ///
22    /// Digits are padded to this width with leading zeros.
23    ///
24    /// This is the second component of the HRID.
25    /// For example, '001' (3 digits) or '0001' (4 digits).
26    digits: usize,
27
28    /// Whether to allow the requirements directory to contain markdown files
29    /// with names that are not valid HRIDs
30    pub allow_unrecognised: bool,
31
32    /// Whether to allow markdown files with names that are valid HRIDs that are
33    /// not correctly formatted
34    pub allow_invalid: bool,
35
36    /// Whether subfolder paths contribute to the namespace of requirements.
37    ///
38    /// When `false` (default): The full HRID is encoded in the filename.
39    ///   Example: `system/auth/REQ-001.md` -> HRID is parsed as `REQ-001`
40    ///   Example: `custom/system-auth-REQ-001.md` -> HRID is
41    /// `system-auth-REQ-001`
42    ///
43    /// When `true`: Subfolders encode the namespace, filename contains KIND-ID.
44    ///   Example: `system/auth/REQ-001.md` -> HRID is `system-auth-REQ-001`
45    ///   Example: `system/auth/USR/001.md` -> HRID is `system-auth-USR-001`
46    ///   (The format is inferred: numeric filename means KIND in parent folder)
47    pub subfolders_are_namespaces: bool,
48}
49
50impl Default for Config {
51    fn default() -> Self {
52        Self {
53            allowed_kinds: Vec::new(),
54            digits: default_digits(),
55            allow_unrecognised: false,
56            allow_invalid: false,
57            subfolders_are_namespaces: false,
58        }
59    }
60}
61
62impl Config {
63    /// Loads the configuration from a TOML file at the given path.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the file cannot be read or if the TOML content is
68    /// invalid.
69    pub fn load(path: &Path) -> Result<Self, String> {
70        let content = std::fs::read_to_string(path)
71            .map_err(|e| format!("Failed to read config file: {e}"))?;
72        toml::from_str(&content).map_err(|e| format!("Failed to parse config file: {e}"))
73    }
74
75    /// Saves the configuration to a TOML file at the given path.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the configuration cannot be serialized to TOML or if
80    /// the file cannot be written.
81    pub fn save(&self, path: &Path) -> Result<(), String> {
82        let content =
83            toml::to_string_pretty(self).map_err(|e| format!("Failed to serialize config: {e}"))?;
84        std::fs::write(path, content).map_err(|e| format!("Failed to write config file: {e}"))
85    }
86
87    /// Returns the number of digits for padding HRID IDs.
88    #[must_use]
89    pub const fn digits(&self) -> usize {
90        self.digits
91    }
92
93    /// Returns the allowed kinds, if configured.
94    #[must_use]
95    pub fn allowed_kinds(&self) -> &[String] {
96        &self.allowed_kinds
97    }
98
99    /// Sets the `subfolders_are_namespaces` configuration option.
100    pub const fn set_subfolders_are_namespaces(&mut self, value: bool) {
101        self.subfolders_are_namespaces = value;
102    }
103}
104
105const fn default_digits() -> usize {
106    3
107}
108
109/// The serialized versions of the configuration.
110/// This allows for future changes to the configuration format and to the domain
111/// type without breaking compatibility.
112#[derive(Debug, Serialize, Deserialize)]
113#[serde(tag = "_version")]
114enum Versions {
115    #[serde(rename = "1")]
116    V1 {
117        #[serde(default, skip_serializing_if = "Vec::is_empty")]
118        allowed_kinds: Vec<String>,
119
120        /// The number of digits in the HRID.
121        ///
122        /// Digits are padded to this width with leading zeros.
123        ///
124        /// This is the second component of the HRID.
125        /// For example, '001' (3 digits) or '0001' (4 digits).
126        #[serde(default = "default_digits")]
127        digits: usize,
128
129        #[serde(default)]
130        allow_unrecognised: bool,
131
132        #[serde(default)]
133        allow_invalid: bool,
134
135        #[serde(default)]
136        subfolders_are_namespaces: bool,
137    },
138}
139
140impl From<Versions> for super::Config {
141    fn from(versions: Versions) -> Self {
142        match versions {
143            Versions::V1 {
144                allowed_kinds,
145                digits,
146                allow_unrecognised,
147                allow_invalid,
148                subfolders_are_namespaces,
149            } => Self {
150                allowed_kinds,
151                digits,
152                allow_unrecognised,
153                allow_invalid,
154                subfolders_are_namespaces,
155            },
156        }
157    }
158}
159
160impl From<super::Config> for Versions {
161    fn from(config: super::Config) -> Self {
162        Self::V1 {
163            allowed_kinds: config.allowed_kinds,
164            digits: config.digits,
165            allow_unrecognised: config.allow_unrecognised,
166            allow_invalid: config.allow_invalid,
167            subfolders_are_namespaces: config.subfolders_are_namespaces,
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use std::io::Write;
175
176    use super::*;
177
178    #[test]
179    fn load_reads_valid_file() {
180        let mut file = tempfile::NamedTempFile::new().unwrap();
181        file.write_all(
182            b"_version = \"1\"\nallowed_kinds = [\"USR\", \"SYS\"]\ndigits = 4\nallow_unrecognised = true\nallow_invalid = true\nsubfolders_are_namespaces = true\n",
183        )
184        .unwrap();
185
186        let config = Config::load(file.path()).unwrap();
187
188        assert_eq!(
189            config.allowed_kinds(),
190            &["USR".to_string(), "SYS".to_string()]
191        );
192        assert_eq!(config.digits(), 4);
193        assert!(config.allow_unrecognised);
194        assert!(config.allow_invalid);
195        assert!(config.subfolders_are_namespaces);
196    }
197
198    #[test]
199    fn load_missing_file_returns_error() {
200        let tmp = tempfile::tempdir().unwrap();
201        let missing = tmp.path().join("missing.toml");
202
203        let error = Config::load(&missing).unwrap_err();
204        assert!(error.starts_with("Failed to read config file:"));
205    }
206
207    #[test]
208    fn load_invalid_toml_returns_error() {
209        let mut file = tempfile::NamedTempFile::new().unwrap();
210        file.write_all(b"_version = \"1\"\ndigits = \"three\"\n")
211            .unwrap();
212
213        let error = Config::load(file.path()).unwrap_err();
214        assert!(error.starts_with("Failed to parse config file:"));
215    }
216
217    #[test]
218    fn empty_file_returns_default() {
219        // Tests that deserialising an empty file returns the default configuration.
220        let expected = Config::default();
221        let actual: Config = toml::from_str(r#"_version = "1""#).unwrap();
222        assert_eq!(actual, expected);
223    }
224}