use std::path::Path;
use crate::domain::AdrStatistics;
use crate::error::Result;
use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum StatsFormat {
#[default]
Text,
Json,
Markdown,
}
impl std::str::FromStr for StatsFormat {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
"markdown" | "md" => Ok(Self::Markdown),
_ => Err(format!("invalid format: {s}")),
}
}
}
#[derive(Debug, Clone)]
pub struct StatsOptions {
pub input_dir: String,
pub pattern: String,
pub format: StatsFormat,
}
impl Default for StatsOptions {
fn default() -> Self {
Self {
input_dir: "docs/decisions".to_string(),
pattern: "**/*.md".to_string(),
format: StatsFormat::Text,
}
}
}
impl StatsOptions {
#[must_use]
pub fn new(input_dir: impl Into<String>) -> Self {
Self {
input_dir: input_dir.into(),
..Default::default()
}
}
#[must_use]
pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
self.pattern = pattern.into();
self
}
#[must_use]
pub const fn with_format(mut self, format: StatsFormat) -> Self {
self.format = format;
self
}
}
#[derive(Debug)]
pub struct StatsUseCase<F: FileSystem> {
fs: F,
parser: DefaultAdrParser,
}
impl<F: FileSystem> StatsUseCase<F> {
#[must_use]
pub fn new(fs: F) -> Self {
Self {
fs,
parser: DefaultAdrParser::new(),
}
}
pub fn execute(&self, options: &StatsOptions) -> Result<StatsResult> {
let base = Path::new(&options.input_dir);
let files = self.fs.glob(base, &options.pattern)?;
if files.is_empty() {
return Err(crate::error::Error::NoAdrsFound {
path: base.to_path_buf(),
});
}
let mut adrs = Vec::with_capacity(files.len());
let mut parse_errors = Vec::new();
for file_path in &files {
let content = match self.fs.read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
parse_errors.push((file_path.clone(), e));
continue;
},
};
match self.parser.parse(file_path, &content) {
Ok(adr) => adrs.push(adr),
Err(e) => parse_errors.push((file_path.clone(), e)),
}
}
let statistics = AdrStatistics::from_adrs(&adrs);
let output = match options.format {
StatsFormat::Text => statistics.summary(),
StatsFormat::Json => {
serde_json::to_string_pretty(&statistics).unwrap_or_else(|_| "{}".to_string())
},
StatsFormat::Markdown => format_markdown(&statistics),
};
Ok(StatsResult {
statistics,
output,
parse_errors,
})
}
}
#[derive(Debug)]
pub struct StatsResult {
pub statistics: AdrStatistics,
pub output: String,
pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
}
impl StatsResult {
#[must_use]
pub fn has_errors(&self) -> bool {
!self.parse_errors.is_empty()
}
}
fn format_markdown(stats: &AdrStatistics) -> String {
use std::fmt::Write;
let mut output = String::new();
let _ = writeln!(output, " ADR Statistics\n");
let _ = writeln!(output, "**Total ADRs:** {}\n", stats.total_count);
let _ = writeln!(output, "# By Status\n");
let _ = writeln!(output, "| Status | Count |");
let _ = writeln!(output, "|--------|-------|");
for (status, count) in &stats.by_status {
let _ = writeln!(output, "| {status} | {count} |");
}
if !stats.by_category.is_empty() {
let _ = writeln!(output, "\n## By Category\n");
let _ = writeln!(output, "| Category | Count |");
let _ = writeln!(output, "|----------|-------|");
for (category, count) in &stats.by_category {
let _ = writeln!(output, "| {category} | {count} |");
}
}
if !stats.by_author.is_empty() {
let _ = writeln!(output, "\n## By Author\n");
let _ = writeln!(output, "| Author | Count |");
let _ = writeln!(output, "|--------|-------|");
for (author, count) in &stats.by_author {
let _ = writeln!(output, "| {author} | {count} |");
}
}
if let (Some(earliest), Some(latest)) = (&stats.earliest_date, &stats.latest_date) {
let _ = writeln!(output, "\n## Date Range\n");
let _ = writeln!(output, "- **Earliest:** {earliest}");
let _ = writeln!(output, "- **Latest:** {latest}");
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::fs::test_support::InMemoryFileSystem;
fn sample_adr_content(title: &str, status: &str, category: &str) -> String {
format!(
r"---
title: {title}
status: {status}
category: {category}
created: 2025-01-15
description: Test ADR
author: Test Author
---
# {title}
Content here.
"
)
}
#[test]
fn test_stats_success() {
let fs = InMemoryFileSystem::new();
fs.add_file(
"docs/decisions/adr-0001.md",
&sample_adr_content("ADR 1", "accepted", "database"),
);
fs.add_file(
"docs/decisions/adr-0002.md",
&sample_adr_content("ADR 2", "proposed", "api"),
);
fs.add_file(
"docs/decisions/adr-0003.md",
&sample_adr_content("ADR 3", "accepted", "database"),
);
let use_case = StatsUseCase::new(fs);
let options = StatsOptions::new("docs/decisions");
let result = use_case.execute(&options);
assert!(result.is_ok());
let result = result.unwrap();
assert_eq!(result.statistics.total_count, 3);
assert!(!result.has_errors());
}
#[test]
fn test_stats_json_format() {
let fs = InMemoryFileSystem::new();
fs.add_file(
"docs/decisions/adr-0001.md",
&sample_adr_content("ADR 1", "accepted", "database"),
);
let use_case = StatsUseCase::new(fs);
let options = StatsOptions::new("docs/decisions").with_format(StatsFormat::Json);
let result = use_case.execute(&options);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.output.contains("\"total_count\""));
}
#[test]
fn test_stats_markdown_format() {
let fs = InMemoryFileSystem::new();
fs.add_file(
"docs/decisions/adr-0001.md",
&sample_adr_content("ADR 1", "accepted", "database"),
);
let use_case = StatsUseCase::new(fs);
let options = StatsOptions::new("docs/decisions").with_format(StatsFormat::Markdown);
let result = use_case.execute(&options);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.output.contains(" ADR Statistics"));
assert!(result.output.contains("| Status | Count |"));
}
#[test]
fn test_stats_no_adrs() {
let fs = InMemoryFileSystem::new();
let use_case = StatsUseCase::new(fs);
let options = StatsOptions::new("empty/dir");
let result = use_case.execute(&options);
assert!(result.is_err());
}
#[test]
fn test_stats_format_from_str() {
assert_eq!("text".parse::<StatsFormat>().ok(), Some(StatsFormat::Text));
assert_eq!("json".parse::<StatsFormat>().ok(), Some(StatsFormat::Json));
assert_eq!(
"markdown".parse::<StatsFormat>().ok(),
Some(StatsFormat::Markdown)
);
assert_eq!(
"md".parse::<StatsFormat>().ok(),
Some(StatsFormat::Markdown)
);
assert!("invalid".parse::<StatsFormat>().is_err());
}
#[test]
fn test_stats_options_builder() {
let options = StatsOptions::new("input")
.with_pattern("*.md")
.with_format(StatsFormat::Json);
assert_eq!(options.input_dir, "input");
assert_eq!(options.pattern, "*.md");
assert_eq!(options.format, StatsFormat::Json);
}
}