1use crate::error::Result;
4use crate::models::{AgentOutput, Finding, Suggestion};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
9struct FindingWithSource {
10 finding: Finding,
12 agent_id: String,
14}
15
16#[derive(Debug, Clone)]
18struct SuggestionWithSource {
19 suggestion: Suggestion,
21 agent_id: String,
23}
24
25pub struct AgentCoordinator {
27 findings_by_source: HashMap<String, Vec<Finding>>,
29 suggestions_by_source: HashMap<String, Vec<Suggestion>>,
31}
32
33impl AgentCoordinator {
34 pub fn new() -> Self {
36 Self {
37 findings_by_source: HashMap::new(),
38 suggestions_by_source: HashMap::new(),
39 }
40 }
41
42 pub fn aggregate(&self, outputs: Vec<AgentOutput>) -> Result<AgentOutput> {
44 let mut aggregated = AgentOutput::default();
45 let mut findings_with_source = Vec::new();
46 let mut suggestions_with_source = Vec::new();
47
48 for output in outputs {
50 let agent_id = output.metadata.agent_id.clone();
51
52 for finding in output.findings {
53 findings_with_source.push(FindingWithSource {
54 finding,
55 agent_id: agent_id.clone(),
56 });
57 }
58
59 for suggestion in output.suggestions {
60 suggestions_with_source.push(SuggestionWithSource {
61 suggestion,
62 agent_id: agent_id.clone(),
63 });
64 }
65
66 aggregated.generated.extend(output.generated);
68 }
69
70 let deduplicated_findings = self.deduplicate_findings_with_source(findings_with_source);
72 aggregated.findings = deduplicated_findings;
73
74 let deduplicated_suggestions =
76 self.deduplicate_suggestions_with_source(suggestions_with_source);
77 aggregated.suggestions = deduplicated_suggestions;
78
79 aggregated
81 .findings
82 .sort_by(|a, b| b.severity.cmp(&a.severity));
83
84 Ok(aggregated)
85 }
86
87 fn deduplicate_findings_with_source(
89 &self,
90 findings_with_source: Vec<FindingWithSource>,
91 ) -> Vec<Finding> {
92 let mut seen = HashMap::new();
93 let mut deduplicated = Vec::new();
94
95 for item in findings_with_source {
96 let key = (item.finding.category.clone(), item.finding.message.clone());
97
98 match seen.entry(key) {
99 std::collections::hash_map::Entry::Occupied(mut entry) => {
100 let sources: &mut Vec<String> = entry.get_mut();
101 if !sources.contains(&item.agent_id) {
102 sources.push(item.agent_id);
103 }
104 }
105 std::collections::hash_map::Entry::Vacant(entry) => {
106 entry.insert(vec![item.agent_id]);
107 deduplicated.push(item.finding);
108 }
109 }
110 }
111
112 deduplicated
113 }
114
115 fn deduplicate_suggestions_with_source(
117 &self,
118 suggestions_with_source: Vec<SuggestionWithSource>,
119 ) -> Vec<Suggestion> {
120 let mut seen = HashMap::new();
121 let mut deduplicated = Vec::new();
122
123 for item in suggestions_with_source {
124 let key = item.suggestion.description.clone();
125
126 match seen.entry(key) {
127 std::collections::hash_map::Entry::Occupied(mut entry) => {
128 let sources: &mut Vec<String> = entry.get_mut();
129 if !sources.contains(&item.agent_id) {
130 sources.push(item.agent_id);
131 }
132 }
133 std::collections::hash_map::Entry::Vacant(entry) => {
134 entry.insert(vec![item.agent_id]);
135 deduplicated.push(item.suggestion);
136 }
137 }
138 }
139
140 deduplicated
141 }
142
143 #[allow(dead_code)]
146 fn deduplicate_findings(&self, findings: &mut Vec<Finding>) {
147 let mut seen = HashMap::new();
148 findings.retain(|finding| {
149 let key = (finding.category.clone(), finding.message.clone());
150 seen.insert(key, true).is_none()
151 });
152 }
153
154 pub fn resolve_conflicts(&self, findings: &[Finding]) -> Vec<Finding> {
162 if findings.is_empty() {
163 return Vec::new();
164 }
165
166 let mut findings_by_location: HashMap<String, Vec<Finding>> = HashMap::new();
168
169 for finding in findings {
170 let location_key = if let Some(loc) = &finding.location {
171 format!("{}:{}:{}", loc.file.display(), loc.line, loc.column)
172 } else {
173 format!("{}:{}", finding.category, finding.message)
174 };
175
176 findings_by_location
177 .entry(location_key)
178 .or_default()
179 .push(finding.clone());
180 }
181
182 let mut resolved = Vec::new();
184
185 for (_, location_findings) in findings_by_location {
186 if location_findings.is_empty() {
187 continue;
188 }
189
190 let max_severity = location_findings
192 .iter()
193 .map(|f| f.severity)
194 .max()
195 .unwrap_or(crate::models::Severity::Info);
196
197 for finding in location_findings {
199 if finding.severity == max_severity {
200 resolved.push(finding);
201 }
202 }
203 }
204
205 resolved
206 }
207
208 pub fn prioritize(&self, findings: &mut [Finding]) {
210 findings.sort_by(|a, b| b.severity.cmp(&a.severity));
211 }
212
213 pub fn findings_by_source(&self) -> &HashMap<String, Vec<Finding>> {
215 &self.findings_by_source
216 }
217
218 pub fn suggestions_by_source(&self) -> &HashMap<String, Vec<Suggestion>> {
220 &self.suggestions_by_source
221 }
222}
223
224impl Default for AgentCoordinator {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::models::{AgentMetadata, Severity};
234
235 fn create_test_finding(id: &str, severity: Severity) -> Finding {
236 Finding {
237 id: id.to_string(),
238 severity,
239 category: "test".to_string(),
240 message: "test message".to_string(),
241 location: None,
242 suggestion: None,
243 }
244 }
245
246 fn create_test_output_with_agent(agent_id: &str, findings: Vec<Finding>) -> AgentOutput {
247 let mut output = AgentOutput::default();
248 output.findings = findings;
249 output.metadata = AgentMetadata {
250 agent_id: agent_id.to_string(),
251 execution_time_ms: 100,
252 tokens_used: 50,
253 };
254 output
255 }
256
257 #[test]
258 fn test_aggregate_empty() {
259 let coordinator = AgentCoordinator::new();
260 let result = coordinator.aggregate(vec![]).unwrap();
261 assert_eq!(result.findings.len(), 0);
262 }
263
264 #[test]
265 fn test_aggregate_single_output() {
266 let coordinator = AgentCoordinator::new();
267 let output = create_test_output_with_agent(
268 "agent1",
269 vec![create_test_finding("f1", Severity::Warning)],
270 );
271
272 let result = coordinator.aggregate(vec![output]).unwrap();
273 assert_eq!(result.findings.len(), 1);
274 }
275
276 #[test]
277 fn test_aggregate_multiple_outputs() {
278 let coordinator = AgentCoordinator::new();
279 let output1 = create_test_output_with_agent(
280 "agent1",
281 vec![Finding {
282 id: "f1".to_string(),
283 severity: Severity::Warning,
284 category: "category1".to_string(),
285 message: "message1".to_string(),
286 location: None,
287 suggestion: None,
288 }],
289 );
290
291 let output2 = create_test_output_with_agent(
292 "agent2",
293 vec![Finding {
294 id: "f2".to_string(),
295 severity: Severity::Critical,
296 category: "category2".to_string(),
297 message: "message2".to_string(),
298 location: None,
299 suggestion: None,
300 }],
301 );
302
303 let result = coordinator.aggregate(vec![output1, output2]).unwrap();
304 assert_eq!(result.findings.len(), 2);
305 }
306
307 #[test]
308 fn test_deduplicate_findings() {
309 let coordinator = AgentCoordinator::new();
310 let mut findings = vec![
311 create_test_finding("f1", Severity::Warning),
312 create_test_finding("f2", Severity::Warning),
313 ];
314
315 coordinator.deduplicate_findings(&mut findings);
316 assert_eq!(findings.len(), 1);
318 }
319
320 #[test]
321 fn test_prioritize_findings() {
322 let coordinator = AgentCoordinator::new();
323 let mut findings = vec![
324 create_test_finding("f1", Severity::Info),
325 create_test_finding("f2", Severity::Critical),
326 create_test_finding("f3", Severity::Warning),
327 ];
328
329 coordinator.prioritize(&mut findings);
330 assert_eq!(findings[0].severity, Severity::Critical);
331 assert_eq!(findings[1].severity, Severity::Warning);
332 assert_eq!(findings[2].severity, Severity::Info);
333 }
334
335 #[test]
336 fn test_deduplicate_findings_with_source() {
337 let coordinator = AgentCoordinator::new();
338 let output1 = create_test_output_with_agent(
339 "agent1",
340 vec![Finding {
341 id: "f1".to_string(),
342 severity: Severity::Warning,
343 category: "quality".to_string(),
344 message: "naming issue".to_string(),
345 location: None,
346 suggestion: None,
347 }],
348 );
349
350 let output2 = create_test_output_with_agent(
351 "agent2",
352 vec![Finding {
353 id: "f2".to_string(),
354 severity: Severity::Warning,
355 category: "quality".to_string(),
356 message: "naming issue".to_string(),
357 location: None,
358 suggestion: None,
359 }],
360 );
361
362 let result = coordinator.aggregate(vec![output1, output2]).unwrap();
363 assert_eq!(result.findings.len(), 1);
365 }
366
367 #[test]
368 fn test_aggregate_with_suggestions() {
369 let coordinator = AgentCoordinator::new();
370 let mut output1 = create_test_output_with_agent("agent1", vec![]);
371 output1.suggestions.push(Suggestion {
372 id: "s1".to_string(),
373 description: "Use better naming".to_string(),
374 diff: None,
375 auto_fixable: true,
376 });
377
378 let mut output2 = create_test_output_with_agent("agent2", vec![]);
379 output2.suggestions.push(Suggestion {
380 id: "s2".to_string(),
381 description: "Use better naming".to_string(),
382 diff: None,
383 auto_fixable: true,
384 });
385
386 let result = coordinator.aggregate(vec![output1, output2]).unwrap();
387 assert_eq!(result.suggestions.len(), 1);
389 }
390
391 #[test]
392 fn test_aggregate_preserves_severity_order() {
393 let coordinator = AgentCoordinator::new();
394 let mut f1 = create_test_finding("f1", Severity::Info);
395 f1.category = "cat1".to_string();
396 f1.message = "msg1".to_string();
397
398 let mut f2 = create_test_finding("f2", Severity::Critical);
399 f2.category = "cat2".to_string();
400 f2.message = "msg2".to_string();
401
402 let mut f3 = create_test_finding("f3", Severity::Warning);
403 f3.category = "cat3".to_string();
404 f3.message = "msg3".to_string();
405
406 let output = create_test_output_with_agent("agent1", vec![f1, f2, f3]);
407
408 let result = coordinator.aggregate(vec![output]).unwrap();
409 assert_eq!(result.findings[0].severity, Severity::Critical);
410 assert_eq!(result.findings[1].severity, Severity::Warning);
411 assert_eq!(result.findings[2].severity, Severity::Info);
412 }
413
414 #[test]
415 fn test_resolve_conflicts() {
416 let coordinator = AgentCoordinator::new();
417 let mut f1 = create_test_finding("f1", Severity::Warning);
418 f1.category = "category1".to_string();
419 f1.message = "message1".to_string();
420
421 let mut f2 = create_test_finding("f2", Severity::Critical);
422 f2.category = "category2".to_string();
423 f2.message = "message2".to_string();
424
425 let findings = vec![f1, f2];
426 let resolved = coordinator.resolve_conflicts(&findings);
427 assert_eq!(resolved.len(), 2);
429 }
430
431 #[test]
432 fn test_resolve_conflicts_prioritizes_severity() {
433 let coordinator = AgentCoordinator::new();
434 let mut f1 = create_test_finding("f1", Severity::Info);
435 f1.location = Some(crate::models::CodeLocation {
436 file: std::path::PathBuf::from("test.rs"),
437 line: 10,
438 column: 5,
439 });
440
441 let mut f2 = create_test_finding("f2", Severity::Critical);
442 f2.location = Some(crate::models::CodeLocation {
443 file: std::path::PathBuf::from("test.rs"),
444 line: 10,
445 column: 5,
446 });
447
448 let findings = vec![f1, f2];
449 let resolved = coordinator.resolve_conflicts(&findings);
450
451 assert_eq!(resolved.len(), 1);
453 assert_eq!(resolved[0].severity, Severity::Critical);
454 }
455
456 #[test]
457 fn test_resolve_conflicts_keeps_same_severity() {
458 let coordinator = AgentCoordinator::new();
459 let mut f1 = create_test_finding("f1", Severity::Warning);
460 f1.location = Some(crate::models::CodeLocation {
461 file: std::path::PathBuf::from("test.rs"),
462 line: 10,
463 column: 5,
464 });
465
466 let mut f2 = create_test_finding("f2", Severity::Warning);
467 f2.location = Some(crate::models::CodeLocation {
468 file: std::path::PathBuf::from("test.rs"),
469 line: 10,
470 column: 5,
471 });
472
473 let findings = vec![f1, f2];
474 let resolved = coordinator.resolve_conflicts(&findings);
475
476 assert_eq!(resolved.len(), 2);
478 }
479
480 #[test]
481 fn test_resolve_conflicts_different_locations() {
482 let coordinator = AgentCoordinator::new();
483 let mut f1 = create_test_finding("f1", Severity::Warning);
484 f1.location = Some(crate::models::CodeLocation {
485 file: std::path::PathBuf::from("test.rs"),
486 line: 10,
487 column: 5,
488 });
489
490 let mut f2 = create_test_finding("f2", Severity::Critical);
491 f2.location = Some(crate::models::CodeLocation {
492 file: std::path::PathBuf::from("test.rs"),
493 line: 20,
494 column: 5,
495 });
496
497 let findings = vec![f1, f2];
498 let resolved = coordinator.resolve_conflicts(&findings);
499
500 assert_eq!(resolved.len(), 2);
502 }
503
504 #[test]
505 fn test_aggregate_with_generated_content() {
506 let coordinator = AgentCoordinator::new();
507 let mut output = create_test_output_with_agent("agent1", vec![]);
508 output.generated.push(crate::models::GeneratedContent {
509 file: std::path::PathBuf::from("test.rs"),
510 content: "// generated".to_string(),
511 });
512
513 let result = coordinator.aggregate(vec![output]).unwrap();
514 assert_eq!(result.generated.len(), 1);
515 }
516}