1use std::path::Path;
6
7use crate::domain::AdrStatistics;
8use crate::error::Result;
9use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum StatsFormat {
14 #[default]
16 Text,
17 Json,
19 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#[derive(Debug, Clone)]
38pub struct StatsOptions {
39 pub input_dir: String,
41 pub pattern: String,
43 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 #[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 #[must_use]
69 pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
70 self.pattern = pattern.into();
71 self
72 }
73
74 #[must_use]
76 pub const fn with_format(mut self, format: StatsFormat) -> Self {
77 self.format = format;
78 self
79 }
80}
81
82#[derive(Debug)]
84pub struct StatsUseCase<F: FileSystem> {
85 fs: F,
86 parser: DefaultAdrParser,
87}
88
89impl<F: FileSystem> StatsUseCase<F> {
90 #[must_use]
92 pub fn new(fs: F) -> Self {
93 Self {
94 fs,
95 parser: DefaultAdrParser::new(),
96 }
97 }
98
99 pub fn execute(&self, options: &StatsOptions) -> Result<StatsResult> {
107 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 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 let statistics = AdrStatistics::from_adrs(&adrs);
138
139 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#[derive(Debug)]
158pub struct StatsResult {
159 pub statistics: AdrStatistics,
161 pub output: String,
163 pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
165}
166
167impl StatsResult {
168 #[must_use]
170 pub fn has_errors(&self) -> bool {
171 !self.parse_errors.is_empty()
172 }
173}
174
175fn 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}