Skip to main content

kardo_core/workspace/
comparison.rs

1// === RPIV: Cross-Project Comparison ===
2// Purpose: Compare multiple projects within a workspace, generate rankings and aggregate metrics
3// Related: workspace/mod.rs, scoring/types.rs
4// === END RPIV ===
5
6//! Cross-project comparison engine for workspace dashboards.
7//!
8//! Takes scan data from multiple projects and produces rankings, common issues,
9//! and aggregate statistics for the workspace overview.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::scoring::{ProjectScore, QualityIssue};
16
17/// Input data for a single project scan.
18#[derive(Debug, Clone)]
19pub struct ProjectScanData {
20    pub path: String,
21    pub name: String,
22    pub score: ProjectScore,
23}
24
25/// Full comparison result for a workspace.
26#[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/// Summary of a single project within the comparison.
35#[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/// Ranking entry for a project.
46#[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/// An issue that appears across multiple projects.
55#[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/// Aggregate metrics across all workspace projects.
65#[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
76/// Engine for comparing projects within a workspace.
77pub struct CrossProjectComparator;
78
79impl CrossProjectComparator {
80    /// Compare multiple projects and produce a full comparison result.
81    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        // Sort by score descending
136        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, // Historical comparison not available without DB
146            })
147            .collect()
148    }
149
150    fn find_common_issues(projects: &[ProjectScanData]) -> Vec<CommonIssue> {
151        // Group issues by ID across all projects
152        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        // Only include issues that appear in more than one project
164        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        // Sort by number of affected projects (most common first), then by severity
177        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
230/// Convert severity string to numeric rank for sorting (lower = more severe).
231fn 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()); // need 2+ projects for common issues
307
308        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        // Rankings should be sorted by score descending
323        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        // Only the shared issue should appear in common_issues
357        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        // avg = (90 + 60 + 30) / 3 = 60.0
384        assert!((result.aggregates.avg_score - 60.0).abs() < f64::EPSILON);
385
386        assert_eq!(result.aggregates.projects_green, 1); // alpha (90)
387        assert_eq!(result.aggregates.projects_yellow, 1); // beta (60)
388        assert_eq!(result.aggregates.projects_red, 1);    // gamma (30)
389    }
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        // issue_a affects 3 projects, issue_b affects 2
421        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}