1use std::collections::HashMap;
36use std::path::PathBuf;
37use std::time::Instant;
38
39use stillwater::effect::writer::{tell, WriterEffect};
40use stillwater::{Monoid, Semigroup};
41
42use crate::core::types::Severity;
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub enum AnalysisPhase {
47 Discovery,
49 Parsing,
51 Complexity,
53 DebtDetection,
55 RiskAssessment,
57 Reporting,
59}
60
61impl AnalysisPhase {
62 pub fn display_name(&self) -> &'static str {
64 match self {
65 Self::Discovery => "Discovery",
66 Self::Parsing => "Parsing",
67 Self::Complexity => "Complexity Analysis",
68 Self::DebtDetection => "Debt Detection",
69 Self::RiskAssessment => "Risk Assessment",
70 Self::Reporting => "Report Generation",
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
80pub enum AnalysisEvent {
81 FileStarted {
83 path: PathBuf,
85 timestamp: Instant,
87 },
88
89 FileCompleted {
91 path: PathBuf,
93 duration_ms: u64,
95 },
96
97 FileFailed {
99 path: PathBuf,
101 error: String,
103 },
104
105 ParseComplete {
107 path: PathBuf,
109 function_count: usize,
111 },
112
113 ComplexityCalculated {
115 path: PathBuf,
117 cognitive: u32,
119 cyclomatic: u32,
121 },
122
123 DebtItemDetected {
125 path: PathBuf,
127 severity: Severity,
129 category: String,
131 },
132
133 PhaseStarted {
135 phase: AnalysisPhase,
137 timestamp: Instant,
139 },
140
141 PhaseCompleted {
143 phase: AnalysisPhase,
145 duration_ms: u64,
147 },
148}
149
150impl AnalysisEvent {
151 pub fn file_started(path: PathBuf) -> Self {
153 Self::FileStarted {
154 path,
155 timestamp: Instant::now(),
156 }
157 }
158
159 pub fn file_completed(path: PathBuf, duration_ms: u64) -> Self {
161 Self::FileCompleted { path, duration_ms }
162 }
163
164 pub fn file_failed(path: PathBuf, error: impl Into<String>) -> Self {
166 Self::FileFailed {
167 path,
168 error: error.into(),
169 }
170 }
171
172 pub fn parse_complete(path: PathBuf, function_count: usize) -> Self {
174 Self::ParseComplete {
175 path,
176 function_count,
177 }
178 }
179
180 pub fn complexity_calculated(path: PathBuf, cognitive: u32, cyclomatic: u32) -> Self {
182 Self::ComplexityCalculated {
183 path,
184 cognitive,
185 cyclomatic,
186 }
187 }
188
189 pub fn debt_detected(path: PathBuf, severity: Severity, category: impl Into<String>) -> Self {
191 Self::DebtItemDetected {
192 path,
193 severity,
194 category: category.into(),
195 }
196 }
197
198 pub fn phase_started(phase: AnalysisPhase) -> Self {
200 Self::PhaseStarted {
201 phase,
202 timestamp: Instant::now(),
203 }
204 }
205
206 pub fn phase_completed(phase: AnalysisPhase, duration_ms: u64) -> Self {
208 Self::PhaseCompleted { phase, duration_ms }
209 }
210}
211
212#[derive(Debug, Clone, Default)]
218pub struct AnalysisMetrics {
219 pub events: Vec<AnalysisEvent>,
221}
222
223impl AnalysisMetrics {
224 pub fn new() -> Self {
226 Self::default()
227 }
228
229 pub fn event(event: AnalysisEvent) -> Self {
231 Self {
232 events: vec![event],
233 }
234 }
235
236 pub fn events(events: Vec<AnalysisEvent>) -> Self {
238 Self { events }
239 }
240
241 pub fn len(&self) -> usize {
243 self.events.len()
244 }
245
246 pub fn is_empty(&self) -> bool {
248 self.events.is_empty()
249 }
250
251 pub fn iter(&self) -> impl Iterator<Item = &AnalysisEvent> {
253 self.events.iter()
254 }
255
256 pub fn filter<F>(&self, predicate: F) -> Self
258 where
259 F: Fn(&AnalysisEvent) -> bool,
260 {
261 Self {
262 events: self
263 .events
264 .iter()
265 .filter(|e| predicate(e))
266 .cloned()
267 .collect(),
268 }
269 }
270
271 pub fn file_started_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
273 self.events
274 .iter()
275 .filter(|e| matches!(e, AnalysisEvent::FileStarted { .. }))
276 }
277
278 pub fn file_completed_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
280 self.events
281 .iter()
282 .filter(|e| matches!(e, AnalysisEvent::FileCompleted { .. }))
283 }
284
285 pub fn debt_detected_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
287 self.events
288 .iter()
289 .filter(|e| matches!(e, AnalysisEvent::DebtItemDetected { .. }))
290 }
291}
292
293impl Semigroup for AnalysisMetrics {
297 fn combine(mut self, other: Self) -> Self {
298 self.events.extend(other.events);
299 self
300 }
301}
302
303impl Monoid for AnalysisMetrics {
307 fn empty() -> Self {
308 Self::default()
309 }
310}
311
312#[derive(Debug, Clone, Default)]
317pub struct AnalysisSummary {
318 pub files_processed: usize,
320 pub files_failed: usize,
322 pub total_duration_ms: u64,
324 pub total_functions: usize,
326 pub avg_cognitive_complexity: f64,
328 pub avg_cyclomatic_complexity: f64,
330 pub debt_items_by_severity: HashMap<Severity, usize>,
332 pub debt_items_by_category: HashMap<String, usize>,
334 pub phase_durations: HashMap<AnalysisPhase, u64>,
336}
337
338impl AnalysisSummary {
339 pub fn total_debt_items(&self) -> usize {
341 self.debt_items_by_severity.values().sum()
342 }
343
344 pub fn avg_file_duration_ms(&self) -> f64 {
346 if self.files_processed == 0 {
347 0.0
348 } else {
349 self.total_duration_ms as f64 / self.files_processed as f64
350 }
351 }
352}
353
354impl From<AnalysisMetrics> for AnalysisSummary {
355 fn from(metrics: AnalysisMetrics) -> Self {
356 let mut summary = AnalysisSummary::default();
357 let mut cognitive_sum: u64 = 0;
358 let mut cyclomatic_sum: u64 = 0;
359 let mut complexity_count: usize = 0;
360
361 for event in metrics.events {
362 match event {
363 AnalysisEvent::FileStarted { .. } => {
364 }
366 AnalysisEvent::FileCompleted { duration_ms, .. } => {
367 summary.files_processed += 1;
368 summary.total_duration_ms += duration_ms;
369 }
370 AnalysisEvent::FileFailed { .. } => {
371 summary.files_failed += 1;
372 }
373 AnalysisEvent::ParseComplete { function_count, .. } => {
374 summary.total_functions += function_count;
375 }
376 AnalysisEvent::ComplexityCalculated {
377 cognitive,
378 cyclomatic,
379 ..
380 } => {
381 cognitive_sum += cognitive as u64;
382 cyclomatic_sum += cyclomatic as u64;
383 complexity_count += 1;
384 }
385 AnalysisEvent::DebtItemDetected {
386 severity, category, ..
387 } => {
388 *summary.debt_items_by_severity.entry(severity).or_insert(0) += 1;
389 *summary.debt_items_by_category.entry(category).or_insert(0) += 1;
390 }
391 AnalysisEvent::PhaseStarted { .. } => {
392 }
394 AnalysisEvent::PhaseCompleted { phase, duration_ms } => {
395 summary.phase_durations.insert(phase, duration_ms);
396 }
397 }
398 }
399
400 if complexity_count > 0 {
402 summary.avg_cognitive_complexity = cognitive_sum as f64 / complexity_count as f64;
403 summary.avg_cyclomatic_complexity = cyclomatic_sum as f64 / complexity_count as f64;
404 }
405
406 summary
407 }
408}
409
410pub fn tell_event<E, Env>(
423 event: AnalysisEvent,
424) -> impl WriterEffect<Output = (), Error = E, Env = Env, Writes = AnalysisMetrics>
425where
426 E: Send + 'static,
427 Env: Clone + Send + Sync + 'static,
428{
429 tell(AnalysisMetrics::event(event))
430}
431
432pub fn tell_events<E, Env>(
447 events: Vec<AnalysisEvent>,
448) -> impl WriterEffect<Output = (), Error = E, Env = Env, Writes = AnalysisMetrics>
449where
450 E: Send + 'static,
451 Env: Clone + Send + Sync + 'static,
452{
453 tell(AnalysisMetrics::events(events))
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn analysis_metrics_monoid_empty() {
462 let empty = AnalysisMetrics::empty();
463 assert!(empty.is_empty());
464 }
465
466 #[test]
467 fn analysis_metrics_monoid_identity() {
468 let metrics = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("test.rs")));
469 let empty = AnalysisMetrics::empty();
470
471 let combined = metrics.clone().combine(empty.clone());
473 assert_eq!(combined.len(), 1);
474
475 let combined = empty.combine(metrics.clone());
477 assert_eq!(combined.len(), 1);
478 }
479
480 #[test]
481 fn analysis_metrics_semigroup_combine() {
482 let m1 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("a.rs")));
483 let m2 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("b.rs")));
484 let m3 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("c.rs")));
485
486 let combined = m1.clone().combine(m2.clone());
488 assert_eq!(combined.len(), 2);
489
490 let left = m1.clone().combine(m2.clone()).combine(m3.clone());
492 let right = m1.combine(m2.combine(m3));
493 assert_eq!(left.len(), right.len());
494 assert_eq!(left.len(), 3);
495 }
496
497 #[test]
498 fn analysis_summary_from_metrics() {
499 let metrics = AnalysisMetrics::events(vec![
500 AnalysisEvent::file_completed(PathBuf::from("a.rs"), 100),
501 AnalysisEvent::file_completed(PathBuf::from("b.rs"), 200),
502 AnalysisEvent::file_failed(PathBuf::from("c.rs"), "parse error"),
503 AnalysisEvent::parse_complete(PathBuf::from("a.rs"), 5),
504 AnalysisEvent::parse_complete(PathBuf::from("b.rs"), 10),
505 AnalysisEvent::complexity_calculated(PathBuf::from("a.rs"), 10, 8),
506 AnalysisEvent::complexity_calculated(PathBuf::from("b.rs"), 20, 16),
507 AnalysisEvent::debt_detected(PathBuf::from("a.rs"), Severity::Warning, "complexity"),
508 AnalysisEvent::debt_detected(PathBuf::from("b.rs"), Severity::Critical, "security"),
509 ]);
510
511 let summary: AnalysisSummary = metrics.into();
512
513 assert_eq!(summary.files_processed, 2);
514 assert_eq!(summary.files_failed, 1);
515 assert_eq!(summary.total_duration_ms, 300);
516 assert_eq!(summary.total_functions, 15);
517 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);
520 assert_eq!(
521 summary.debt_items_by_severity.get(&Severity::Warning),
522 Some(&1)
523 );
524 assert_eq!(
525 summary.debt_items_by_severity.get(&Severity::Critical),
526 Some(&1)
527 );
528 assert_eq!(summary.debt_items_by_category.get("complexity"), Some(&1));
529 assert_eq!(summary.debt_items_by_category.get("security"), Some(&1));
530 }
531
532 #[test]
533 fn analysis_summary_avg_file_duration() {
534 let summary = AnalysisSummary {
535 files_processed: 4,
536 total_duration_ms: 400,
537 ..Default::default()
538 };
539 assert!((summary.avg_file_duration_ms() - 100.0).abs() < 0.01);
540 }
541
542 #[test]
543 fn analysis_summary_avg_file_duration_empty() {
544 let summary = AnalysisSummary::default();
545 assert!((summary.avg_file_duration_ms()).abs() < 0.01);
546 }
547
548 #[test]
549 fn analysis_event_constructors() {
550 let path = PathBuf::from("test.rs");
551
552 let event = AnalysisEvent::file_started(path.clone());
553 assert!(matches!(event, AnalysisEvent::FileStarted { .. }));
554
555 let event = AnalysisEvent::file_completed(path.clone(), 100);
556 assert!(matches!(
557 event,
558 AnalysisEvent::FileCompleted {
559 duration_ms: 100,
560 ..
561 }
562 ));
563
564 let event = AnalysisEvent::file_failed(path.clone(), "error");
565 assert!(matches!(event, AnalysisEvent::FileFailed { .. }));
566
567 let event = AnalysisEvent::parse_complete(path.clone(), 5);
568 assert!(matches!(
569 event,
570 AnalysisEvent::ParseComplete {
571 function_count: 5,
572 ..
573 }
574 ));
575
576 let event = AnalysisEvent::complexity_calculated(path.clone(), 10, 8);
577 assert!(matches!(
578 event,
579 AnalysisEvent::ComplexityCalculated {
580 cognitive: 10,
581 cyclomatic: 8,
582 ..
583 }
584 ));
585
586 let event = AnalysisEvent::debt_detected(path.clone(), Severity::Warning, "complexity");
587 assert!(matches!(
588 event,
589 AnalysisEvent::DebtItemDetected {
590 severity: Severity::Warning,
591 ..
592 }
593 ));
594
595 let event = AnalysisEvent::phase_started(AnalysisPhase::Parsing);
596 assert!(matches!(
597 event,
598 AnalysisEvent::PhaseStarted {
599 phase: AnalysisPhase::Parsing,
600 ..
601 }
602 ));
603
604 let event = AnalysisEvent::phase_completed(AnalysisPhase::Complexity, 50);
605 assert!(matches!(
606 event,
607 AnalysisEvent::PhaseCompleted {
608 phase: AnalysisPhase::Complexity,
609 duration_ms: 50
610 }
611 ));
612 }
613
614 #[test]
615 fn analysis_metrics_filter() {
616 let metrics = AnalysisMetrics::events(vec![
617 AnalysisEvent::file_started(PathBuf::from("a.rs")),
618 AnalysisEvent::file_completed(PathBuf::from("a.rs"), 100),
619 AnalysisEvent::file_started(PathBuf::from("b.rs")),
620 AnalysisEvent::file_completed(PathBuf::from("b.rs"), 200),
621 ]);
622
623 let started_only = metrics.filter(|e| matches!(e, AnalysisEvent::FileStarted { .. }));
624 assert_eq!(started_only.len(), 2);
625
626 let completed_only = metrics.filter(|e| matches!(e, AnalysisEvent::FileCompleted { .. }));
627 assert_eq!(completed_only.len(), 2);
628 }
629
630 #[test]
631 fn analysis_phase_display_name() {
632 assert_eq!(AnalysisPhase::Discovery.display_name(), "Discovery");
633 assert_eq!(AnalysisPhase::Parsing.display_name(), "Parsing");
634 assert_eq!(
635 AnalysisPhase::Complexity.display_name(),
636 "Complexity Analysis"
637 );
638 assert_eq!(
639 AnalysisPhase::DebtDetection.display_name(),
640 "Debt Detection"
641 );
642 assert_eq!(
643 AnalysisPhase::RiskAssessment.display_name(),
644 "Risk Assessment"
645 );
646 assert_eq!(AnalysisPhase::Reporting.display_name(), "Report Generation");
647 }
648
649 #[tokio::test]
650 async fn writer_effect_collects_single_event() {
651 let effect = tell_event::<(), ()>(AnalysisEvent::file_started(PathBuf::from("test.rs")));
653 let (_, metrics) = effect.run_writer(&()).await;
654
655 assert_eq!(metrics.len(), 1);
656 assert!(metrics
657 .iter()
658 .any(|e| matches!(e, AnalysisEvent::FileStarted { .. })));
659 }
660
661 #[tokio::test]
662 async fn writer_effect_with_chained_events() {
663 use stillwater::EffectExt;
664
665 let effect1 = tell_event::<(), ()>(AnalysisEvent::file_started(PathBuf::from("test.rs")));
667 let effect2 =
668 tell_event::<(), ()>(AnalysisEvent::file_completed(PathBuf::from("test.rs"), 50));
669
670 let effect = effect1.and_then(|_| effect2);
672
673 let (_, metrics) = effect.run_writer(&()).await;
674 assert_eq!(metrics.len(), 2);
675
676 let summary: AnalysisSummary = metrics.into();
677 assert_eq!(summary.files_processed, 1);
678 assert_eq!(summary.total_duration_ms, 50);
679 }
680
681 #[tokio::test]
682 async fn writer_effect_tap_tell_accumulates() {
683 use stillwater::effect::writer::WriterEffectExt;
684
685 let effect = tell_event::<(), ()>(AnalysisEvent::phase_started(AnalysisPhase::Complexity))
687 .tap_tell(|_| {
688 AnalysisMetrics::event(AnalysisEvent::phase_completed(
689 AnalysisPhase::Complexity,
690 10,
691 ))
692 });
693
694 let (_, metrics) = effect.run_writer(&()).await;
695 assert_eq!(metrics.len(), 2);
696
697 let has_started = metrics.iter().any(|e| {
699 matches!(
700 e,
701 AnalysisEvent::PhaseStarted {
702 phase: AnalysisPhase::Complexity,
703 ..
704 }
705 )
706 });
707 let has_completed = metrics.iter().any(|e| {
708 matches!(
709 e,
710 AnalysisEvent::PhaseCompleted {
711 phase: AnalysisPhase::Complexity,
712 ..
713 }
714 )
715 });
716 assert!(has_started);
717 assert!(has_completed);
718 }
719}