use std::path::Path;
use crate::domain::{Severity, ValidationReport, Validator, default_rules};
use crate::error::Result;
use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
#[derive(Debug, Clone)]
pub struct ValidateOptions {
pub input_dir: String,
pub pattern: String,
pub strict: bool,
}
impl Default for ValidateOptions {
fn default() -> Self {
Self {
input_dir: "docs/decisions".to_string(),
pattern: "**/*.md".to_string(),
strict: false,
}
}
}
impl ValidateOptions {
#[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_strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
}
#[derive(Debug)]
pub struct ValidateUseCase<F: FileSystem> {
fs: F,
parser: DefaultAdrParser,
}
impl<F: FileSystem> ValidateUseCase<F> {
#[must_use]
pub fn new(fs: F) -> Self {
Self {
fs,
parser: DefaultAdrParser::new(),
}
}
pub fn execute(&self, options: &ValidateOptions) -> Result<ValidateResult> {
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 validator = Validator::new(default_rules());
let mut reports = Vec::with_capacity(files.len());
let mut parse_errors = Vec::new();
for file_path in &files {
match self.validate_file(file_path, &validator) {
Ok(report) => reports.push((file_path.clone(), report)),
Err(e) => parse_errors.push((file_path.clone(), e)),
}
}
let mut total_errors = 0;
let mut total_warnings = 0;
for (_, report) in &reports {
total_errors += report.errors().len();
total_warnings += report.warnings().len();
}
let passed = if options.strict {
total_errors == 0 && total_warnings == 0 && parse_errors.is_empty()
} else {
total_errors == 0 && parse_errors.is_empty()
};
Ok(ValidateResult {
reports,
parse_errors,
total_errors,
total_warnings,
passed,
})
}
fn validate_file(&self, path: &Path, validator: &Validator) -> Result<ValidationReport> {
let content = self.fs.read_to_string(path)?;
let adr = self.parser.parse(path, &content)?;
Ok(validator.validate(&adr))
}
}
#[derive(Debug)]
pub struct ValidateResult {
pub reports: Vec<(std::path::PathBuf, ValidationReport)>,
pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
pub total_errors: usize,
pub total_warnings: usize,
pub passed: bool,
}
impl ValidateResult {
#[must_use]
pub fn all_issues(
&self,
) -> impl Iterator<Item = (&std::path::PathBuf, &crate::domain::ValidationIssue)> {
self.reports
.iter()
.flat_map(|(path, report)| report.issues().iter().map(move |issue| (path, issue)))
}
#[must_use]
pub fn error_issues(
&self,
) -> impl Iterator<Item = (&std::path::PathBuf, &crate::domain::ValidationIssue)> {
self.all_issues()
.filter(|(_, issue)| issue.severity == Severity::Error)
}
#[must_use]
pub fn warning_issues(
&self,
) -> impl Iterator<Item = (&std::path::PathBuf, &crate::domain::ValidationIssue)> {
self.all_issues()
.filter(|(_, issue)| issue.severity == Severity::Warning)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::fs::test_support::InMemoryFileSystem;
fn valid_adr_content() -> &'static str {
r"---
title: Use PostgreSQL for persistence
status: accepted
category: database
created: 2025-01-15
description: We decided to use PostgreSQL as our primary database.
author: Jane Doe
---
# Use PostgreSQL for persistence
## Context
We need a database.
"
}
fn minimal_adr_content() -> &'static str {
r"---
title: Minimal ADR
status: proposed
---
# Minimal ADR
Some content.
"
}
fn invalid_adr_content() -> &'static str {
r"---
description: Missing title
---
# No Title
Some content.
"
}
#[test]
fn test_validate_valid_adr() {
let fs = InMemoryFileSystem::new();
fs.add_file("docs/decisions/adr-0001.md", valid_adr_content());
let use_case = ValidateUseCase::new(fs);
let options = ValidateOptions::new("docs/decisions");
let result = use_case.execute(&options);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.passed);
assert_eq!(result.total_errors, 0);
}
#[test]
fn test_validate_minimal_adr_has_warnings() {
let fs = InMemoryFileSystem::new();
fs.add_file("docs/decisions/adr-0001.md", minimal_adr_content());
let use_case = ValidateUseCase::new(fs);
let options = ValidateOptions::new("docs/decisions");
let result = use_case.execute(&options);
assert!(result.is_ok());
let result = result.unwrap();
assert!(result.passed);
assert_eq!(result.total_errors, 0);
assert!(result.total_warnings > 0);
}
#[test]
fn test_validate_strict_mode() {
let fs = InMemoryFileSystem::new();
fs.add_file("docs/decisions/adr-0001.md", minimal_adr_content());
let use_case = ValidateUseCase::new(fs);
let options = ValidateOptions::new("docs/decisions").with_strict(true);
let result = use_case.execute(&options);
assert!(result.is_ok());
let result = result.unwrap();
assert!(!result.passed);
}
#[test]
fn test_validate_invalid_adr() {
let fs = InMemoryFileSystem::new();
fs.add_file("docs/decisions/adr-0001.md", invalid_adr_content());
let use_case = ValidateUseCase::new(fs);
let options = ValidateOptions::new("docs/decisions");
let result = use_case.execute(&options);
assert!(result.is_err() || result.as_ref().is_ok_and(|r| !r.parse_errors.is_empty()));
}
#[test]
fn test_validate_no_adrs() {
let fs = InMemoryFileSystem::new();
let use_case = ValidateUseCase::new(fs);
let options = ValidateOptions::new("empty/dir");
let result = use_case.execute(&options);
assert!(result.is_err());
}
#[test]
fn test_validate_options_builder() {
let options = ValidateOptions::new("input")
.with_pattern("*.md")
.with_strict(true);
assert_eq!(options.input_dir, "input");
assert_eq!(options.pattern, "*.md");
assert!(options.strict);
}
}