adrscope/application/
generate.rs

1//! Generate HTML viewer use case.
2//!
3//! Orchestrates ADR discovery, parsing, and HTML generation.
4
5use 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/// Options for the generate command.
14#[derive(Debug, Clone)]
15pub struct GenerateOptions {
16    /// Input directory containing ADR files.
17    pub input_dir: String,
18    /// Output file path for the HTML viewer.
19    pub output: String,
20    /// Page title.
21    pub title: String,
22    /// Theme preference.
23    pub theme: Theme,
24    /// Glob pattern for matching ADR files.
25    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    /// Creates new options with the given input directory.
42    #[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    /// Sets the output file path.
51    #[must_use]
52    pub fn with_output(mut self, output: impl Into<String>) -> Self {
53        self.output = output.into();
54        self
55    }
56
57    /// Sets the page title.
58    #[must_use]
59    pub fn with_title(mut self, title: impl Into<String>) -> Self {
60        self.title = title.into();
61        self
62    }
63
64    /// Sets the theme preference.
65    #[must_use]
66    pub const fn with_theme(mut self, theme: Theme) -> Self {
67        self.theme = theme;
68        self
69    }
70
71    /// Sets the glob pattern for matching files.
72    #[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/// Use case for generating HTML viewers.
80#[derive(Debug)]
81pub struct GenerateUseCase<F: FileSystem> {
82    fs: F,
83    parser: DefaultAdrParser,
84    renderer: HtmlRenderer,
85}
86
87impl<F: FileSystem> GenerateUseCase<F> {
88    /// Creates a new generate use case.
89    #[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    /// Executes the generate use case.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if:
103    /// - No ADR files are found
104    /// - File reading fails
105    /// - Parsing fails
106    /// - HTML generation fails
107    /// - File writing fails
108    pub fn execute(&self, options: &GenerateOptions) -> Result<GenerateResult> {
109        // Discover ADR files
110        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        // Parse all ADRs
120        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        // Sort by ID for consistent ordering
131        adrs.sort_by(|a, b| a.id().cmp(b.id()));
132
133        // Generate HTML
134        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        // Write output
140        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/// Result of the generate use case.
161#[derive(Debug)]
162pub struct GenerateResult {
163    /// Path to the generated HTML file.
164    pub output_path: String,
165    /// Number of ADRs included.
166    pub adr_count: usize,
167    /// Files that failed to parse.
168    pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
169}
170
171impl GenerateResult {
172    /// Returns true if there were any parse errors.
173    #[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}