adrscope/application/
stats.rs

1//! Statistics generation use case.
2//!
3//! Orchestrates ADR discovery, parsing, and statistics computation.
4
5use std::path::Path;
6
7use crate::domain::AdrStatistics;
8use crate::error::Result;
9use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
10
11/// Output format for statistics.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum StatsFormat {
14    /// Human-readable text format.
15    #[default]
16    Text,
17    /// JSON format.
18    Json,
19    /// Markdown format.
20    Markdown,
21}
22
23impl std::str::FromStr for StatsFormat {
24    type Err = String;
25
26    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
27        match s.to_lowercase().as_str() {
28            "text" => Ok(Self::Text),
29            "json" => Ok(Self::Json),
30            "markdown" | "md" => Ok(Self::Markdown),
31            _ => Err(format!("invalid format: {s}")),
32        }
33    }
34}
35
36/// Options for the stats command.
37#[derive(Debug, Clone)]
38pub struct StatsOptions {
39    /// Input directory containing ADR files.
40    pub input_dir: String,
41    /// Glob pattern for matching ADR files.
42    pub pattern: String,
43    /// Output format.
44    pub format: StatsFormat,
45}
46
47impl Default for StatsOptions {
48    fn default() -> Self {
49        Self {
50            input_dir: "docs/decisions".to_string(),
51            pattern: "**/*.md".to_string(),
52            format: StatsFormat::Text,
53        }
54    }
55}
56
57impl StatsOptions {
58    /// Creates new options with the given input directory.
59    #[must_use]
60    pub fn new(input_dir: impl Into<String>) -> Self {
61        Self {
62            input_dir: input_dir.into(),
63            ..Default::default()
64        }
65    }
66
67    /// Sets the glob pattern for matching files.
68    #[must_use]
69    pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
70        self.pattern = pattern.into();
71        self
72    }
73
74    /// Sets the output format.
75    #[must_use]
76    pub const fn with_format(mut self, format: StatsFormat) -> Self {
77        self.format = format;
78        self
79    }
80}
81
82/// Use case for generating ADR statistics.
83#[derive(Debug)]
84pub struct StatsUseCase<F: FileSystem> {
85    fs: F,
86    parser: DefaultAdrParser,
87}
88
89impl<F: FileSystem> StatsUseCase<F> {
90    /// Creates a new stats use case.
91    #[must_use]
92    pub fn new(fs: F) -> Self {
93        Self {
94            fs,
95            parser: DefaultAdrParser::new(),
96        }
97    }
98
99    /// Executes the statistics generation use case.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if:
104    /// - No ADR files are found
105    /// - File reading fails
106    pub fn execute(&self, options: &StatsOptions) -> Result<StatsResult> {
107        // Discover ADR files
108        let base = Path::new(&options.input_dir);
109        let files = self.fs.glob(base, &options.pattern)?;
110
111        if files.is_empty() {
112            return Err(crate::error::Error::NoAdrsFound {
113                path: base.to_path_buf(),
114            });
115        }
116
117        // Parse all ADRs
118        let mut adrs = Vec::with_capacity(files.len());
119        let mut parse_errors = Vec::new();
120
121        for file_path in &files {
122            let content = match self.fs.read_to_string(file_path) {
123                Ok(c) => c,
124                Err(e) => {
125                    parse_errors.push((file_path.clone(), e));
126                    continue;
127                },
128            };
129
130            match self.parser.parse(file_path, &content) {
131                Ok(adr) => adrs.push(adr),
132                Err(e) => parse_errors.push((file_path.clone(), e)),
133            }
134        }
135
136        // Compute statistics
137        let statistics = AdrStatistics::from_adrs(&adrs);
138
139        // Format output
140        let output = match options.format {
141            StatsFormat::Text => statistics.summary(),
142            StatsFormat::Json => {
143                serde_json::to_string_pretty(&statistics).unwrap_or_else(|_| "{}".to_string())
144            },
145            StatsFormat::Markdown => format_markdown(&statistics),
146        };
147
148        Ok(StatsResult {
149            statistics,
150            output,
151            parse_errors,
152        })
153    }
154}
155
156/// Result of the statistics use case.
157#[derive(Debug)]
158pub struct StatsResult {
159    /// Computed statistics.
160    pub statistics: AdrStatistics,
161    /// Formatted output string.
162    pub output: String,
163    /// Files that failed to parse.
164    pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
165}
166
167impl StatsResult {
168    /// Returns true if there were any parse errors.
169    #[must_use]
170    pub fn has_errors(&self) -> bool {
171        !self.parse_errors.is_empty()
172    }
173}
174
175/// Formats statistics as markdown.
176fn format_markdown(stats: &AdrStatistics) -> String {
177    use std::fmt::Write;
178    let mut output = String::new();
179
180    let _ = writeln!(output, " ADR Statistics\n");
181    let _ = writeln!(output, "**Total ADRs:** {}\n", stats.total_count);
182
183    let _ = writeln!(output, "# By Status\n");
184    let _ = writeln!(output, "| Status | Count |");
185    let _ = writeln!(output, "|--------|-------|");
186    for (status, count) in &stats.by_status {
187        let _ = writeln!(output, "| {status} | {count} |");
188    }
189
190    if !stats.by_category.is_empty() {
191        let _ = writeln!(output, "\n## By Category\n");
192        let _ = writeln!(output, "| Category | Count |");
193        let _ = writeln!(output, "|----------|-------|");
194        for (category, count) in &stats.by_category {
195            let _ = writeln!(output, "| {category} | {count} |");
196        }
197    }
198
199    if !stats.by_author.is_empty() {
200        let _ = writeln!(output, "\n## By Author\n");
201        let _ = writeln!(output, "| Author | Count |");
202        let _ = writeln!(output, "|--------|-------|");
203        for (author, count) in &stats.by_author {
204            let _ = writeln!(output, "| {author} | {count} |");
205        }
206    }
207
208    if let (Some(earliest), Some(latest)) = (&stats.earliest_date, &stats.latest_date) {
209        let _ = writeln!(output, "\n## Date Range\n");
210        let _ = writeln!(output, "- **Earliest:** {earliest}");
211        let _ = writeln!(output, "- **Latest:** {latest}");
212    }
213
214    output
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::infrastructure::fs::test_support::InMemoryFileSystem;
221
222    fn sample_adr_content(title: &str, status: &str, category: &str) -> String {
223        format!(
224            r"---
225title: {title}
226status: {status}
227category: {category}
228created: 2025-01-15
229description: Test ADR
230author: Test Author
231---
232
233# {title}
234
235Content here.
236"
237        )
238    }
239
240    #[test]
241    fn test_stats_success() {
242        let fs = InMemoryFileSystem::new();
243        fs.add_file(
244            "docs/decisions/adr-0001.md",
245            &sample_adr_content("ADR 1", "accepted", "database"),
246        );
247        fs.add_file(
248            "docs/decisions/adr-0002.md",
249            &sample_adr_content("ADR 2", "proposed", "api"),
250        );
251        fs.add_file(
252            "docs/decisions/adr-0003.md",
253            &sample_adr_content("ADR 3", "accepted", "database"),
254        );
255
256        let use_case = StatsUseCase::new(fs);
257        let options = StatsOptions::new("docs/decisions");
258
259        let result = use_case.execute(&options);
260        assert!(result.is_ok());
261
262        let result = result.unwrap();
263        assert_eq!(result.statistics.total_count, 3);
264        assert!(!result.has_errors());
265    }
266
267    #[test]
268    fn test_stats_json_format() {
269        let fs = InMemoryFileSystem::new();
270        fs.add_file(
271            "docs/decisions/adr-0001.md",
272            &sample_adr_content("ADR 1", "accepted", "database"),
273        );
274
275        let use_case = StatsUseCase::new(fs);
276        let options = StatsOptions::new("docs/decisions").with_format(StatsFormat::Json);
277
278        let result = use_case.execute(&options);
279        assert!(result.is_ok());
280
281        let result = result.unwrap();
282        assert!(result.output.contains("\"total_count\""));
283    }
284
285    #[test]
286    fn test_stats_markdown_format() {
287        let fs = InMemoryFileSystem::new();
288        fs.add_file(
289            "docs/decisions/adr-0001.md",
290            &sample_adr_content("ADR 1", "accepted", "database"),
291        );
292
293        let use_case = StatsUseCase::new(fs);
294        let options = StatsOptions::new("docs/decisions").with_format(StatsFormat::Markdown);
295
296        let result = use_case.execute(&options);
297        assert!(result.is_ok());
298
299        let result = result.unwrap();
300        assert!(result.output.contains(" ADR Statistics"));
301        assert!(result.output.contains("| Status | Count |"));
302    }
303
304    #[test]
305    fn test_stats_no_adrs() {
306        let fs = InMemoryFileSystem::new();
307        let use_case = StatsUseCase::new(fs);
308        let options = StatsOptions::new("empty/dir");
309
310        let result = use_case.execute(&options);
311        assert!(result.is_err());
312    }
313
314    #[test]
315    fn test_stats_format_from_str() {
316        assert_eq!("text".parse::<StatsFormat>().ok(), Some(StatsFormat::Text));
317        assert_eq!("json".parse::<StatsFormat>().ok(), Some(StatsFormat::Json));
318        assert_eq!(
319            "markdown".parse::<StatsFormat>().ok(),
320            Some(StatsFormat::Markdown)
321        );
322        assert_eq!(
323            "md".parse::<StatsFormat>().ok(),
324            Some(StatsFormat::Markdown)
325        );
326        assert!("invalid".parse::<StatsFormat>().is_err());
327    }
328
329    #[test]
330    fn test_stats_options_builder() {
331        let options = StatsOptions::new("input")
332            .with_pattern("*.md")
333            .with_format(StatsFormat::Json);
334
335        assert_eq!(options.input_dir, "input");
336        assert_eq!(options.pattern, "*.md");
337        assert_eq!(options.format, StatsFormat::Json);
338    }
339}