adrscope/domain/
adr.rs

1//! Core ADR domain entity.
2//!
3//! This module defines the `Adr` struct which represents a fully parsed
4//! Architecture Decision Record with all its metadata and content.
5
6use std::path::PathBuf;
7
8use serde::Serialize;
9
10use super::{Frontmatter, Status};
11
12/// Unique identifier for an ADR, typically derived from the filename.
13///
14/// # Examples
15///
16/// ```
17/// use adrscope::domain::AdrId;
18///
19/// let id = AdrId::new("adr_0001");
20/// assert_eq!(id.as_str(), "adr_0001");
21/// ```
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
23pub struct AdrId(String);
24
25impl AdrId {
26    /// Creates a new ADR identifier.
27    #[must_use]
28    pub fn new(id: impl Into<String>) -> Self {
29        Self(id.into())
30    }
31
32    /// Returns the identifier as a string slice.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37
38    /// Extracts an ADR ID from a file path.
39    ///
40    /// The ID is derived from the file stem (filename without extension).
41    #[must_use]
42    pub fn from_path(path: &std::path::Path) -> Self {
43        let id = path
44            .file_stem()
45            .and_then(|s| s.to_str())
46            .unwrap_or("unknown");
47        Self::new(id)
48    }
49}
50
51impl std::fmt::Display for AdrId {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}", self.0)
54    }
55}
56
57impl AsRef<str> for AdrId {
58    fn as_ref(&self) -> &str {
59        &self.0
60    }
61}
62
63/// A fully parsed Architecture Decision Record.
64///
65/// Contains the parsed frontmatter metadata, the raw markdown body,
66/// and the pre-rendered HTML body for embedding in viewers.
67#[derive(Debug, Clone, Serialize)]
68pub struct Adr {
69    /// Unique identifier derived from filename.
70    id: AdrId,
71
72    /// Original filename of the ADR.
73    filename: String,
74
75    /// Source file path (relative to ADR directory).
76    #[serde(skip)]
77    source_path: PathBuf,
78
79    /// Parsed YAML frontmatter.
80    frontmatter: Frontmatter,
81
82    /// Raw markdown body (without frontmatter).
83    #[serde(skip)]
84    body_markdown: String,
85
86    /// Pre-rendered HTML body.
87    body_html: String,
88
89    /// Plain text version of body (for search indexing).
90    body_text: String,
91}
92
93impl Adr {
94    /// Creates a new ADR with all components.
95    #[must_use]
96    pub fn new(
97        id: AdrId,
98        filename: String,
99        source_path: PathBuf,
100        frontmatter: Frontmatter,
101        body_markdown: String,
102        body_html: String,
103        body_text: String,
104    ) -> Self {
105        Self {
106            id,
107            filename,
108            source_path,
109            frontmatter,
110            body_markdown,
111            body_html,
112            body_text,
113        }
114    }
115
116    /// Returns the unique identifier.
117    #[must_use]
118    pub fn id(&self) -> &AdrId {
119        &self.id
120    }
121
122    /// Returns the filename.
123    #[must_use]
124    pub fn filename(&self) -> &str {
125        &self.filename
126    }
127
128    /// Returns the source file path.
129    #[must_use]
130    pub fn source_path(&self) -> &PathBuf {
131        &self.source_path
132    }
133
134    /// Returns the parsed frontmatter.
135    #[must_use]
136    pub fn frontmatter(&self) -> &Frontmatter {
137        &self.frontmatter
138    }
139
140    /// Returns the raw markdown body.
141    #[must_use]
142    pub fn body_markdown(&self) -> &str {
143        &self.body_markdown
144    }
145
146    /// Returns the pre-rendered HTML body.
147    #[must_use]
148    pub fn body_html(&self) -> &str {
149        &self.body_html
150    }
151
152    /// Returns the plain text body for search indexing.
153    #[must_use]
154    pub fn body_text(&self) -> &str {
155        &self.body_text
156    }
157
158    // Convenience accessors delegating to frontmatter
159
160    /// Returns the ADR title.
161    #[must_use]
162    pub fn title(&self) -> &str {
163        &self.frontmatter.title
164    }
165
166    /// Returns the ADR description.
167    #[must_use]
168    pub fn description(&self) -> &str {
169        &self.frontmatter.description
170    }
171
172    /// Returns the ADR status.
173    #[must_use]
174    pub fn status(&self) -> Status {
175        self.frontmatter.status
176    }
177
178    /// Returns the ADR category.
179    #[must_use]
180    pub fn category(&self) -> &str {
181        &self.frontmatter.category
182    }
183
184    /// Returns the ADR tags.
185    #[must_use]
186    pub fn tags(&self) -> &[String] {
187        &self.frontmatter.tags
188    }
189
190    /// Returns the ADR author.
191    #[must_use]
192    pub fn author(&self) -> &str {
193        &self.frontmatter.author
194    }
195
196    /// Returns the ADR project.
197    #[must_use]
198    pub fn project(&self) -> &str {
199        &self.frontmatter.project
200    }
201
202    /// Returns the technologies affected by this ADR.
203    #[must_use]
204    pub fn technologies(&self) -> &[String] {
205        &self.frontmatter.technologies
206    }
207
208    /// Returns the related ADR filenames.
209    #[must_use]
210    pub fn related(&self) -> &[String] {
211        &self.frontmatter.related
212    }
213
214    /// Returns the created date if available.
215    #[must_use]
216    pub fn created(&self) -> Option<time::Date> {
217        self.frontmatter.created
218    }
219
220    /// Returns the updated date if available.
221    #[must_use]
222    pub fn updated(&self) -> Option<time::Date> {
223        self.frontmatter.updated
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_adr_id_from_path() {
233        let path = PathBuf::from("docs/decisions/adr_0001.md");
234        let id = AdrId::from_path(&path);
235        assert_eq!(id.as_str(), "adr_0001");
236    }
237
238    #[test]
239    fn test_adr_id_display() {
240        let id = AdrId::new("adr_0001");
241        assert_eq!(format!("{id}"), "adr_0001");
242    }
243
244    #[test]
245    fn test_adr_creation() {
246        let frontmatter = Frontmatter::new("Test ADR").with_status(Status::Accepted);
247
248        let adr = Adr::new(
249            AdrId::new("adr_0001"),
250            "adr_0001.md".to_string(),
251            PathBuf::from("docs/decisions/adr_0001.md"),
252            frontmatter,
253            "# Context\n\nSome context.".to_string(),
254            "<h1>Context</h1><p>Some context.</p>".to_string(),
255            "Context Some context.".to_string(),
256        );
257
258        assert_eq!(adr.id().as_str(), "adr_0001");
259        assert_eq!(adr.title(), "Test ADR");
260        assert_eq!(adr.status(), Status::Accepted);
261        assert!(adr.body_html().contains("<h1>Context</h1>"));
262    }
263
264    #[test]
265    fn test_adr_serialization() {
266        let frontmatter = Frontmatter::new("Test").with_category("architecture");
267
268        let adr = Adr::new(
269            AdrId::new("test"),
270            "test.md".to_string(),
271            PathBuf::from("test.md"),
272            frontmatter,
273            "body".to_string(),
274            "<p>body</p>".to_string(),
275            "body".to_string(),
276        );
277
278        let json = serde_json::to_string(&adr).expect("should serialize");
279        assert!(json.contains("\"id\":\"test\""));
280        assert!(json.contains("\"filename\":\"test.md\""));
281        // source_path and body_markdown should be skipped
282        assert!(!json.contains("source_path"));
283        assert!(!json.contains("body_markdown"));
284    }
285
286    #[test]
287    fn test_adr_all_accessors() {
288        use time::macros::date;
289
290        let frontmatter = Frontmatter::new("Complete ADR")
291            .with_description("Full description")
292            .with_status(Status::Deprecated)
293            .with_category("security")
294            .with_author("Security Team")
295            .with_project("test-project")
296            .with_created(date!(2025 - 01 - 10))
297            .with_updated(date!(2025 - 01 - 15))
298            .with_tags(vec!["security".to_string()])
299            .with_technologies(vec!["rust".to_string()])
300            .with_related(vec!["adr-001.md".to_string()]);
301
302        let adr = Adr::new(
303            AdrId::new("adr_0002"),
304            "adr_0002.md".to_string(),
305            PathBuf::from("docs/decisions/adr_0002.md"),
306            frontmatter,
307            "# Body\n\nMarkdown content.".to_string(),
308            "<h1>Body</h1><p>Markdown content.</p>".to_string(),
309            "Body Markdown content.".to_string(),
310        );
311
312        // Test all accessors
313        assert_eq!(adr.id().as_str(), "adr_0002");
314        assert_eq!(adr.filename(), "adr_0002.md");
315        assert_eq!(
316            adr.source_path(),
317            &PathBuf::from("docs/decisions/adr_0002.md")
318        );
319        assert_eq!(adr.frontmatter().title, "Complete ADR");
320        assert_eq!(adr.body_markdown(), "# Body\n\nMarkdown content.");
321        assert_eq!(adr.body_html(), "<h1>Body</h1><p>Markdown content.</p>");
322        assert_eq!(adr.body_text(), "Body Markdown content.");
323        assert_eq!(adr.title(), "Complete ADR");
324        assert_eq!(adr.description(), "Full description");
325        assert_eq!(adr.status(), Status::Deprecated);
326        assert_eq!(adr.category(), "security");
327        assert_eq!(adr.tags(), &["security"]);
328        assert_eq!(adr.author(), "Security Team");
329        assert_eq!(adr.project(), "test-project");
330        assert_eq!(adr.technologies(), &["rust"]);
331        assert_eq!(adr.related(), &["adr-001.md"]);
332        assert_eq!(adr.created(), Some(date!(2025 - 01 - 10)));
333        assert_eq!(adr.updated(), Some(date!(2025 - 01 - 15)));
334    }
335}