use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use stillwater::effect::writer::{tell, WriterEffect};
use stillwater::{Monoid, Semigroup};
use crate::core::types::Severity;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AnalysisPhase {
Discovery,
Parsing,
Complexity,
DebtDetection,
RiskAssessment,
Reporting,
}
impl AnalysisPhase {
pub fn display_name(&self) -> &'static str {
match self {
Self::Discovery => "Discovery",
Self::Parsing => "Parsing",
Self::Complexity => "Complexity Analysis",
Self::DebtDetection => "Debt Detection",
Self::RiskAssessment => "Risk Assessment",
Self::Reporting => "Report Generation",
}
}
}
#[derive(Debug, Clone)]
pub enum AnalysisEvent {
FileStarted {
path: PathBuf,
timestamp: Instant,
},
FileCompleted {
path: PathBuf,
duration_ms: u64,
},
FileFailed {
path: PathBuf,
error: String,
},
ParseComplete {
path: PathBuf,
function_count: usize,
},
ComplexityCalculated {
path: PathBuf,
cognitive: u32,
cyclomatic: u32,
},
DebtItemDetected {
path: PathBuf,
severity: Severity,
category: String,
},
PhaseStarted {
phase: AnalysisPhase,
timestamp: Instant,
},
PhaseCompleted {
phase: AnalysisPhase,
duration_ms: u64,
},
}
impl AnalysisEvent {
pub fn file_started(path: PathBuf) -> Self {
Self::FileStarted {
path,
timestamp: Instant::now(),
}
}
pub fn file_completed(path: PathBuf, duration_ms: u64) -> Self {
Self::FileCompleted { path, duration_ms }
}
pub fn file_failed(path: PathBuf, error: impl Into<String>) -> Self {
Self::FileFailed {
path,
error: error.into(),
}
}
pub fn parse_complete(path: PathBuf, function_count: usize) -> Self {
Self::ParseComplete {
path,
function_count,
}
}
pub fn complexity_calculated(path: PathBuf, cognitive: u32, cyclomatic: u32) -> Self {
Self::ComplexityCalculated {
path,
cognitive,
cyclomatic,
}
}
pub fn debt_detected(path: PathBuf, severity: Severity, category: impl Into<String>) -> Self {
Self::DebtItemDetected {
path,
severity,
category: category.into(),
}
}
pub fn phase_started(phase: AnalysisPhase) -> Self {
Self::PhaseStarted {
phase,
timestamp: Instant::now(),
}
}
pub fn phase_completed(phase: AnalysisPhase, duration_ms: u64) -> Self {
Self::PhaseCompleted { phase, duration_ms }
}
}
#[derive(Debug, Clone, Default)]
pub struct AnalysisMetrics {
pub events: Vec<AnalysisEvent>,
}
impl AnalysisMetrics {
pub fn new() -> Self {
Self::default()
}
pub fn event(event: AnalysisEvent) -> Self {
Self {
events: vec![event],
}
}
pub fn events(events: Vec<AnalysisEvent>) -> Self {
Self { events }
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &AnalysisEvent> {
self.events.iter()
}
pub fn filter<F>(&self, predicate: F) -> Self
where
F: Fn(&AnalysisEvent) -> bool,
{
Self {
events: self
.events
.iter()
.filter(|e| predicate(e))
.cloned()
.collect(),
}
}
pub fn file_started_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
self.events
.iter()
.filter(|e| matches!(e, AnalysisEvent::FileStarted { .. }))
}
pub fn file_completed_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
self.events
.iter()
.filter(|e| matches!(e, AnalysisEvent::FileCompleted { .. }))
}
pub fn debt_detected_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
self.events
.iter()
.filter(|e| matches!(e, AnalysisEvent::DebtItemDetected { .. }))
}
}
impl Semigroup for AnalysisMetrics {
fn combine(mut self, other: Self) -> Self {
self.events.extend(other.events);
self
}
}
impl Monoid for AnalysisMetrics {
fn empty() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Default)]
pub struct AnalysisSummary {
pub files_processed: usize,
pub files_failed: usize,
pub total_duration_ms: u64,
pub total_functions: usize,
pub avg_cognitive_complexity: f64,
pub avg_cyclomatic_complexity: f64,
pub debt_items_by_severity: HashMap<Severity, usize>,
pub debt_items_by_category: HashMap<String, usize>,
pub phase_durations: HashMap<AnalysisPhase, u64>,
}
impl AnalysisSummary {
pub fn total_debt_items(&self) -> usize {
self.debt_items_by_severity.values().sum()
}
pub fn avg_file_duration_ms(&self) -> f64 {
if self.files_processed == 0 {
0.0
} else {
self.total_duration_ms as f64 / self.files_processed as f64
}
}
}
impl From<AnalysisMetrics> for AnalysisSummary {
fn from(metrics: AnalysisMetrics) -> Self {
let mut summary = AnalysisSummary::default();
let mut cognitive_sum: u64 = 0;
let mut cyclomatic_sum: u64 = 0;
let mut complexity_count: usize = 0;
for event in metrics.events {
match event {
AnalysisEvent::FileStarted { .. } => {
}
AnalysisEvent::FileCompleted { duration_ms, .. } => {
summary.files_processed += 1;
summary.total_duration_ms += duration_ms;
}
AnalysisEvent::FileFailed { .. } => {
summary.files_failed += 1;
}
AnalysisEvent::ParseComplete { function_count, .. } => {
summary.total_functions += function_count;
}
AnalysisEvent::ComplexityCalculated {
cognitive,
cyclomatic,
..
} => {
cognitive_sum += cognitive as u64;
cyclomatic_sum += cyclomatic as u64;
complexity_count += 1;
}
AnalysisEvent::DebtItemDetected {
severity, category, ..
} => {
*summary.debt_items_by_severity.entry(severity).or_insert(0) += 1;
*summary.debt_items_by_category.entry(category).or_insert(0) += 1;
}
AnalysisEvent::PhaseStarted { .. } => {
}
AnalysisEvent::PhaseCompleted { phase, duration_ms } => {
summary.phase_durations.insert(phase, duration_ms);
}
}
}
if complexity_count > 0 {
summary.avg_cognitive_complexity = cognitive_sum as f64 / complexity_count as f64;
summary.avg_cyclomatic_complexity = cyclomatic_sum as f64 / complexity_count as f64;
}
summary
}
}
pub fn tell_event<E, Env>(
event: AnalysisEvent,
) -> impl WriterEffect<Output = (), Error = E, Env = Env, Writes = AnalysisMetrics>
where
E: Send + 'static,
Env: Clone + Send + Sync + 'static,
{
tell(AnalysisMetrics::event(event))
}
pub fn tell_events<E, Env>(
events: Vec<AnalysisEvent>,
) -> impl WriterEffect<Output = (), Error = E, Env = Env, Writes = AnalysisMetrics>
where
E: Send + 'static,
Env: Clone + Send + Sync + 'static,
{
tell(AnalysisMetrics::events(events))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn analysis_metrics_monoid_empty() {
let empty = AnalysisMetrics::empty();
assert!(empty.is_empty());
}
#[test]
fn analysis_metrics_monoid_identity() {
let metrics = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("test.rs")));
let empty = AnalysisMetrics::empty();
let combined = metrics.clone().combine(empty.clone());
assert_eq!(combined.len(), 1);
let combined = empty.combine(metrics.clone());
assert_eq!(combined.len(), 1);
}
#[test]
fn analysis_metrics_semigroup_combine() {
let m1 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("a.rs")));
let m2 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("b.rs")));
let m3 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("c.rs")));
let combined = m1.clone().combine(m2.clone());
assert_eq!(combined.len(), 2);
let left = m1.clone().combine(m2.clone()).combine(m3.clone());
let right = m1.combine(m2.combine(m3));
assert_eq!(left.len(), right.len());
assert_eq!(left.len(), 3);
}
#[test]
fn analysis_summary_from_metrics() {
let metrics = AnalysisMetrics::events(vec![
AnalysisEvent::file_completed(PathBuf::from("a.rs"), 100),
AnalysisEvent::file_completed(PathBuf::from("b.rs"), 200),
AnalysisEvent::file_failed(PathBuf::from("c.rs"), "parse error"),
AnalysisEvent::parse_complete(PathBuf::from("a.rs"), 5),
AnalysisEvent::parse_complete(PathBuf::from("b.rs"), 10),
AnalysisEvent::complexity_calculated(PathBuf::from("a.rs"), 10, 8),
AnalysisEvent::complexity_calculated(PathBuf::from("b.rs"), 20, 16),
AnalysisEvent::debt_detected(PathBuf::from("a.rs"), Severity::Warning, "complexity"),
AnalysisEvent::debt_detected(PathBuf::from("b.rs"), Severity::Critical, "security"),
]);
let summary: AnalysisSummary = metrics.into();
assert_eq!(summary.files_processed, 2);
assert_eq!(summary.files_failed, 1);
assert_eq!(summary.total_duration_ms, 300);
assert_eq!(summary.total_functions, 15);
assert!((summary.avg_cognitive_complexity - 15.0).abs() < 0.01); assert!((summary.avg_cyclomatic_complexity - 12.0).abs() < 0.01); assert_eq!(summary.total_debt_items(), 2);
assert_eq!(
summary.debt_items_by_severity.get(&Severity::Warning),
Some(&1)
);
assert_eq!(
summary.debt_items_by_severity.get(&Severity::Critical),
Some(&1)
);
assert_eq!(summary.debt_items_by_category.get("complexity"), Some(&1));
assert_eq!(summary.debt_items_by_category.get("security"), Some(&1));
}
#[test]
fn analysis_summary_avg_file_duration() {
let summary = AnalysisSummary {
files_processed: 4,
total_duration_ms: 400,
..Default::default()
};
assert!((summary.avg_file_duration_ms() - 100.0).abs() < 0.01);
}
#[test]
fn analysis_summary_avg_file_duration_empty() {
let summary = AnalysisSummary::default();
assert!((summary.avg_file_duration_ms()).abs() < 0.01);
}
#[test]
fn analysis_event_constructors() {
let path = PathBuf::from("test.rs");
let event = AnalysisEvent::file_started(path.clone());
assert!(matches!(event, AnalysisEvent::FileStarted { .. }));
let event = AnalysisEvent::file_completed(path.clone(), 100);
assert!(matches!(
event,
AnalysisEvent::FileCompleted {
duration_ms: 100,
..
}
));
let event = AnalysisEvent::file_failed(path.clone(), "error");
assert!(matches!(event, AnalysisEvent::FileFailed { .. }));
let event = AnalysisEvent::parse_complete(path.clone(), 5);
assert!(matches!(
event,
AnalysisEvent::ParseComplete {
function_count: 5,
..
}
));
let event = AnalysisEvent::complexity_calculated(path.clone(), 10, 8);
assert!(matches!(
event,
AnalysisEvent::ComplexityCalculated {
cognitive: 10,
cyclomatic: 8,
..
}
));
let event = AnalysisEvent::debt_detected(path.clone(), Severity::Warning, "complexity");
assert!(matches!(
event,
AnalysisEvent::DebtItemDetected {
severity: Severity::Warning,
..
}
));
let event = AnalysisEvent::phase_started(AnalysisPhase::Parsing);
assert!(matches!(
event,
AnalysisEvent::PhaseStarted {
phase: AnalysisPhase::Parsing,
..
}
));
let event = AnalysisEvent::phase_completed(AnalysisPhase::Complexity, 50);
assert!(matches!(
event,
AnalysisEvent::PhaseCompleted {
phase: AnalysisPhase::Complexity,
duration_ms: 50
}
));
}
#[test]
fn analysis_metrics_filter() {
let metrics = AnalysisMetrics::events(vec![
AnalysisEvent::file_started(PathBuf::from("a.rs")),
AnalysisEvent::file_completed(PathBuf::from("a.rs"), 100),
AnalysisEvent::file_started(PathBuf::from("b.rs")),
AnalysisEvent::file_completed(PathBuf::from("b.rs"), 200),
]);
let started_only = metrics.filter(|e| matches!(e, AnalysisEvent::FileStarted { .. }));
assert_eq!(started_only.len(), 2);
let completed_only = metrics.filter(|e| matches!(e, AnalysisEvent::FileCompleted { .. }));
assert_eq!(completed_only.len(), 2);
}
#[test]
fn analysis_phase_display_name() {
assert_eq!(AnalysisPhase::Discovery.display_name(), "Discovery");
assert_eq!(AnalysisPhase::Parsing.display_name(), "Parsing");
assert_eq!(
AnalysisPhase::Complexity.display_name(),
"Complexity Analysis"
);
assert_eq!(
AnalysisPhase::DebtDetection.display_name(),
"Debt Detection"
);
assert_eq!(
AnalysisPhase::RiskAssessment.display_name(),
"Risk Assessment"
);
assert_eq!(AnalysisPhase::Reporting.display_name(), "Report Generation");
}
#[tokio::test]
async fn writer_effect_collects_single_event() {
let effect = tell_event::<(), ()>(AnalysisEvent::file_started(PathBuf::from("test.rs")));
let (_, metrics) = effect.run_writer(&()).await;
assert_eq!(metrics.len(), 1);
assert!(metrics
.iter()
.any(|e| matches!(e, AnalysisEvent::FileStarted { .. })));
}
#[tokio::test]
async fn writer_effect_with_chained_events() {
use stillwater::EffectExt;
let effect1 = tell_event::<(), ()>(AnalysisEvent::file_started(PathBuf::from("test.rs")));
let effect2 =
tell_event::<(), ()>(AnalysisEvent::file_completed(PathBuf::from("test.rs"), 50));
let effect = effect1.and_then(|_| effect2);
let (_, metrics) = effect.run_writer(&()).await;
assert_eq!(metrics.len(), 2);
let summary: AnalysisSummary = metrics.into();
assert_eq!(summary.files_processed, 1);
assert_eq!(summary.total_duration_ms, 50);
}
#[tokio::test]
async fn writer_effect_tap_tell_accumulates() {
use stillwater::effect::writer::WriterEffectExt;
let effect = tell_event::<(), ()>(AnalysisEvent::phase_started(AnalysisPhase::Complexity))
.tap_tell(|_| {
AnalysisMetrics::event(AnalysisEvent::phase_completed(
AnalysisPhase::Complexity,
10,
))
});
let (_, metrics) = effect.run_writer(&()).await;
assert_eq!(metrics.len(), 2);
let has_started = metrics.iter().any(|e| {
matches!(
e,
AnalysisEvent::PhaseStarted {
phase: AnalysisPhase::Complexity,
..
}
)
});
let has_completed = metrics.iter().any(|e| {
matches!(
e,
AnalysisEvent::PhaseCompleted {
phase: AnalysisPhase::Complexity,
..
}
)
});
assert!(has_started);
assert!(has_completed);
}
}