1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use tokio::sync::mpsc;
5
6use crate::core::review::{CommitStatus, Issue};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AnalysisRequest {
10 pub file_path: String,
11 pub content: String,
12 pub language: String,
13 pub commit_status: CommitStatus,
14}
15
16#[derive(Debug, Clone)]
17pub struct ProgressUpdate {
18 pub current_file: String,
19 pub progress: f64,
20 pub stage: String,
21}
22
23#[derive(Debug, Clone, PartialEq)]
24pub enum GpuBackend {
25 Metal,
26 Cuda,
27 Mkl,
28 Cpu,
29}
30
31impl std::fmt::Display for GpuBackend {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 GpuBackend::Metal => write!(f, "Metal"),
35 GpuBackend::Cuda => write!(f, "CUDA"),
36 GpuBackend::Mkl => write!(f, "MKL"),
37 GpuBackend::Cpu => write!(f, "CPU"),
38 }
39 }
40}
41
42pub struct AIAnalyzer {
43 backend: GpuBackend,
44 enable_ai: bool,
45}
46
47impl AIAnalyzer {
48 pub async fn new(use_gpu: bool, enable_ai: bool) -> Result<Self> {
49 println!("š§ Initializing AI analyzer...");
50
51 let backend = if use_gpu {
53 Self::detect_gpu_backend()
54 } else {
55 GpuBackend::Cpu
56 };
57
58 println!("š§ Using backend: {backend:?}");
59
60 if enable_ai {
61 println!("š¤ AI inference enabled - using advanced AI analysis");
62 } else {
63 println!("ļæ½ AI inference disabled - using rule-based analysis only");
64 }
65
66 let analyzer = AIAnalyzer { backend, enable_ai };
67
68 println!(
70 "š§ AI Analyzer initialized with {} backend",
71 analyzer.get_backend()
72 );
73
74 Ok(analyzer)
75 }
76
77 pub fn get_backend(&self) -> &GpuBackend {
79 &self.backend
80 }
81
82 fn detect_gpu_backend() -> GpuBackend {
83 if cfg!(target_os = "macos") && Self::is_apple_silicon() {
85 println!("š Apple Silicon detected, using Metal backend");
86 GpuBackend::Metal
87 }
88 else if Self::has_cuda_support() {
90 println!("š¢ NVIDIA CUDA detected, using CUDA backend");
91 GpuBackend::Cuda
92 }
93 else if Self::has_mkl_support() {
95 println!("šµ Intel MKL detected, using MKL backend");
96 GpuBackend::Mkl
97 }
98 else {
100 println!("š» No GPU acceleration detected, falling back to CPU");
101 GpuBackend::Cpu
102 }
103 }
104
105 fn is_apple_silicon() -> bool {
106 cfg!(target_arch = "aarch64") && cfg!(target_os = "macos")
108 }
109
110 fn has_cuda_support() -> bool {
111 std::process::Command::new("nvidia-smi")
114 .output()
115 .map(|output| output.status.success())
116 .unwrap_or(false)
117 }
118
119 fn has_mkl_support() -> bool {
120 cfg!(target_arch = "x86_64")
123 }
124
125 pub async fn analyze_file(
126 &self,
127 request: AnalysisRequest,
128 progress_tx: Option<mpsc::UnboundedSender<ProgressUpdate>>,
129 ) -> Result<Vec<Issue>> {
130 let _language = self.detect_language(&request.file_path);
131
132 if let Some(ref tx) = progress_tx {
133 let _ = tx.send(ProgressUpdate {
134 current_file: request.file_path.clone(),
135 progress: 0.0,
136 stage: "Starting analysis".to_string(),
137 });
138 }
139
140 let mut issues = Vec::new();
141
142 if self.enable_ai {
144 println!("š¤ AI inference enabled - using advanced AI analysis");
145 issues.extend(self.ai_enhanced_analysis(&request)?);
148 } else {
149 println!("š AI inference disabled - using rule-based analysis only");
150 issues.extend(self.rule_based_analysis(&request)?);
151 }
152
153 if let Some(ref tx) = progress_tx {
154 let _ = tx.send(ProgressUpdate {
155 current_file: request.file_path.clone(),
156 progress: 100.0,
157 stage: "Analysis complete".to_string(),
158 });
159 }
160
161 Ok(issues)
162 }
163
164 pub fn rule_based_analysis(&self, request: &AnalysisRequest) -> Result<Vec<Issue>> {
165 let mut issues = Vec::new();
166
167 for (line_num, line) in request.content.lines().enumerate() {
168 let line_number = line_num + 1;
169 let line_lower = line.to_lowercase();
170
171 if (line_lower.contains("password")
175 || line_lower.contains("api_key")
176 || line_lower.contains("secret"))
177 && line.contains("=")
178 && (line.contains("\"") || line.contains("'"))
179 {
180 issues.push(Issue {
181 file: request.file_path.clone(),
182 line: line_number,
183 severity: "Critical".to_string(),
184 category: "Security".to_string(),
185 description: "Hardcoded credentials detected - use environment variables"
186 .to_string(),
187 commit_status: request.commit_status.clone(),
188 });
189 }
190
191 if line.contains("eval(") || line.contains("exec(") {
193 issues.push(Issue {
194 file: request.file_path.clone(),
195 line: line_number,
196 severity: "Critical".to_string(),
197 category: "Security".to_string(),
198 description: "Code injection vulnerability - avoid eval/exec".to_string(),
199 commit_status: request.commit_status.clone(),
200 });
201 }
202
203 if line.contains("query")
205 && line.contains("format!")
206 && (line.contains("SELECT") || line.contains("INSERT") || line.contains("UPDATE"))
207 {
208 issues.push(Issue {
209 file: request.file_path.clone(),
210 line: line_number,
211 severity: "Critical".to_string(),
212 category: "Security".to_string(),
213 description: "Potential SQL injection - use parameterized queries".to_string(),
214 commit_status: request.commit_status.clone(),
215 });
216 }
217
218 if (line.contains("Command::new")
220 || line.contains("subprocess")
221 || line.contains("system("))
222 && (line.contains("format!")
223 || line.contains("user_input")
224 || line.contains("args"))
225 {
226 issues.push(Issue {
227 file: request.file_path.clone(),
228 line: line_number,
229 severity: "Critical".to_string(),
230 category: "Security".to_string(),
231 description: "Command injection vulnerability - sanitize inputs".to_string(),
232 commit_status: request.commit_status.clone(),
233 });
234 }
235
236 if line.contains("../")
238 && (line.contains("read") || line.contains("open") || line.contains("file"))
239 {
240 issues.push(Issue {
241 file: request.file_path.clone(),
242 line: line_number,
243 severity: "High".to_string(),
244 category: "Security".to_string(),
245 description: "Path traversal vulnerability - validate file paths".to_string(),
246 commit_status: request.commit_status.clone(),
247 });
248 }
249
250 if line.contains("for") && line.trim().starts_with("for") {
254 let lines: Vec<&str> = request.content.lines().collect();
256 for (idx, _) in lines
257 .iter()
258 .enumerate()
259 .take(std::cmp::min(line_num + 10, lines.len()))
260 .skip(line_num + 1)
261 {
262 if lines[idx].trim().starts_with("for") {
263 issues.push(Issue {
264 file: request.file_path.clone(),
265 line: line_number,
266 severity: "Medium".to_string(),
267 category: "Performance".to_string(),
268 description: "Nested loops detected - consider optimization"
269 .to_string(),
270 commit_status: request.commit_status.clone(),
271 });
272 break;
273 }
274 }
275 }
276
277 match request.language.as_str() {
279 "rust" => {
280 if line.contains("unsafe") {
282 issues.push(Issue {
283 file: request.file_path.clone(),
284 line: line_number,
285 severity: "High".to_string(),
286 category: "Security".to_string(),
287 description: "Unsafe code block - requires justification and review"
288 .to_string(),
289 commit_status: request.commit_status.clone(),
290 });
291 }
292
293 if line.contains("std::ptr::null") {
294 issues.push(Issue {
295 file: request.file_path.clone(),
296 line: line_number,
297 severity: "Critical".to_string(),
298 category: "Security".to_string(),
299 description: "Null pointer dereference - will cause segfault"
300 .to_string(),
301 commit_status: request.commit_status.clone(),
302 });
303 }
304
305 if line.contains("unwrap()") && !line.contains("expect(") {
307 issues.push(Issue {
308 file: request.file_path.clone(),
309 line: line_number,
310 severity: "Medium".to_string(),
311 category: "Error Handling".to_string(),
312 description:
313 "Use expect() or proper error handling instead of unwrap()"
314 .to_string(),
315 commit_status: request.commit_status.clone(),
316 });
317 }
318
319 if line.contains(".clone()") && line.contains("&") {
321 issues.push(Issue {
322 file: request.file_path.clone(),
323 line: line_number,
324 severity: "Low".to_string(),
325 category: "Performance".to_string(),
326 description: "Unnecessary clone - consider borrowing instead"
327 .to_string(),
328 commit_status: request.commit_status.clone(),
329 });
330 }
331 }
332 "python" => {
333 if line.contains("pickle.loads") && !line.contains("trusted") {
335 issues.push(Issue {
336 file: request.file_path.clone(),
337 line: line_number,
338 severity: "Critical".to_string(),
339 category: "Security".to_string(),
340 description: "Unsafe deserialization - pickle.loads is dangerous"
341 .to_string(),
342 commit_status: request.commit_status.clone(),
343 });
344 }
345
346 if line.contains("yaml.load") && !line.contains("safe_load") {
347 issues.push(Issue {
348 file: request.file_path.clone(),
349 line: line_number,
350 severity: "High".to_string(),
351 category: "Security".to_string(),
352 description: "Use yaml.safe_load instead of yaml.load".to_string(),
353 commit_status: request.commit_status.clone(),
354 });
355 }
356
357 if line.contains("+=") && (line.contains("\"") || line.contains("'")) {
359 issues.push(Issue {
360 file: request.file_path.clone(),
361 line: line_number,
362 severity: "Medium".to_string(),
363 category: "Performance".to_string(),
364 description:
365 "String concatenation in loop - use join() for better performance"
366 .to_string(),
367 commit_status: request.commit_status.clone(),
368 });
369 }
370 }
371 "javascript" | "typescript" => {
372 if line.contains("innerHTML") && line.contains("+") {
374 issues.push(Issue {
375 file: request.file_path.clone(),
376 line: line_number,
377 severity: "High".to_string(),
378 category: "Security".to_string(),
379 description: "XSS vulnerability - validate before setting innerHTML"
380 .to_string(),
381 commit_status: request.commit_status.clone(),
382 });
383 }
384
385 if line.contains("document.getElementById") && line.contains("for") {
387 issues.push(Issue {
388 file: request.file_path.clone(),
389 line: line_number,
390 severity: "Medium".to_string(),
391 category: "Performance".to_string(),
392 description: "DOM query in loop - cache the element reference"
393 .to_string(),
394 commit_status: request.commit_status.clone(),
395 });
396 }
397 }
398 _ => {}
399 }
400
401 if line.contains("TODO") || line.contains("FIXME") || line.contains("HACK") {
404 issues.push(Issue {
405 file: request.file_path.clone(),
406 line: line_number,
407 severity: "Low".to_string(),
408 category: "Code Quality".to_string(),
409 description: "Code comment indicates incomplete implementation".to_string(),
410 commit_status: request.commit_status.clone(),
411 });
412 }
413
414 if line.len() > 120 {
416 issues.push(Issue {
417 file: request.file_path.clone(),
418 line: line_number,
419 severity: "Low".to_string(),
420 category: "Code Quality".to_string(),
421 description: format!(
422 "Line too long ({} chars) - consider breaking into multiple lines",
423 line.len()
424 ),
425 commit_status: request.commit_status.clone(),
426 });
427 }
428 }
429
430 Ok(issues)
431 }
432
433 fn ai_enhanced_analysis(&self, request: &AnalysisRequest) -> Result<Vec<Issue>> {
434 let mut issues = Vec::new();
435
436 issues.extend(self.rule_based_analysis(request)?);
438
439 let content = &request.content;
441 let lines: Vec<&str> = content.lines().collect();
442
443 if self.detect_architecture_issues(&lines, request) {
447 issues.push(Issue {
448 file: request.file_path.clone(),
449 line: 1,
450 severity: "Medium".to_string(),
451 category: "Architecture".to_string(),
452 description: "Potential architectural issues detected - consider refactoring"
453 .to_string(),
454 commit_status: request.commit_status.clone(),
455 });
456 }
457
458 let complexity_score = self.calculate_complexity_score(&lines);
460 if complexity_score > 50 {
461 issues.push(Issue {
462 file: request.file_path.clone(),
463 line: 1,
464 severity: "Medium".to_string(),
465 category: "Maintainability".to_string(),
466 description: format!(
467 "High complexity score ({complexity_score}) - consider breaking into smaller functions"
468 ),
469 commit_status: request.commit_status.clone(),
470 });
471 }
472
473 if self.detect_race_conditions(&lines, request) {
475 issues.push(Issue {
476 file: request.file_path.clone(),
477 line: 1,
478 severity: "High".to_string(),
479 category: "Concurrency".to_string(),
480 description: "Potential race condition detected - review shared state access"
481 .to_string(),
482 commit_status: request.commit_status.clone(),
483 });
484 }
485
486 if self.detect_error_handling_issues(&lines, request) {
488 issues.push(Issue {
489 file: request.file_path.clone(),
490 line: 1,
491 severity: "Medium".to_string(),
492 category: "Error Handling".to_string(),
493 description: "Inconsistent error handling patterns - standardize approach"
494 .to_string(),
495 commit_status: request.commit_status.clone(),
496 });
497 }
498
499 if self.detect_performance_issues(&lines, request) {
501 issues.push(Issue {
502 file: request.file_path.clone(),
503 line: 1,
504 severity: "Medium".to_string(),
505 category: "Performance".to_string(),
506 description: "Performance optimization opportunities identified".to_string(),
507 commit_status: request.commit_status.clone(),
508 });
509 }
510
511 Ok(issues)
512 }
513
514 fn detect_architecture_issues(&self, lines: &[&str], request: &AnalysisRequest) -> bool {
515 let mut method_count = 0;
516 let mut field_count = 0;
517
518 for line in lines {
519 let trimmed = line.trim();
520 match request.language.as_str() {
521 "rust" => {
522 if trimmed.starts_with("fn ") {
523 method_count += 1;
524 }
525 if trimmed.starts_with("let ")
526 || trimmed.starts_with("const ")
527 || trimmed.contains(": ")
528 {
529 field_count += 1;
530 }
531 }
532 "python" => {
533 if trimmed.starts_with("def ") {
534 method_count += 1;
535 }
536 if trimmed.starts_with("self.") && trimmed.contains("=") {
537 field_count += 1;
538 }
539 }
540 "javascript" | "typescript" => {
541 if trimmed.contains("function ")
542 || (trimmed.contains("=>") && trimmed.contains("{"))
543 {
544 method_count += 1;
545 }
546 if trimmed.contains("this.") && trimmed.contains("=") {
547 field_count += 1;
548 }
549 }
550 _ => {}
551 }
552 }
553
554 method_count > 20 || field_count > 15
556 }
557
558 fn calculate_complexity_score(&self, lines: &[&str]) -> u32 {
559 let mut score = 0u32;
560
561 for line in lines {
562 let trimmed = line.trim();
563
564 if trimmed.starts_with("if ")
566 || trimmed.starts_with("else")
567 || trimmed.starts_with("for ")
568 || trimmed.starts_with("while ")
569 || trimmed.starts_with("match ")
570 || trimmed.starts_with("switch")
571 {
572 score += 2;
573 }
574
575 let indent_level = line.len() - line.trim_start().len();
577 if indent_level > 8 {
578 score += 1;
579 }
580
581 if trimmed.contains("catch") || trimmed.contains("except") || trimmed.contains("rescue")
583 {
584 score += 1;
585 }
586 }
587
588 score
589 }
590
591 fn detect_race_conditions(&self, lines: &[&str], request: &AnalysisRequest) -> bool {
592 let mut has_shared_state = false;
593 let mut has_concurrent_access = false;
594
595 for line in lines {
596 let trimmed = line.trim().to_lowercase();
597
598 match request.language.as_str() {
599 "rust" => {
600 if trimmed.contains("arc<")
602 || trimmed.contains("mutex")
603 || trimmed.contains("rwlock")
604 || trimmed.contains("static mut")
605 {
606 has_shared_state = true;
607 }
608
609 if trimmed.contains("tokio::spawn")
611 || trimmed.contains("thread::spawn")
612 || trimmed.contains("async")
613 {
614 has_concurrent_access = true;
615 }
616 }
617 "python" => {
618 if trimmed.contains("threading")
619 || trimmed.contains("multiprocessing")
620 || trimmed.contains("asyncio")
621 {
622 has_concurrent_access = true;
623 }
624 if trimmed.contains("global") || trimmed.contains("shared") {
625 has_shared_state = true;
626 }
627 }
628 "javascript" | "typescript" => {
629 if trimmed.contains("worker")
630 || trimmed.contains("promise")
631 || trimmed.contains("async")
632 {
633 has_concurrent_access = true;
634 }
635 if trimmed.contains("window.") || trimmed.contains("global") {
636 has_shared_state = true;
637 }
638 }
639 _ => {}
640 }
641 }
642
643 has_shared_state && has_concurrent_access
644 }
645
646 fn detect_error_handling_issues(&self, lines: &[&str], request: &AnalysisRequest) -> bool {
647 let mut error_patterns = Vec::new();
648 let mut total_lines = 0;
649
650 for line in lines {
651 total_lines += 1;
652 let trimmed = line.trim().to_lowercase();
653
654 match request.language.as_str() {
655 "rust" => {
656 if trimmed.contains("unwrap()") {
657 error_patterns.push("unwrap");
658 }
659 if trimmed.contains("expect(") {
660 error_patterns.push("expect");
661 }
662 if trimmed.contains("?") {
663 error_patterns.push("question_mark");
664 }
665 }
666 "python" => {
667 if trimmed.contains("except:") || trimmed.contains("except exception") {
668 error_patterns.push("bare_except");
669 }
670 if trimmed.contains("raise") {
671 error_patterns.push("raise");
672 }
673 }
674 "javascript" | "typescript" => {
675 if trimmed.contains("throw") {
676 error_patterns.push("throw");
677 }
678 if trimmed.contains("catch") && trimmed.contains("console.error") {
679 error_patterns.push("console_error");
680 }
681 }
682 _ => {}
683 }
684 }
685
686 let unique_patterns: std::collections::HashSet<_> = error_patterns.into_iter().collect();
688 unique_patterns.len() > 2 && total_lines > 50
689 }
690
691 fn detect_performance_issues(&self, lines: &[&str], request: &AnalysisRequest) -> bool {
692 let mut performance_concerns = 0;
693
694 for line in lines {
695 let trimmed = line.trim().to_lowercase();
696
697 match request.language.as_str() {
698 "rust" => {
699 if trimmed.contains("vec!") && trimmed.contains("for ") {
701 performance_concerns += 1;
702 }
703 if trimmed.contains("push_str") && trimmed.contains("for ") {
705 performance_concerns += 1;
706 }
707 }
708 "python" => {
709 if trimmed.contains("[") && trimmed.contains("for ") && trimmed.contains("in ")
711 {
712 performance_concerns += 1;
713 }
714 }
715 "javascript" | "typescript" => {
716 if trimmed.contains("getelement") && trimmed.contains("for ") {
718 performance_concerns += 1;
719 }
720 }
721 _ => {}
722 }
723 }
724
725 performance_concerns > 2
726 }
727
728 fn detect_language(&self, file_path: &str) -> String {
729 let path = Path::new(file_path);
730 match path.extension().and_then(|ext| ext.to_str()) {
731 Some("rs") => "rust".to_string(),
732 Some("js") => "javascript".to_string(),
733 Some("ts") => "typescript".to_string(),
734 Some("py") => "python".to_string(),
735 Some("java") => "java".to_string(),
736 Some("cpp") | Some("cc") | Some("cxx") => "cpp".to_string(),
737 Some("c") => "c".to_string(),
738 Some("go") => "go".to_string(),
739 Some("php") => "php".to_string(),
740 Some("rb") => "ruby".to_string(),
741 Some("cs") => "csharp".to_string(),
742 _ => "unknown".to_string(),
743 }
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::core::review::CommitStatus;
751
752 fn make_request(file: &str, content: &str, language: &str) -> AnalysisRequest {
753 AnalysisRequest {
754 file_path: file.to_string(),
755 content: content.to_string(),
756 language: language.to_string(),
757 commit_status: CommitStatus::Modified,
758 }
759 }
760
761 #[test]
762 fn test_detect_language_variants() {
763 let analyzer = AIAnalyzer {
764 backend: GpuBackend::Cpu,
765 enable_ai: true,
766 };
767 assert_eq!(analyzer.detect_language("src/main.rs"), "rust");
768 assert_eq!(analyzer.detect_language("a/b/c.py"), "python");
769 assert_eq!(analyzer.detect_language("index.ts"), "typescript");
770 assert_eq!(analyzer.detect_language("script.js"), "javascript");
771 assert_eq!(analyzer.detect_language("unknown.foo"), "unknown");
772 }
773
774 #[test]
775 fn test_rule_based_analysis_rust_patterns() {
776 let analyzer = AIAnalyzer {
777 backend: GpuBackend::Cpu,
778 enable_ai: true,
779 };
780 let content = r#"
781 // SECURITY
782 let password = "secret";
783 let _ = eval("2+2");
784 let query = format!("SELECT * FROM users");
785 std::process::Command::new("sh").arg(format!("{}", user_input));
786 let _ = std::fs::read("../etc/passwd");
787 // PERFORMANCE
788 for i in 0..10 {
789 for j in 0..10 {}
790 }
791 // RUST SPECIFIC
792 unsafe { /* do unsafe things */ }
793 let p = std::ptr::null();
794 let _ = something.unwrap();
795 let _y = &x.clone();
796 // QUALITY
797 // TODO: fix
798 // Long line next
799 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
800 "#;
801 let req = make_request("file.rs", content, "rust");
802 let issues = analyzer.rule_based_analysis(&req).unwrap();
803 assert!(!issues.is_empty());
804 assert!(issues.iter().any(|i| i.category == "Security"));
806 assert!(issues.iter().any(|i| i.category == "Performance"));
807 assert!(issues.iter().any(|i| i.category == "Code Quality"));
808 }
809
810 #[test]
811 fn test_rule_based_analysis_python_patterns() {
812 let analyzer = AIAnalyzer {
813 backend: GpuBackend::Cpu,
814 enable_ai: true,
815 };
816 let content = r#"
817 import pickle
818 data = pickle.loads(b"...")
819 import yaml
820 result = yaml.load("x: 1")
821 s = "";
822 for i in range(10): s += "x"
823 "#;
824 let req = make_request("script.py", content, "python");
825 let issues = analyzer.rule_based_analysis(&req).unwrap();
826 assert!(issues.iter().any(|i| i.category == "Security"));
827 assert!(issues.iter().any(|i| i.category == "Performance"));
828 }
829
830 #[test]
831 fn test_rule_based_analysis_js_patterns() {
832 let analyzer = AIAnalyzer {
833 backend: GpuBackend::Cpu,
834 enable_ai: true,
835 };
836 let content = r#"
837 let x = "user";
838 element.innerHTML = "<div>" + x;
839 for (let i = 0; i < 10; i++) { document.getElementById("id"); }
840 "#;
841 let req = make_request("script.js", content, "javascript");
842 let issues = analyzer.rule_based_analysis(&req).unwrap();
843 assert!(issues.iter().any(|i| i.category == "Security"));
844 assert!(issues.iter().any(|i| i.category == "Performance"));
845 }
846
847 #[test]
848 fn test_analyze_file_emits_progress_and_issues() {
849 let rt = tokio::runtime::Runtime::new().unwrap();
850 rt.block_on(async {
851 let analyzer = AIAnalyzer::new(false, true).await.unwrap();
852 let (tx, mut rx) = mpsc::unbounded_channel::<ProgressUpdate>();
853 let req = make_request("file.rs", "let password = \"x\";", "rust");
854 let issues = analyzer.analyze_file(req, Some(tx)).await.unwrap();
855 assert!(!issues.is_empty());
856 let mut got_any = false;
858 for _ in 0..4 {
859 if rx.try_recv().is_ok() {
860 got_any = true;
861 break;
862 }
863 }
864 assert!(got_any, "expected at least one progress message");
865 });
866 }
867}