use crate::{
common::{EmptyTestViolation, ProjectedEdge},
files::{
CustomFileViolation, CycleViolation, ExternalModuleDependencyViolation,
FileDependencyViolation, FilePatternViolation,
},
layers::{LayerDependencyRule, LayerDependencyViolation},
metrics::{
CustomMetricViolation, MetricPredicateViolation, MetricThresholdViolation,
MetricZoneViolation,
},
slices::{SliceDependencyRule, SliceDependencyViolation},
testing::TestViolation,
violation::Violation,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct ViolationFactory;
impl ViolationFactory {
#[must_use]
pub fn from_violation(violation: &Violation) -> TestViolation {
match violation {
Violation::EmptyTest(violation) => format_empty_test(violation),
Violation::Cycle(violation) => format_cycle(violation),
Violation::FilePattern(violation) => format_file_pattern(violation),
Violation::FileDependency(violation) => format_file_dependency(violation),
Violation::ExternalModuleDependency(violation) => {
format_external_module_dependency(violation)
}
Violation::CustomFile(violation) => format_custom_file(violation),
Violation::LayerDependency(violation) => format_layer_dependency(violation),
Violation::SliceDependency(violation) => format_slice_dependency(violation),
Violation::MetricZone(violation) => format_metric_zone(violation),
Violation::CustomMetric(violation) => format_custom_metric(violation),
Violation::MetricThreshold(violation) => format_metric_threshold(violation),
Violation::MetricPredicate(violation) => format_metric_predicate(violation),
}
}
}
fn format_empty_test(violation: &EmptyTestViolation) -> TestViolation {
let mood = if violation.is_negated {
"negated"
} else {
"positive"
};
let scope = if violation.selectors.is_empty() {
"without explicit selectors".to_owned()
} else {
format!(
"with selectors: {}",
violation
.selectors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" AND ")
)
};
let details = format!(
"The {mood} {} rule selected no subjects {scope}. Verify the selectors or explicitly use CheckOptions::new().with_allow_empty_tests(true) for an intentional empty scope.",
violation.subject
);
TestViolation::new("Empty test violation", details)
}
fn format_file_pattern(violation: &FilePatternViolation) -> TestViolation {
let relationship = if violation.is_negated {
"matches the forbidden"
} else {
"does not match the required"
};
let requirement = format!(
"{} pattern \"{}\"",
violation.check_filter.target(),
violation.check_filter.pattern().source()
);
TestViolation::new(
"File pattern violation",
format!(
"File '{}' {relationship} {requirement}.",
violation.projected_node.label
),
)
}
fn format_file_dependency(violation: &FileDependencyViolation) -> TestViolation {
let edge = &violation.dependency;
let relationship = if violation.is_negated {
format!(
"File '{}' depends on forbidden file '{}'.",
edge.source_label, edge.target_label
)
} else {
format!(
"File '{}' depends on '{}', which is outside the allowed file target set.",
edge.source_label, edge.target_label
)
};
TestViolation::new(
"File dependency violation",
format!("{relationship}{}", evidence_suffix(edge)),
)
}
fn format_external_module_dependency(
violation: &ExternalModuleDependencyViolation,
) -> TestViolation {
let edge = &violation.dependency;
let relationship = if violation.is_negated {
format!(
"File '{}' depends on forbidden external module '{}'.",
edge.source_label, edge.target_label
)
} else {
format!(
"File '{}' depends on external module '{}', which is outside the allowlist.",
edge.source_label, edge.target_label
)
};
TestViolation::new(
"External module dependency violation",
format!("{relationship}{}", evidence_suffix(edge)),
)
}
fn format_cycle(violation: &CycleViolation) -> TestViolation {
let evidence = violation
.cycle
.iter()
.flat_map(|edge| edge.cumulated_edges.iter())
.map(ToString::to_string)
.collect::<Vec<_>>();
let evidence = if evidence.is_empty() {
String::new()
} else {
format!(" Evidence: {}.", evidence.join("; "))
};
TestViolation::new(
"Circular dependency detected",
format!("Cycle: {}.{evidence}", violation.path.join(" -> ")),
)
}
fn format_custom_file(violation: &CustomFileViolation) -> TestViolation {
let relationship = if violation.is_negated {
"matched the forbidden custom predicate"
} else {
"failed the required custom predicate"
};
let file = &violation.file_info;
let line_word = if file.non_blank_line_count == 1 {
"line"
} else {
"lines"
};
TestViolation::new(
"Custom file condition violation",
format!(
"File '{}' {relationship} '{}'. Source facts: name '{}', extension '{}', directory '{}', {} non-blank {line_word}.",
file.path,
violation.message,
file.name,
file.extension,
file.directory,
file.non_blank_line_count
),
)
}
fn format_layer_dependency(violation: &LayerDependencyViolation) -> TestViolation {
let relationship = match violation.rule {
LayerDependencyRule::MayOnlyDependOnLayers => format!(
"Layer '{}' depends on layer '{}', which is outside its allowed layer set.",
violation.source_layer, violation.target_layer
),
LayerDependencyRule::MayNotDependOnLayers => format!(
"Layer '{}' depends on forbidden layer '{}'.",
violation.source_layer, violation.target_layer
),
};
let edge = &violation.dependency;
TestViolation::new(
"Layer dependency violation",
format!(
"{relationship} File dependency: '{}' -> '{}'.{}",
edge.source_label,
edge.target_label,
evidence_suffix(edge)
),
)
}
fn format_slice_dependency(violation: &SliceDependencyViolation) -> TestViolation {
let relationship = match violation.rule {
SliceDependencyRule::ContainDependency if violation.is_negated => format!(
"Slice '{}' depends on forbidden slice '{}'.",
violation.source_slice, violation.target_slice
),
SliceDependencyRule::ContainDependency => format!(
"Slice '{}' does not contain the required dependency on slice '{}'.",
violation.source_slice, violation.target_slice
),
SliceDependencyRule::AdhereToDiagram => format!(
"Slice '{}' depends on slice '{}', which is not allowed by the architecture diagram.",
violation.source_slice, violation.target_slice
),
};
TestViolation::new(
"Slice dependency violation",
format!("{relationship}{}", evidence_suffix(&violation.dependency)),
)
}
fn format_metric_zone(violation: &MetricZoneViolation) -> TestViolation {
TestViolation::new(
"Metric zone violation",
format!(
"Component '{}' is in the zone of {} (abstractness {:.3}, instability {:.3}).",
violation.distance_info.identifier(),
violation.zone,
violation.abstractness,
violation.instability
),
)
}
fn format_custom_metric(violation: &CustomMetricViolation) -> TestViolation {
TestViolation::new(
"Custom metric violation",
format!(
"Type '{}' failed custom metric '{}': {} (value {}).",
violation.type_info.name(),
violation.metric_name,
violation.description,
violation.value
),
)
}
fn format_metric_threshold(violation: &MetricThresholdViolation) -> TestViolation {
TestViolation::new(
"Metric threshold violation",
format!(
"Metric '{}' for '{}' was {}; expected {} {}.",
violation.metric_name,
violation.identifier(),
violation.value,
violation.comparison,
violation.threshold
),
)
}
fn format_metric_predicate(violation: &MetricPredicateViolation) -> TestViolation {
TestViolation::new(
"Metric predicate violation",
format!(
"Metric '{}' for '{}' with value {} did not satisfy the custom predicate.",
violation.metric_name,
violation.identifier(),
violation.value
),
)
}
fn evidence_suffix(edge: &ProjectedEdge) -> String {
if edge.cumulated_edges.is_empty() {
String::new()
} else {
format!(
" Evidence: {}.",
edge.cumulated_edges
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{
common::{
Edge, EmptyTestViolation, Graph, ImportKind, ProjectedEdge, RegexFactory,
project_to_nodes,
},
files::{
CustomFileViolation, CycleViolation, ExternalModuleDependencyViolation,
FileDependencyViolation, FileInfo, FilePatternViolation,
},
layers::{LayerDependencyRule, LayerDependencyViolation},
metrics::{
ArchitecturalZone, MetricZoneViolation, ProjectMetricsInfo, build_distance_infos,
extract_file_metrics,
},
slices::{SliceDependencyRule, SliceDependencyViolation},
violation::Violation,
};
use super::ViolationFactory;
fn projected(source: &str, target: &str, external: bool) -> ProjectedEdge {
ProjectedEdge::new(
source,
target,
[Edge::new(
source,
target,
external,
[ImportKind::Use, ImportKind::PathReference],
)],
)
}
#[test]
fn formats_empty_scope_with_mood_selectors_and_actionable_opt_out() {
let selector = RegexFactory::default()
.path_matcher("missing/**")
.expect("fixture selector should compile");
let violation =
Violation::from(EmptyTestViolation::new_with_mood("files", [selector], true));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Empty test violation");
assert_eq!(
formatted.details,
"The negated files rule selected no subjects with selectors: path matches \"missing/**\". Verify the selectors or explicitly use CheckOptions::new().with_allow_empty_tests(true) for an intentional empty scope."
);
}
#[test]
fn formats_both_file_pattern_moods_from_typed_filter_data() {
let filter = RegexFactory::default()
.filename_matcher("*_service.rs")
.expect("fixture selector should compile");
let node = project_to_nodes(&Graph::from_edges([Edge::self_edge("src/api.rs")]))
.into_iter()
.next()
.expect("fixture graph should project one node");
let positive = Violation::from(FilePatternViolation::new(
filter.clone(),
node.clone(),
false,
));
let negated = Violation::from(FilePatternViolation::new(filter, node, true));
assert_eq!(
ViolationFactory::from_violation(&positive).details,
"File 'src/api.rs' does not match the required filename pattern \"*_service.rs\"."
);
assert_eq!(
ViolationFactory::from_violation(&negated).details,
"File 'src/api.rs' matches the forbidden filename pattern \"*_service.rs\"."
);
}
#[test]
fn formats_internal_and_external_dependencies_with_raw_rust_evidence() {
let internal = Violation::from(FileDependencyViolation::new(
projected("src/api.rs", "src/db.rs", false),
true,
));
let external = Violation::from(ExternalModuleDependencyViolation::new(
projected("src/api.rs", "tokio", true),
false,
));
assert_eq!(
ViolationFactory::from_violation(&internal).details,
"File 'src/api.rs' depends on forbidden file 'src/db.rs'. Evidence: src/api.rs -> src/db.rs [use, path_reference]."
);
assert_eq!(
ViolationFactory::from_violation(&external).details,
"File 'src/api.rs' depends on external module 'tokio', which is outside the allowlist. Evidence: src/api.rs -> tokio (external) [use, path_reference]."
);
}
#[test]
fn formats_cycle_path_and_every_raw_dependency() {
let first = projected("src/api.rs", "src/domain.rs", false);
let second = projected("src/domain.rs", "src/api.rs", false);
let violation = Violation::from(CycleViolation::new([first, second]));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Circular dependency detected");
assert_eq!(
formatted.details,
"Cycle: src/api.rs -> src/domain.rs -> src/api.rs. Evidence: src/api.rs -> src/domain.rs [use, path_reference]; src/domain.rs -> src/api.rs [use, path_reference]."
);
}
#[test]
fn formats_custom_requirement_mood_and_portable_source_facts() {
let info = FileInfo::new("src/api.rs", "pub fn api() {}\n\n");
let violation = Violation::from(CustomFileViolation::new(
info,
"contain no public functions",
true,
));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Custom file condition violation");
assert_eq!(
formatted.details,
"File 'src/api.rs' matched the forbidden custom predicate 'contain no public functions'. Source facts: name 'api', extension '.rs', directory 'src', 1 non-blank line."
);
}
#[test]
fn formats_layer_policy_and_concrete_file_evidence() {
let dependency = projected("src/api.rs", "src/db.rs", false);
let allowed = Violation::from(LayerDependencyViolation::new(
dependency.clone(),
"api",
"database",
LayerDependencyRule::MayOnlyDependOnLayers,
));
let forbidden = Violation::from(LayerDependencyViolation::new(
dependency,
"api",
"database",
LayerDependencyRule::MayNotDependOnLayers,
));
assert_eq!(
ViolationFactory::from_violation(&allowed).details,
"Layer 'api' depends on layer 'database', which is outside its allowed layer set. File dependency: 'src/api.rs' -> 'src/db.rs'. Evidence: src/api.rs -> src/db.rs [use, path_reference]."
);
assert_eq!(
ViolationFactory::from_violation(&forbidden).details,
"Layer 'api' depends on forbidden layer 'database'. File dependency: 'src/api.rs' -> 'src/db.rs'. Evidence: src/api.rs -> src/db.rs [use, path_reference]."
);
}
#[test]
fn formats_forbidden_slice_dependency_with_concrete_rust_evidence() {
let dependency = projected("src/api.rs", "src/db.rs", false);
let violation = Violation::from(SliceDependencyViolation::new(
dependency,
"api",
"database",
SliceDependencyRule::ContainDependency,
true,
));
assert_eq!(
ViolationFactory::from_violation(&violation).details,
"Slice 'api' depends on forbidden slice 'database'. Evidence: src/api.rs -> src/db.rs [use, path_reference]."
);
}
#[test]
fn formats_metric_zone_with_component_and_coordinates() {
let metrics = ProjectMetricsInfo::from_files(
PathBuf::from("fixture"),
vec![
extract_file_metrics("src/stable.rs", "struct Stable;")
.expect("fixture should parse"),
],
);
let info = build_distance_infos(
&metrics,
&Graph::from_edges([Edge::self_edge("src/stable.rs")]),
)
.into_iter()
.next()
.expect("fixture should produce one component");
let violation = Violation::from(MetricZoneViolation::new(info, ArchitecturalZone::Pain));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Metric zone violation");
assert_eq!(
formatted.details,
"Component 'src/stable.rs' is in the zone of pain (abstractness 0.000, instability 0.000)."
);
}
#[test]
fn formats_custom_metric_with_type_definition_and_value() {
let type_info = extract_file_metrics("src/service.rs", "struct Service { port: usize }")
.expect("fixture should parse")
.types()
.first()
.expect("fixture should have one type")
.clone();
let violation = Violation::from(crate::metrics::CustomMetricViolation::new(
type_info,
"field_count",
"must have no more than zero fields",
1.0,
));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Custom metric violation");
assert_eq!(
formatted.details,
"Type 'Service' failed custom metric 'field_count': must have no more than zero fields (value 1)."
);
}
#[test]
fn formats_metric_threshold_with_exact_comparison_evidence() {
let type_info = extract_file_metrics("src/service.rs", "struct Service;")
.expect("fixture should parse")
.types()
.first()
.expect("fixture should contain one type")
.clone();
let violation = Violation::from(crate::metrics::MetricThresholdViolation::new(
crate::metrics::MetricSubject::Type(type_info),
"method_count",
4.0,
4.0,
crate::metrics::MetricComparison::Below,
));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Metric threshold violation");
assert_eq!(
formatted.details,
"Metric 'method_count' for 'Service' was 4; expected below 4."
);
}
#[test]
fn formats_metric_predicate_without_inventing_callback_prose() {
let type_info = extract_file_metrics("src/service.rs", "struct Service;")
.expect("fixture should parse")
.types()
.first()
.expect("fixture should contain one type")
.clone();
let violation = Violation::from(crate::metrics::MetricPredicateViolation::new(
crate::metrics::MetricSubject::Type(type_info),
"method_count",
4.0,
));
let formatted = ViolationFactory::from_violation(&violation);
assert_eq!(formatted.message, "Metric predicate violation");
assert_eq!(
formatted.details,
"Metric 'method_count' for 'Service' with value 4 did not satisfy the custom predicate."
);
}
}