adrscope/application/
wiki.rs

1//! Generate GitHub Wiki pages use case.
2//!
3//! Orchestrates ADR discovery, parsing, and Wiki markdown generation.
4
5use std::path::Path;
6
7use crate::domain::Adr;
8use crate::error::Result;
9use crate::infrastructure::renderer::WikiRenderer;
10use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
11
12/// Options for the wiki command.
13#[derive(Debug, Clone)]
14pub struct WikiOptions {
15    /// Input directory containing ADR files.
16    pub input_dir: String,
17    /// Output directory for wiki files.
18    pub output_dir: String,
19    /// Optional URL to the GitHub Pages viewer.
20    pub pages_url: Option<String>,
21    /// Glob pattern for matching ADR files.
22    pub pattern: String,
23}
24
25impl Default for WikiOptions {
26    fn default() -> Self {
27        Self {
28            input_dir: "docs/decisions".to_string(),
29            output_dir: "wiki".to_string(),
30            pages_url: None,
31            pattern: "**/*.md".to_string(),
32        }
33    }
34}
35
36impl WikiOptions {
37    /// Creates new options with the given input directory.
38    #[must_use]
39    pub fn new(input_dir: impl Into<String>) -> Self {
40        Self {
41            input_dir: input_dir.into(),
42            ..Default::default()
43        }
44    }
45
46    /// Sets the output directory.
47    #[must_use]
48    pub fn with_output_dir(mut self, output_dir: impl Into<String>) -> Self {
49        self.output_dir = output_dir.into();
50        self
51    }
52
53    /// Sets the GitHub Pages URL.
54    #[must_use]
55    pub fn with_pages_url(mut self, url: impl Into<String>) -> Self {
56        self.pages_url = Some(url.into());
57        self
58    }
59
60    /// Sets the glob pattern for matching files.
61    #[must_use]
62    pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
63        self.pattern = pattern.into();
64        self
65    }
66}
67
68/// Use case for generating GitHub Wiki pages.
69#[derive(Debug)]
70pub struct WikiUseCase<F: FileSystem> {
71    fs: F,
72    parser: DefaultAdrParser,
73    renderer: WikiRenderer,
74}
75
76impl<F: FileSystem> WikiUseCase<F> {
77    /// Creates a new wiki use case.
78    #[must_use]
79    pub fn new(fs: F) -> Self {
80        Self {
81            fs,
82            parser: DefaultAdrParser::new(),
83            renderer: WikiRenderer::new(),
84        }
85    }
86
87    /// Executes the wiki generation use case.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if:
92    /// - No ADR files are found
93    /// - File reading fails
94    /// - Parsing fails
95    /// - File writing fails
96    pub fn execute(&self, options: &WikiOptions) -> Result<WikiResult> {
97        // Discover ADR files
98        let base = Path::new(&options.input_dir);
99        let files = self.fs.glob(base, &options.pattern)?;
100
101        if files.is_empty() {
102            return Err(crate::error::Error::NoAdrsFound {
103                path: base.to_path_buf(),
104            });
105        }
106
107        // Parse all ADRs
108        let mut adrs = Vec::with_capacity(files.len());
109        let mut errors = Vec::new();
110
111        for file_path in &files {
112            match self.parse_adr(file_path) {
113                Ok(adr) => adrs.push(adr),
114                Err(e) => errors.push((file_path.clone(), e)),
115            }
116        }
117
118        // Sort by ID for consistent ordering
119        adrs.sort_by(|a, b| a.id().cmp(b.id()));
120
121        // Generate wiki pages
122        let pages = self
123            .renderer
124            .render_all(&adrs, options.pages_url.as_deref())?;
125
126        // Create output directory
127        self.fs.create_dir_all(Path::new(&options.output_dir))?;
128
129        // Write wiki pages
130        let mut generated_files = Vec::with_capacity(pages.len());
131        for (filename, content) in pages {
132            let output_path = format!("{}/{}", options.output_dir, filename);
133            self.fs.write(Path::new(&output_path), &content)?;
134            generated_files.push(output_path);
135        }
136
137        // Copy original ADR files to wiki directory
138        for adr in &adrs {
139            let dest_path = format!("{}/{}", options.output_dir, adr.filename());
140            let content = self.fs.read_to_string(adr.source_path())?;
141            self.fs.write(Path::new(&dest_path), &content)?;
142            generated_files.push(dest_path);
143        }
144
145        Ok(WikiResult {
146            output_dir: options.output_dir.clone(),
147            generated_files,
148            adr_count: adrs.len(),
149            parse_errors: errors,
150        })
151    }
152
153    fn parse_adr(&self, path: &Path) -> Result<Adr> {
154        let content = self.fs.read_to_string(path)?;
155        self.parser.parse(path, &content)
156    }
157}
158
159/// Result of the wiki generation use case.
160#[derive(Debug)]
161pub struct WikiResult {
162    /// Output directory path.
163    pub output_dir: String,
164    /// List of generated file paths.
165    pub generated_files: Vec<String>,
166    /// Number of ADRs processed.
167    pub adr_count: usize,
168    /// Files that failed to parse.
169    pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
170}
171
172impl WikiResult {
173    /// Returns true if there were any parse errors.
174    #[must_use]
175    pub fn has_errors(&self) -> bool {
176        !self.parse_errors.is_empty()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::infrastructure::fs::test_support::InMemoryFileSystem;
184
185    fn sample_adr_content() -> &'static str {
186        r"---
187title: Use PostgreSQL for persistence
188status: accepted
189category: database
190created: 2025-01-15
191description: We decided to use PostgreSQL as our primary database.
192---
193
194# Use PostgreSQL for persistence
195
196## Context
197
198We need a database for our application.
199
200## Decision
201
202We will use PostgreSQL.
203
204## Consequences
205
206- We get ACID compliance
207- We need to manage database migrations
208"
209    }
210
211    #[test]
212    fn test_wiki_success() {
213        let fs = InMemoryFileSystem::new();
214        fs.add_file("docs/decisions/adr-0001.md", sample_adr_content());
215
216        let use_case = WikiUseCase::new(fs);
217        let options = WikiOptions::new("docs/decisions")
218            .with_output_dir("wiki")
219            .with_pages_url("https://example.com/adrs");
220
221        let result = use_case.execute(&options);
222        assert!(result.is_ok());
223
224        let result = result.unwrap();
225        assert_eq!(result.adr_count, 1);
226        assert!(!result.has_errors());
227        // Should have index + by-status + by-category + timeline + statistics + original ADR
228        assert!(result.generated_files.len() >= 5);
229    }
230
231    #[test]
232    fn test_wiki_no_adrs() {
233        let fs = InMemoryFileSystem::new();
234        let use_case = WikiUseCase::new(fs);
235        let options = WikiOptions::new("empty/dir");
236
237        let result = use_case.execute(&options);
238        assert!(result.is_err());
239    }
240
241    #[test]
242    fn test_wiki_options_builder() {
243        let options = WikiOptions::new("input")
244            .with_output_dir("wiki")
245            .with_pages_url("https://example.com")
246            .with_pattern("*.md");
247
248        assert_eq!(options.input_dir, "input");
249        assert_eq!(options.output_dir, "wiki");
250        assert_eq!(options.pages_url, Some("https://example.com".to_string()));
251        assert_eq!(options.pattern, "*.md");
252    }
253}