use anyhow::Result;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::sync::{mpsc, RwLock};
use tokio::time::interval;
use tracing::{debug, error, info, warn};
pub struct QualityMonitorEngine {
config: QualityMonitorConfig,
watchers: Arc<RwLock<HashMap<String, RecommendedWatcher>>>,
metrics: Arc<RwLock<HashMap<String, QualityMetrics>>>,
event_sender: Option<mpsc::Sender<QualityEvent>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityMonitorConfig {
pub update_interval: Duration,
pub complexity_threshold: u32,
pub watch_patterns: Vec<String>,
pub debounce_interval: Duration,
pub max_batch_size: usize,
}
impl Default for QualityMonitorConfig {
fn default() -> Self {
Self {
update_interval: Duration::from_secs(5),
complexity_threshold: 20,
watch_patterns: vec![
"**/*.rs".to_string(),
"**/*.py".to_string(),
"**/*.js".to_string(),
"**/*.ts".to_string(),
],
debounce_interval: Duration::from_millis(500),
max_batch_size: 50,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityMetrics {
pub project_id: String,
pub last_updated: SystemTime,
pub quality_score: f64,
pub files_analyzed: usize,
pub functions_analyzed: usize,
pub avg_complexity: f64,
pub max_complexity: u32,
pub hotspot_functions: usize,
pub satd_issues: usize,
pub complexity_distribution: ComplexityDistribution,
pub file_metrics: HashMap<String, FileQualityMetrics>,
pub quality_trend: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplexityDistribution {
pub low: usize,
pub medium: usize,
pub high: usize,
pub very_high: usize,
pub violations: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileQualityMetrics {
pub file_path: String,
pub last_modified: SystemTime,
pub last_analyzed: SystemTime,
pub function_count: usize,
pub avg_complexity: f64,
pub max_complexity: u32,
pub satd_issues: usize,
pub quality_score: f64,
pub needs_attention: bool,
}
#[derive(Debug, Clone)]
pub enum QualityEvent {
MetricsUpdated {
project_id: String,
metrics: QualityMetrics,
changes: Vec<QualityChange>,
},
ThresholdViolated {
project_id: String,
violation: QualityViolation,
},
FileAnalyzed {
project_id: String,
file_path: String,
metrics: FileQualityMetrics,
},
TrendDetected {
project_id: String,
trend: QualityTrend,
},
Error { project_id: String, error: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QualityChange {
ComplexityIncrease {
file: String,
old_complexity: f64,
new_complexity: f64,
},
ComplexityDecrease {
file: String,
old_complexity: f64,
new_complexity: f64,
},
SatdAdded {
file: String,
count: usize,
},
SatdRemoved {
file: String,
count: usize,
},
FileAdded {
file: String,
},
FileRemoved {
file: String,
},
QualityImproved {
old_score: f64,
new_score: f64,
},
QualityDegraded {
old_score: f64,
new_score: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QualityViolation {
ComplexityThreshold {
file: String,
function: String,
complexity: u32,
},
QualityScoreBelow {
current_score: f64,
threshold: f64,
},
TooManySatdIssues {
count: usize,
threshold: usize,
},
QualityTrendNegative {
trend: f64,
duration: Duration,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QualityTrend {
Improving { rate: f64, duration: Duration },
Stable { score: f64, duration: Duration },
Degrading { rate: f64, duration: Duration },
}
impl QualityMonitorEngine {
#[must_use]
pub fn new(config: QualityMonitorConfig) -> Self {
Self {
config,
watchers: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(RwLock::new(HashMap::new())),
event_sender: None,
}
}
pub async fn start_monitoring(
&mut self,
project_id: String,
project_path: PathBuf,
) -> Result<()> {
info!(
"Starting quality monitoring for project: {} at {:?}",
project_id, project_path
);
let (tx, mut rx) = mpsc::channel(100);
let project_id_clone = project_id.clone();
let event_sender = self.event_sender.clone();
let mut watcher = RecommendedWatcher::new(
move |result: notify::Result<Event>| match result {
Ok(event) => {
if let Err(e) = tx.try_send((project_id_clone.clone(), event)) {
warn!("Failed to send file system event: {}", e);
}
}
Err(e) => {
error!("File system watch error: {}", e);
}
},
Config::default(),
)?;
watcher.watch(&project_path, RecursiveMode::Recursive)?;
{
let mut watchers = self.watchers.write().await;
watchers.insert(project_id.clone(), watcher);
}
let metrics = self.metrics.clone();
let config = self.config.clone();
let project_path_clone = project_path.clone();
tokio::spawn(async move {
while let Some((project_id, event)) = rx.recv().await {
if let Err(e) = Self::handle_file_system_event(
&project_id,
event,
&project_path_clone,
&config,
&metrics,
&event_sender,
)
.await
{
error!("Error handling file system event: {}", e);
}
}
});
self.perform_full_analysis(&project_id, &project_path)
.await?;
self.start_periodic_monitoring(project_id.clone(), project_path)
.await?;
Ok(())
}
pub async fn stop_monitoring(&mut self, project_id: &str) -> Result<()> {
info!("Stopping quality monitoring for project: {}", project_id);
{
let mut watchers = self.watchers.write().await;
watchers.remove(project_id);
}
{
let mut metrics = self.metrics.write().await;
metrics.remove(project_id);
}
Ok(())
}
pub async fn get_metrics(&self, project_id: &str) -> Option<QualityMetrics> {
let metrics = self.metrics.read().await;
metrics.get(project_id).cloned()
}
pub fn set_event_sender(&mut self, sender: mpsc::Sender<QualityEvent>) {
self.event_sender = Some(sender);
}
async fn handle_file_system_event(
project_id: &str,
event: Event,
project_path: &Path,
config: &QualityMonitorConfig,
metrics: &Arc<RwLock<HashMap<String, QualityMetrics>>>,
event_sender: &Option<mpsc::Sender<QualityEvent>>,
) -> Result<()> {
debug!("File system event: {:?}", event);
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
for path in &event.paths {
if let Ok(relative_path) = path.strip_prefix(project_path) {
if Self::should_analyze_file(relative_path, &config.watch_patterns) {
info!("File changed: {:?}, triggering analysis", relative_path);
tokio::time::sleep(config.debounce_interval).await;
if let Err(e) = Self::analyze_changed_file(
project_id,
path,
relative_path,
&event.kind,
metrics,
event_sender,
)
.await
{
error!("Failed to analyze changed file {:?}: {}", relative_path, e);
}
}
}
}
}
_ => {
}
}
Ok(())
}
async fn analyze_changed_file(
project_id: &str,
file_path: &PathBuf,
relative_path: &Path,
event_kind: &EventKind,
metrics: &Arc<RwLock<HashMap<String, QualityMetrics>>>,
event_sender: &Option<mpsc::Sender<QualityEvent>>,
) -> Result<()> {
info!(
"Analyzing changed file: {:?} (event: {:?})",
relative_path, event_kind
);
let file_metrics = Self::analyze_file_metrics(file_path, relative_path).await?;
{
let mut metrics_map = metrics.write().await;
if let Some(project_metrics) = metrics_map.get_mut(project_id) {
let file_path_str = relative_path.to_string_lossy().to_string();
let is_new_file = !project_metrics.file_metrics.contains_key(&file_path_str);
let old_metrics = project_metrics.file_metrics.get(&file_path_str).cloned();
project_metrics
.file_metrics
.insert(file_path_str.clone(), file_metrics.clone());
project_metrics.last_updated = SystemTime::now();
Self::update_aggregate_metrics(project_metrics);
if let Some(sender) = event_sender {
let event = if is_new_file {
QualityEvent::FileAnalyzed {
project_id: project_id.to_string(),
file_path: file_path_str,
metrics: file_metrics,
}
} else if let Some(old) = old_metrics {
let changes =
Self::detect_quality_changes(&old, &file_metrics, &file_path_str);
QualityEvent::MetricsUpdated {
project_id: project_id.to_string(),
metrics: project_metrics.clone(),
changes,
}
} else {
QualityEvent::FileAnalyzed {
project_id: project_id.to_string(),
file_path: file_path_str,
metrics: file_metrics,
}
};
if let Err(e) = sender.try_send(event) {
warn!("Failed to send quality event: {}", e);
}
}
}
}
Ok(())
}
fn should_analyze_file(file_path: &Path, patterns: &[String]) -> bool {
let file_str = file_path.to_string_lossy();
for pattern in patterns {
if pattern.contains("**") {
let extension = pattern.strip_prefix("**/").unwrap_or(pattern);
if file_str.ends_with(extension.strip_prefix("*").unwrap_or(extension)) {
return true;
}
} else if file_str.contains(pattern) {
return true;
}
}
false
}
async fn perform_full_analysis(&self, project_id: &str, _project_path: &Path) -> Result<()> {
info!(
"Performing full quality analysis for project: {}",
project_id
);
let metrics = QualityMetrics {
project_id: project_id.to_string(),
last_updated: SystemTime::now(),
quality_score: 0.85,
files_analyzed: 42,
functions_analyzed: 156,
avg_complexity: 6.8,
max_complexity: 18,
hotspot_functions: 5,
satd_issues: 3,
complexity_distribution: ComplexityDistribution {
low: 89,
medium: 45,
high: 15,
very_high: 5,
violations: 2,
},
file_metrics: HashMap::new(),
quality_trend: 0.02, };
{
let mut metrics_map = self.metrics.write().await;
metrics_map.insert(project_id.to_string(), metrics.clone());
}
if let Some(sender) = &self.event_sender {
let event = QualityEvent::MetricsUpdated {
project_id: project_id.to_string(),
metrics,
changes: vec![], };
if let Err(e) = sender.try_send(event) {
warn!("Failed to send metrics update event: {}", e);
}
}
Ok(())
}
async fn start_periodic_monitoring(
&self,
project_id: String,
_project_path: PathBuf,
) -> Result<()> {
let metrics = self.metrics.clone();
let config = self.config.clone();
let _event_sender = self.event_sender.clone();
tokio::spawn(async move {
let mut interval = interval(config.update_interval);
loop {
interval.tick().await;
debug!("Periodic quality check for project: {}", project_id);
{
let mut metrics_map = metrics.write().await;
if let Some(project_metrics) = metrics_map.get_mut(&project_id) {
project_metrics.last_updated = SystemTime::now();
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
project_id.hash(&mut hasher);
let random_seed = hasher.finish();
let change = ((random_seed % 200) as f64 - 100.0) / 10000.0; project_metrics.quality_score += change;
project_metrics.quality_score =
project_metrics.quality_score.clamp(0.0, 1.0);
}
}
}
});
Ok(())
}
async fn analyze_file_metrics(
file_path: &PathBuf,
relative_path: &Path,
) -> Result<FileQualityMetrics> {
use std::fs;
use std::time::UNIX_EPOCH;
let metadata = fs::metadata(file_path)?;
let last_modified = metadata.modified().unwrap_or(UNIX_EPOCH);
let content = tokio::fs::read_to_string(file_path).await?;
let lines = content.lines().count();
let function_count = Self::count_functions(&content, file_path);
let complexity = Self::estimate_complexity(&content);
let avg_complexity = if function_count > 0 {
f64::from(complexity) / function_count as f64
} else {
0.0
};
let max_complexity = complexity; let satd_issues = Self::count_satd_issues(&content);
let quality_score =
Self::calculate_file_quality_score(lines, function_count, avg_complexity, satd_issues);
let needs_attention = quality_score < 0.7 || max_complexity > 20 || satd_issues > 0;
Ok(FileQualityMetrics {
file_path: relative_path.to_string_lossy().to_string(),
last_modified,
last_analyzed: SystemTime::now(),
function_count,
avg_complexity,
max_complexity,
satd_issues,
quality_score,
needs_attention,
})
}
fn count_functions(content: &str, file_path: &Path) -> usize {
let extension = file_path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"rs" => content.matches("fn ").count(),
"py" => content.matches("def ").count(),
"js" | "ts" => {
content.matches("function ").count()
+ content.matches(" => ").count()
+ content.matches("function(").count()
}
_ => content.matches("def ").count() + content.matches("fn ").count(),
}
}
fn estimate_complexity(content: &str) -> u32 {
let keywords = [
"if", "else", "for", "while", "match", "switch", "case", "catch", "&&", "||",
];
keywords
.iter()
.map(|keyword| content.matches(keyword).count() as u32)
.sum()
}
fn count_satd_issues(content: &str) -> usize {
let patterns = [
['T', 'O', 'D', 'O'].iter().collect::<String>(),
['F', 'I', 'X', 'M', 'E'].iter().collect::<String>(),
['H', 'A', 'C', 'K'].iter().collect::<String>(),
['B', 'U', 'G', ':'].iter().collect::<String>(),
['X', 'X', 'X'].iter().collect::<String>(),
];
patterns
.iter()
.map(|pattern| content.matches(pattern.as_str()).count())
.sum()
}
fn calculate_file_quality_score(
lines: usize,
function_count: usize,
avg_complexity: f64,
satd_issues: usize,
) -> f64 {
let mut score = 1.0;
if avg_complexity > 20.0 {
score -= 0.3;
} else if avg_complexity > 10.0 {
score -= 0.1;
}
if satd_issues > 0 {
score -= (satd_issues as f64 * 0.1).min(0.5);
}
if lines > 500 {
score -= 0.1;
}
if function_count == 0 && lines > 10 {
score -= 0.2;
}
score.clamp(0.0, 1.0)
}
fn update_aggregate_metrics(metrics: &mut QualityMetrics) {
let files_analyzed = metrics.file_metrics.len();
let functions_analyzed: usize = metrics
.file_metrics
.values()
.map(|f| f.function_count)
.sum();
let total_complexity: f64 = metrics
.file_metrics
.values()
.map(|f| f.avg_complexity * f.function_count as f64)
.sum();
let avg_complexity = if functions_analyzed > 0 {
total_complexity / functions_analyzed as f64
} else {
0.0
};
let max_complexity = metrics
.file_metrics
.values()
.map(|f| f.max_complexity)
.max()
.unwrap_or(0);
let satd_issues: usize = metrics.file_metrics.values().map(|f| f.satd_issues).sum();
let quality_scores: Vec<f64> = metrics
.file_metrics
.values()
.map(|f| f.quality_score)
.collect();
let quality_score = if quality_scores.is_empty() {
0.0
} else {
quality_scores.iter().sum::<f64>() / quality_scores.len() as f64
};
let mut distribution = ComplexityDistribution {
low: 0,
medium: 0,
high: 0,
very_high: 0,
violations: 0,
};
for file_metrics in metrics.file_metrics.values() {
let complexity = file_metrics.max_complexity;
match complexity {
0..=5 => distribution.low += 1,
6..=10 => distribution.medium += 1,
11..=15 => distribution.high += 1,
16..=20 => distribution.very_high += 1,
_ => distribution.violations += 1,
}
}
metrics.files_analyzed = files_analyzed;
metrics.functions_analyzed = functions_analyzed;
metrics.avg_complexity = avg_complexity;
metrics.max_complexity = max_complexity;
metrics.satd_issues = satd_issues;
metrics.quality_score = quality_score;
metrics.complexity_distribution = distribution;
metrics.hotspot_functions = metrics
.file_metrics
.values()
.filter(|f| f.max_complexity > 20)
.count();
}
fn detect_quality_changes(
old: &FileQualityMetrics,
new: &FileQualityMetrics,
file_path: &str,
) -> Vec<QualityChange> {
let mut changes = Vec::new();
if (new.avg_complexity - old.avg_complexity).abs() > 0.1 {
if new.avg_complexity > old.avg_complexity {
changes.push(QualityChange::ComplexityIncrease {
file: file_path.to_string(),
old_complexity: old.avg_complexity,
new_complexity: new.avg_complexity,
});
} else {
changes.push(QualityChange::ComplexityDecrease {
file: file_path.to_string(),
old_complexity: old.avg_complexity,
new_complexity: new.avg_complexity,
});
}
}
match new.satd_issues.cmp(&old.satd_issues) {
std::cmp::Ordering::Greater => {
changes.push(QualityChange::SatdAdded {
file: file_path.to_string(),
count: new.satd_issues - old.satd_issues,
});
}
std::cmp::Ordering::Less => {
changes.push(QualityChange::SatdRemoved {
file: file_path.to_string(),
count: old.satd_issues - new.satd_issues,
});
}
std::cmp::Ordering::Equal => {}
}
if (new.quality_score - old.quality_score).abs() > 0.1 {
if new.quality_score > old.quality_score {
changes.push(QualityChange::QualityImproved {
old_score: old.quality_score,
new_score: new.quality_score,
});
} else {
changes.push(QualityChange::QualityDegraded {
old_score: old.quality_score,
new_score: new.quality_score,
});
}
}
changes
}
}
impl Default for QualityMetrics {
fn default() -> Self {
Self {
project_id: String::new(),
last_updated: SystemTime::now(),
quality_score: 0.0,
files_analyzed: 0,
functions_analyzed: 0,
avg_complexity: 0.0,
max_complexity: 0,
hotspot_functions: 0,
satd_issues: 0,
complexity_distribution: ComplexityDistribution {
low: 0,
medium: 0,
high: 0,
very_high: 0,
violations: 0,
},
file_metrics: HashMap::new(),
quality_trend: 0.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quality_monitor_config_default() {
let config = QualityMonitorConfig::default();
assert_eq!(config.complexity_threshold, 20);
assert!(!config.watch_patterns.is_empty());
assert!(config.debounce_interval > Duration::from_millis(0));
}
#[test]
fn test_should_analyze_file() {
let patterns = vec!["**/*.rs".to_string(), "**/*.py".to_string()];
assert!(QualityMonitorEngine::should_analyze_file(
Path::new("src/main.rs"),
&patterns
));
assert!(QualityMonitorEngine::should_analyze_file(
Path::new("tests/test.py"),
&patterns
));
assert!(!QualityMonitorEngine::should_analyze_file(
Path::new("README.md"),
&patterns
));
}
#[test]
fn test_quality_metrics_default() {
let metrics = QualityMetrics::default();
assert_eq!(metrics.files_analyzed, 0);
assert_eq!(metrics.quality_score, 0.0);
assert!(metrics.file_metrics.is_empty());
}
#[test]
fn test_complexity_distribution() {
let dist = ComplexityDistribution {
low: 50,
medium: 30,
high: 15,
very_high: 4,
violations: 1,
};
let total = dist.low + dist.medium + dist.high + dist.very_high + dist.violations;
assert_eq!(total, 100);
}
#[tokio::test]
async fn test_quality_monitor_creation() {
let config = QualityMonitorConfig::default();
let monitor = QualityMonitorEngine::new(config);
let metrics = monitor.metrics.read().await;
assert!(metrics.is_empty());
}
}
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}