1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::scoring::{ProjectScore, QualityIssue};
16
17#[derive(Debug, Clone)]
19pub struct ProjectScanData {
20 pub path: String,
21 pub name: String,
22 pub score: ProjectScore,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ComparisonResult {
28 pub projects: Vec<ProjectSummary>,
29 pub rankings: Vec<ProjectRanking>,
30 pub common_issues: Vec<CommonIssue>,
31 pub aggregates: WorkspaceAggregates,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ProjectSummary {
37 pub path: String,
38 pub name: String,
39 pub score: f64,
40 pub traffic_light: String,
41 pub issue_count: usize,
42 pub last_scan: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ProjectRanking {
48 pub project_name: String,
49 pub rank: usize,
50 pub score: f64,
51 pub change_from_previous: Option<f64>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CommonIssue {
57 pub issue_id: String,
58 pub title: String,
59 pub category: String,
60 pub affected_projects: Vec<String>,
61 pub severity: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct WorkspaceAggregates {
67 pub avg_score: f64,
68 pub min_score: f64,
69 pub max_score: f64,
70 pub total_issues: usize,
71 pub projects_green: usize,
72 pub projects_yellow: usize,
73 pub projects_red: usize,
74}
75
76pub struct CrossProjectComparator;
78
79impl CrossProjectComparator {
80 pub fn compare(projects: &[ProjectScanData]) -> ComparisonResult {
82 if projects.is_empty() {
83 return ComparisonResult {
84 projects: vec![],
85 rankings: vec![],
86 common_issues: vec![],
87 aggregates: WorkspaceAggregates {
88 avg_score: 0.0,
89 min_score: 0.0,
90 max_score: 0.0,
91 total_issues: 0,
92 projects_green: 0,
93 projects_yellow: 0,
94 projects_red: 0,
95 },
96 };
97 }
98
99 let summaries = Self::build_summaries(projects);
100 let rankings = Self::build_rankings(projects);
101 let common_issues = Self::find_common_issues(projects);
102 let aggregates = Self::compute_aggregates(projects);
103
104 ComparisonResult {
105 projects: summaries,
106 rankings,
107 common_issues,
108 aggregates,
109 }
110 }
111
112 fn build_summaries(projects: &[ProjectScanData]) -> Vec<ProjectSummary> {
113 projects
114 .iter()
115 .map(|p| {
116 let tl = format!("{:?}", p.score.traffic_light).to_lowercase();
117 ProjectSummary {
118 path: p.path.clone(),
119 name: p.name.clone(),
120 score: (p.score.total * 100.0).round(),
121 traffic_light: tl,
122 issue_count: p.score.issues.len(),
123 last_scan: None,
124 }
125 })
126 .collect()
127 }
128
129 fn build_rankings(projects: &[ProjectScanData]) -> Vec<ProjectRanking> {
130 let mut scored: Vec<(&ProjectScanData, f64)> = projects
131 .iter()
132 .map(|p| (p, (p.score.total * 100.0).round()))
133 .collect();
134
135 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
137
138 scored
139 .iter()
140 .enumerate()
141 .map(|(i, (p, score))| ProjectRanking {
142 project_name: p.name.clone(),
143 rank: i + 1,
144 score: *score,
145 change_from_previous: None, })
147 .collect()
148 }
149
150 fn find_common_issues(projects: &[ProjectScanData]) -> Vec<CommonIssue> {
151 let mut issue_map: HashMap<String, (QualityIssue, Vec<String>)> = HashMap::new();
153
154 for project in projects {
155 for issue in &project.score.issues {
156 let entry = issue_map
157 .entry(issue.id.clone())
158 .or_insert_with(|| (issue.clone(), Vec::new()));
159 entry.1.push(project.name.clone());
160 }
161 }
162
163 let mut common: Vec<CommonIssue> = issue_map
165 .into_iter()
166 .filter(|(_, (_, affected))| affected.len() > 1)
167 .map(|(id, (issue, affected))| CommonIssue {
168 issue_id: id,
169 title: issue.title,
170 category: format!("{:?}", issue.category).to_lowercase(),
171 severity: format!("{:?}", issue.severity).to_lowercase(),
172 affected_projects: affected,
173 })
174 .collect();
175
176 common.sort_by(|a, b| {
178 let count_cmp = b.affected_projects.len().cmp(&a.affected_projects.len());
179 if count_cmp != std::cmp::Ordering::Equal {
180 return count_cmp;
181 }
182 severity_rank(&a.severity).cmp(&severity_rank(&b.severity))
183 });
184
185 common
186 }
187
188 fn compute_aggregates(projects: &[ProjectScanData]) -> WorkspaceAggregates {
189 let scores: Vec<f64> = projects
190 .iter()
191 .map(|p| (p.score.total * 100.0).round())
192 .collect();
193
194 let total_issues: usize = projects.iter().map(|p| p.score.issues.len()).sum();
195
196 let avg_score = scores.iter().sum::<f64>() / scores.len() as f64;
197 let min_score = scores
198 .iter()
199 .copied()
200 .fold(f64::INFINITY, f64::min);
201 let max_score = scores
202 .iter()
203 .copied()
204 .fold(f64::NEG_INFINITY, f64::max);
205
206 let mut green = 0;
207 let mut yellow = 0;
208 let mut red = 0;
209
210 for p in projects {
211 match p.score.traffic_light {
212 crate::scoring::TrafficLight::Green => green += 1,
213 crate::scoring::TrafficLight::Yellow => yellow += 1,
214 crate::scoring::TrafficLight::Red => red += 1,
215 }
216 }
217
218 WorkspaceAggregates {
219 avg_score: (avg_score * 10.0).round() / 10.0,
220 min_score,
221 max_score,
222 total_issues,
223 projects_green: green,
224 projects_yellow: yellow,
225 projects_red: red,
226 }
227 }
228}
229
230fn severity_rank(severity: &str) -> u8 {
232 match severity {
233 "blocking" => 0,
234 "high" => 1,
235 "medium" => 2,
236 "low" => 3,
237 _ => 4,
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::scoring::{
245 IssueCategory, IssueSeverity, ProjectScore, QualityIssue, TrafficLight,
246 };
247 use crate::scoring::types::ComponentScores;
248
249 fn make_issue(id: &str, title: &str, category: IssueCategory, severity: IssueSeverity) -> QualityIssue {
250 QualityIssue::new(
251 id.to_string(),
252 None,
253 category,
254 severity,
255 title.to_string(),
256 "test".to_string(),
257 None,
258 )
259 }
260
261 fn make_score(total: f64, issues: Vec<QualityIssue>) -> ProjectScore {
262 ProjectScore {
263 total,
264 components: ComponentScores {
265 freshness: total,
266 configuration: total,
267 integrity: total,
268 agent_setup: total,
269 structure: total,
270 },
271 traffic_light: TrafficLight::from_score(total),
272 issues,
273 }
274 }
275
276 fn make_project(name: &str, total: f64, issues: Vec<QualityIssue>) -> ProjectScanData {
277 ProjectScanData {
278 path: format!("/projects/{}", name),
279 name: name.to_string(),
280 score: make_score(total, issues),
281 }
282 }
283
284 #[test]
285 fn test_compare_empty() {
286 let result = CrossProjectComparator::compare(&[]);
287 assert!(result.projects.is_empty());
288 assert!(result.rankings.is_empty());
289 assert!(result.common_issues.is_empty());
290 assert!((result.aggregates.avg_score - 0.0).abs() < f64::EPSILON);
291 }
292
293 #[test]
294 fn test_compare_single_project() {
295 let projects = vec![make_project("alpha", 0.85, vec![])];
296
297 let result = CrossProjectComparator::compare(&projects);
298
299 assert_eq!(result.projects.len(), 1);
300 assert_eq!(result.projects[0].name, "alpha");
301 assert!((result.projects[0].score - 85.0).abs() < f64::EPSILON);
302
303 assert_eq!(result.rankings.len(), 1);
304 assert_eq!(result.rankings[0].rank, 1);
305
306 assert!(result.common_issues.is_empty()); assert!((result.aggregates.avg_score - 85.0).abs() < f64::EPSILON);
309 assert_eq!(result.aggregates.projects_green, 1);
310 }
311
312 #[test]
313 fn test_compare_multiple_projects_ranking() {
314 let projects = vec![
315 make_project("alpha", 0.90, vec![]),
316 make_project("beta", 0.60, vec![]),
317 make_project("gamma", 0.75, vec![]),
318 ];
319
320 let result = CrossProjectComparator::compare(&projects);
321
322 assert_eq!(result.rankings.len(), 3);
324 assert_eq!(result.rankings[0].project_name, "alpha");
325 assert_eq!(result.rankings[0].rank, 1);
326 assert_eq!(result.rankings[1].project_name, "gamma");
327 assert_eq!(result.rankings[1].rank, 2);
328 assert_eq!(result.rankings[2].project_name, "beta");
329 assert_eq!(result.rankings[2].rank, 3);
330 }
331
332 #[test]
333 fn test_compare_common_issues() {
334 let shared_issue = make_issue(
335 "missing-readme",
336 "Missing README",
337 IssueCategory::Configuration,
338 IssueSeverity::High,
339 );
340
341 let unique_issue = make_issue(
342 "stale-docs",
343 "Stale documentation",
344 IssueCategory::Freshness,
345 IssueSeverity::Medium,
346 );
347
348 let projects = vec![
349 make_project("alpha", 0.70, vec![shared_issue.clone(), unique_issue.clone()]),
350 make_project("beta", 0.60, vec![shared_issue.clone()]),
351 make_project("gamma", 0.80, vec![]),
352 ];
353
354 let result = CrossProjectComparator::compare(&projects);
355
356 assert_eq!(result.common_issues.len(), 1);
358 assert_eq!(result.common_issues[0].issue_id, "missing-readme");
359 assert_eq!(result.common_issues[0].affected_projects.len(), 2);
360 assert!(result.common_issues[0].affected_projects.contains(&"alpha".to_string()));
361 assert!(result.common_issues[0].affected_projects.contains(&"beta".to_string()));
362 }
363
364 #[test]
365 fn test_compare_aggregates() {
366 let projects = vec![
367 make_project("alpha", 0.90, vec![
368 make_issue("i1", "Issue 1", IssueCategory::Freshness, IssueSeverity::Low),
369 ]),
370 make_project("beta", 0.60, vec![
371 make_issue("i2", "Issue 2", IssueCategory::Integrity, IssueSeverity::High),
372 make_issue("i3", "Issue 3", IssueCategory::Structure, IssueSeverity::Medium),
373 ]),
374 make_project("gamma", 0.30, vec![]),
375 ];
376
377 let result = CrossProjectComparator::compare(&projects);
378
379 assert_eq!(result.aggregates.total_issues, 3);
380 assert!((result.aggregates.min_score - 30.0).abs() < f64::EPSILON);
381 assert!((result.aggregates.max_score - 90.0).abs() < f64::EPSILON);
382
383 assert!((result.aggregates.avg_score - 60.0).abs() < f64::EPSILON);
385
386 assert_eq!(result.aggregates.projects_green, 1); assert_eq!(result.aggregates.projects_yellow, 1); assert_eq!(result.aggregates.projects_red, 1); }
390
391 #[test]
392 fn test_compare_traffic_light_classification() {
393 let projects = vec![
394 make_project("green1", 0.85, vec![]),
395 make_project("green2", 0.95, vec![]),
396 make_project("yellow1", 0.55, vec![]),
397 make_project("red1", 0.20, vec![]),
398 ];
399
400 let result = CrossProjectComparator::compare(&projects);
401
402 assert_eq!(result.aggregates.projects_green, 2);
403 assert_eq!(result.aggregates.projects_yellow, 1);
404 assert_eq!(result.aggregates.projects_red, 1);
405 }
406
407 #[test]
408 fn test_common_issues_sorted_by_frequency() {
409 let issue_a = make_issue("issue-a", "Issue A", IssueCategory::Freshness, IssueSeverity::Low);
410 let issue_b = make_issue("issue-b", "Issue B", IssueCategory::Integrity, IssueSeverity::High);
411
412 let projects = vec![
413 make_project("p1", 0.70, vec![issue_a.clone(), issue_b.clone()]),
414 make_project("p2", 0.65, vec![issue_a.clone(), issue_b.clone()]),
415 make_project("p3", 0.60, vec![issue_a.clone()]),
416 ];
417
418 let result = CrossProjectComparator::compare(&projects);
419
420 assert_eq!(result.common_issues.len(), 2);
422 assert_eq!(result.common_issues[0].issue_id, "issue-a");
423 assert_eq!(result.common_issues[0].affected_projects.len(), 3);
424 assert_eq!(result.common_issues[1].issue_id, "issue-b");
425 assert_eq!(result.common_issues[1].affected_projects.len(), 2);
426 }
427
428 #[test]
429 fn test_project_summaries() {
430 let projects = vec![
431 make_project("alpha", 0.85, vec![
432 make_issue("i1", "Issue 1", IssueCategory::Freshness, IssueSeverity::Low),
433 make_issue("i2", "Issue 2", IssueCategory::Integrity, IssueSeverity::High),
434 ]),
435 ];
436
437 let result = CrossProjectComparator::compare(&projects);
438
439 assert_eq!(result.projects.len(), 1);
440 assert_eq!(result.projects[0].name, "alpha");
441 assert_eq!(result.projects[0].path, "/projects/alpha");
442 assert!((result.projects[0].score - 85.0).abs() < f64::EPSILON);
443 assert_eq!(result.projects[0].traffic_light, "green");
444 assert_eq!(result.projects[0].issue_count, 2);
445 }
446}