adrscope/application/
generate.rs1use std::path::Path;
6
7use crate::domain::Adr;
8use crate::error::Result;
9use crate::infrastructure::{
10 AdrParser, DefaultAdrParser, FileSystem, HtmlRenderer, RenderConfig, Theme,
11};
12
13#[derive(Debug, Clone)]
15pub struct GenerateOptions {
16 pub input_dir: String,
18 pub output: String,
20 pub title: String,
22 pub theme: Theme,
24 pub pattern: String,
26}
27
28impl Default for GenerateOptions {
29 fn default() -> Self {
30 Self {
31 input_dir: "docs/decisions".to_string(),
32 output: "adrs.html".to_string(),
33 title: "Architecture Decision Records".to_string(),
34 theme: Theme::Auto,
35 pattern: "**/*.md".to_string(),
36 }
37 }
38}
39
40impl GenerateOptions {
41 #[must_use]
43 pub fn new(input_dir: impl Into<String>) -> Self {
44 Self {
45 input_dir: input_dir.into(),
46 ..Default::default()
47 }
48 }
49
50 #[must_use]
52 pub fn with_output(mut self, output: impl Into<String>) -> Self {
53 self.output = output.into();
54 self
55 }
56
57 #[must_use]
59 pub fn with_title(mut self, title: impl Into<String>) -> Self {
60 self.title = title.into();
61 self
62 }
63
64 #[must_use]
66 pub const fn with_theme(mut self, theme: Theme) -> Self {
67 self.theme = theme;
68 self
69 }
70
71 #[must_use]
73 pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
74 self.pattern = pattern.into();
75 self
76 }
77}
78
79#[derive(Debug)]
81pub struct GenerateUseCase<F: FileSystem> {
82 fs: F,
83 parser: DefaultAdrParser,
84 renderer: HtmlRenderer,
85}
86
87impl<F: FileSystem> GenerateUseCase<F> {
88 #[must_use]
90 pub fn new(fs: F) -> Self {
91 Self {
92 fs,
93 parser: DefaultAdrParser::new(),
94 renderer: HtmlRenderer::new(),
95 }
96 }
97
98 pub fn execute(&self, options: &GenerateOptions) -> Result<GenerateResult> {
109 let base = Path::new(&options.input_dir);
111 let files = self.fs.glob(base, &options.pattern)?;
112
113 if files.is_empty() {
114 return Err(crate::error::Error::NoAdrsFound {
115 path: base.to_path_buf(),
116 });
117 }
118
119 let mut adrs = Vec::with_capacity(files.len());
121 let mut errors = Vec::new();
122
123 for file_path in &files {
124 match self.parse_adr(file_path) {
125 Ok(adr) => adrs.push(adr),
126 Err(e) => errors.push((file_path.clone(), e)),
127 }
128 }
129
130 adrs.sort_by(|a, b| a.id().cmp(b.id()));
132
133 let config = RenderConfig::new(&options.title).with_theme(options.theme);
135 let html = self
136 .renderer
137 .render(adrs.clone(), &options.input_dir, &config)?;
138
139 if let Some(parent) = Path::new(&options.output).parent() {
141 if !parent.as_os_str().is_empty() {
142 self.fs.create_dir_all(parent)?;
143 }
144 }
145 self.fs.write(Path::new(&options.output), &html)?;
146
147 Ok(GenerateResult {
148 output_path: options.output.clone(),
149 adr_count: adrs.len(),
150 parse_errors: errors,
151 })
152 }
153
154 fn parse_adr(&self, path: &Path) -> Result<Adr> {
155 let content = self.fs.read_to_string(path)?;
156 self.parser.parse(path, &content)
157 }
158}
159
160#[derive(Debug)]
162pub struct GenerateResult {
163 pub output_path: String,
165 pub adr_count: usize,
167 pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
169}
170
171impl GenerateResult {
172 #[must_use]
174 pub fn has_errors(&self) -> bool {
175 !self.parse_errors.is_empty()
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::infrastructure::fs::test_support::InMemoryFileSystem;
183
184 fn sample_adr_content() -> &'static str {
185 r"---
186title: Use PostgreSQL for persistence
187status: accepted
188category: database
189created: 2025-01-15
190description: We decided to use PostgreSQL as our primary database.
191---
192
193# Use PostgreSQL for persistence
194
195## Context
196
197We need a database for our application.
198
199## Decision
200
201We will use PostgreSQL.
202
203## Consequences
204
205- We get ACID compliance
206- We need to manage database migrations
207"
208 }
209
210 #[test]
211 fn test_generate_success() {
212 let fs = InMemoryFileSystem::new();
213 fs.add_file("docs/decisions/adr-0001.md", sample_adr_content());
214
215 let use_case = GenerateUseCase::new(fs);
216 let options = GenerateOptions::new("docs/decisions").with_output("output.html");
217
218 let result = use_case.execute(&options);
219 assert!(result.is_ok());
220
221 let result = result.unwrap();
222 assert_eq!(result.adr_count, 1);
223 assert_eq!(result.output_path, "output.html");
224 assert!(!result.has_errors());
225 }
226
227 #[test]
228 fn test_generate_no_adrs() {
229 let fs = InMemoryFileSystem::new();
230 let use_case = GenerateUseCase::new(fs);
231 let options = GenerateOptions::new("empty/dir");
232
233 let result = use_case.execute(&options);
234 assert!(result.is_err());
235 }
236
237 #[test]
238 fn test_generate_options_builder() {
239 let options = GenerateOptions::new("input")
240 .with_output("out.html")
241 .with_title("My ADRs")
242 .with_theme(Theme::Dark)
243 .with_pattern("*.md");
244
245 assert_eq!(options.input_dir, "input");
246 assert_eq!(options.output, "out.html");
247 assert_eq!(options.title, "My ADRs");
248 assert_eq!(options.theme, Theme::Dark);
249 assert_eq!(options.pattern, "*.md");
250 }
251}