testlint_sdk/
reporter.rs

1use crate::profiler::CommonProfileData;
2use chrono::Utc;
3use std::sync::{Arc, Mutex};
4use std::thread;
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum ReportFormat {
9    Json,
10    Protobuf,
11}
12
13#[derive(Debug, Clone)]
14pub struct ReporterConfig {
15    pub endpoint: String,
16    pub interval_secs: u64,
17    pub enabled: bool,
18    pub api_key: Option<String>,
19    pub max_buffer_size: usize,
20    pub format: ReportFormat,
21}
22
23impl Default for ReporterConfig {
24    fn default() -> Self {
25        ReporterConfig {
26            endpoint: "https://quality-web-app.pages.dev/api/v0/profiles".to_string(),
27            interval_secs: 300, // 5 minutes
28            enabled: false,
29            api_key: None,
30            max_buffer_size: 100,           // Keep max 100 profiles in buffer
31            format: ReportFormat::Protobuf, // Protobuf by default (more efficient)
32        }
33    }
34}
35
36impl ReporterConfig {
37    pub fn from_env_and_args(
38        endpoint: Option<String>,
39        interval: Option<u64>,
40        api_key: Option<String>,
41        max_buffer_size: Option<usize>,
42        format: Option<String>,
43    ) -> Self {
44        let mut config = ReporterConfig::default();
45
46        // Check environment variables first
47        if let Ok(env_endpoint) = std::env::var("PROFILER_ENDPOINT") {
48            config.endpoint = env_endpoint;
49        }
50        if let Ok(env_interval) = std::env::var("PROFILER_INTERVAL") {
51            if let Ok(interval) = env_interval.parse() {
52                config.interval_secs = interval;
53            }
54        }
55        if let Ok(env_api_key) = std::env::var("PROFILER_API_KEY") {
56            config.api_key = Some(env_api_key);
57        }
58        if let Ok(env_buffer_size) = std::env::var("PROFILER_MAX_BUFFER_SIZE") {
59            if let Ok(size) = env_buffer_size.parse() {
60                config.max_buffer_size = size;
61            }
62        }
63        if let Ok(env_format) = std::env::var("PROFILER_FORMAT") {
64            config.format = match env_format.to_lowercase().as_str() {
65                "json" => ReportFormat::Json,
66                "protobuf" | "proto" => ReportFormat::Protobuf,
67                _ => config.format,
68            };
69        }
70
71        // CLI args override environment variables
72        let endpoint_provided = endpoint.is_some();
73        if let Some(ep) = endpoint {
74            config.endpoint = ep;
75            config.enabled = true;
76        }
77        if let Some(int) = interval {
78            config.interval_secs = int;
79        }
80        if let Some(key) = api_key {
81            config.api_key = Some(key);
82        }
83        if let Some(size) = max_buffer_size {
84            config.max_buffer_size = size;
85        }
86        if let Some(fmt) = format {
87            config.format = match fmt.to_lowercase().as_str() {
88                "json" => ReportFormat::Json,
89                "protobuf" | "proto" => ReportFormat::Protobuf,
90                _ => config.format,
91            };
92        }
93
94        // Enable if endpoint is set via env or arg
95        if std::env::var("PROFILER_ENDPOINT").is_ok() || endpoint_provided {
96            config.enabled = true;
97        }
98
99        config
100    }
101}
102
103pub struct ProfileReporter {
104    config: ReporterConfig,
105    profile_buffer: Arc<Mutex<Vec<CommonProfileData>>>,
106    client: reqwest::blocking::Client,
107}
108
109impl ProfileReporter {
110    pub fn new(config: ReporterConfig) -> Self {
111        let client = reqwest::blocking::Client::builder()
112            .timeout(Duration::from_secs(30))
113            .build()
114            .expect("Failed to create HTTP client");
115
116        ProfileReporter {
117            config,
118            profile_buffer: Arc::new(Mutex::new(Vec::new())),
119            client,
120        }
121    }
122
123    pub fn add_profile(&self, profile: CommonProfileData) {
124        if !self.config.enabled {
125            return;
126        }
127
128        let mut buffer = self.profile_buffer.lock().unwrap();
129
130        // If buffer is at capacity, remove oldest profile
131        if buffer.len() >= self.config.max_buffer_size {
132            buffer.remove(0);
133            eprintln!(
134                "⚠️  Buffer full ({} profiles), discarding oldest profile",
135                self.config.max_buffer_size
136            );
137        }
138
139        buffer.push(profile);
140        println!("📊 Profile added to buffer (total: {})", buffer.len());
141    }
142
143    pub fn start_periodic_reporting(&self) -> Option<thread::JoinHandle<()>> {
144        if !self.config.enabled {
145            println!("📡 Periodic reporting disabled");
146            return None;
147        }
148
149        println!(
150            "📡 Starting periodic reporting to: {}",
151            self.config.endpoint
152        );
153        println!("⏱️  Report interval: {} seconds", self.config.interval_secs);
154
155        let config = self.config.clone();
156        let profile_buffer = Arc::clone(&self.profile_buffer);
157        let client = self.client.clone();
158
159        let handle = thread::spawn(move || {
160            loop {
161                thread::sleep(Duration::from_secs(config.interval_secs));
162
163                let mut buffer = profile_buffer.lock().unwrap();
164                if buffer.is_empty() {
165                    println!("📊 No profiles to report");
166                    continue;
167                }
168
169                let profiles_to_send: Vec<CommonProfileData> = buffer.drain(..).collect();
170                drop(buffer); // Release lock before sending
171
172                println!(
173                    "📤 Sending {} profiles to backend...",
174                    profiles_to_send.len()
175                );
176
177                match Self::send_profiles_with_retry(&client, &config, &profiles_to_send, 3) {
178                    Ok(_) => {
179                        println!(
180                            "✅ Successfully reported {} profiles",
181                            profiles_to_send.len()
182                        );
183                    }
184                    Err(e) => {
185                        eprintln!("⚠️  Failed to report profiles after retries: {}", e);
186                        eprintln!("   Profiles will be retried in next batch");
187                        // Re-add profiles to buffer for retry in next interval
188                        let mut buffer = profile_buffer.lock().unwrap();
189                        buffer.extend(profiles_to_send);
190                    }
191                }
192            }
193        });
194
195        Some(handle)
196    }
197
198    fn send_profiles_with_retry(
199        client: &reqwest::blocking::Client,
200        config: &ReporterConfig,
201        profiles: &[CommonProfileData],
202        max_retries: u32,
203    ) -> Result<(), String> {
204        let mut last_error = String::new();
205
206        for attempt in 1..=max_retries {
207            match Self::send_profiles(client, config, profiles) {
208                Ok(_) => {
209                    if attempt > 1 {
210                        println!("   ✅ Succeeded on attempt {}/{}", attempt, max_retries);
211                    }
212                    return Ok(());
213                }
214                Err(e) => {
215                    last_error = e.clone();
216                    if attempt < max_retries {
217                        eprintln!("   ⚠️  Attempt {}/{} failed: {}", attempt, max_retries, e);
218                        eprintln!("   🔄 Retrying in {} seconds...", attempt);
219                        thread::sleep(Duration::from_secs(attempt as u64));
220                    }
221                }
222            }
223        }
224
225        Err(format!(
226            "All {} attempts failed. Last error: {}",
227            max_retries, last_error
228        ))
229    }
230
231    fn send_profiles(
232        client: &reqwest::blocking::Client,
233        config: &ReporterConfig,
234        profiles: &[CommonProfileData],
235    ) -> Result<(), String> {
236        let mut request = client.post(&config.endpoint);
237
238        match config.format {
239            ReportFormat::Json => {
240                // JSON format (human-readable, verbose)
241                let payload = serde_json::json!({
242                    "profiles": profiles,
243                    "reported_at": Utc::now().to_rfc3339(),
244                    "agent_version": env!("CARGO_PKG_VERSION"),
245                });
246
247                request = request
248                    .json(&payload)
249                    .header("Content-Type", "application/json");
250            }
251            ReportFormat::Protobuf => {
252                // Protobuf format (compact binary, ~60-70% smaller)
253                use crate::proto::ProfileBatch;
254                use prost::Message;
255
256                let proto_profiles: Vec<_> = profiles.iter().map(|p| p.to_protobuf()).collect();
257
258                let batch = ProfileBatch {
259                    profiles: proto_profiles,
260                    reported_at: Utc::now().to_rfc3339(),
261                    agent_version: env!("CARGO_PKG_VERSION").to_string(),
262                };
263
264                let mut buf = Vec::new();
265                batch
266                    .encode(&mut buf)
267                    .map_err(|e| format!("Failed to encode protobuf: {}", e))?;
268
269                request = request
270                    .body(buf)
271                    .header("Content-Type", "application/x-protobuf");
272            }
273        }
274
275        request = request.header(
276            "User-Agent",
277            format!("quality-agent/{}", env!("CARGO_PKG_VERSION")),
278        );
279
280        // Add API key if provided
281        if let Some(ref api_key) = config.api_key {
282            request = request.header("Authorization", format!("Bearer {}", api_key));
283        }
284
285        let response = request
286            .send()
287            .map_err(|e| format!("HTTP request failed: {}", e))?;
288
289        if response.status().is_success() {
290            Ok(())
291        } else {
292            Err(format!(
293                "Server returned error: {} - {}",
294                response.status(),
295                response
296                    .text()
297                    .unwrap_or_else(|_| "Unknown error".to_string())
298            ))
299        }
300    }
301
302    pub fn send_now(&self) -> Result<(), String> {
303        if !self.config.enabled {
304            return Err("Reporting is disabled".to_string());
305        }
306
307        let mut buffer = self.profile_buffer.lock().unwrap();
308        if buffer.is_empty() {
309            return Err("No profiles to send".to_string());
310        }
311
312        let profiles_to_send: Vec<CommonProfileData> = buffer.drain(..).collect();
313        drop(buffer);
314
315        println!(
316            "📤 Sending {} profiles immediately...",
317            profiles_to_send.len()
318        );
319
320        match Self::send_profiles_with_retry(&self.client, &self.config, &profiles_to_send, 3) {
321            Ok(_) => Ok(()),
322            Err(e) => {
323                // Re-add to buffer for next periodic send
324                let mut buffer = self.profile_buffer.lock().unwrap();
325                buffer.extend(profiles_to_send);
326                Err(e)
327            }
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_reporter_config_defaults() {
338        let config = ReporterConfig::default();
339        assert_eq!(
340            config.endpoint,
341            "https://quality-web-app.pages.dev/api/v0/profiles"
342        );
343        assert_eq!(config.interval_secs, 300);
344        assert_eq!(config.max_buffer_size, 100);
345        assert_eq!(config.format, ReportFormat::Protobuf);
346        assert!(!config.enabled);
347    }
348
349    #[test]
350    fn test_reporter_config_from_args() {
351        let config = ReporterConfig::from_env_and_args(
352            Some("https://example.com/api".to_string()),
353            Some(60),
354            Some("test-key".to_string()),
355            Some(50),
356            Some("json".to_string()),
357        );
358        assert_eq!(config.endpoint, "https://example.com/api");
359        assert_eq!(config.interval_secs, 60);
360        assert_eq!(config.max_buffer_size, 50);
361        assert_eq!(config.format, ReportFormat::Json);
362        assert_eq!(config.api_key, Some("test-key".to_string()));
363        assert!(config.enabled);
364    }
365
366    #[test]
367    fn test_reporter_disabled_by_default() {
368        let config = ReporterConfig::from_env_and_args(None, None, None, None, None);
369        let reporter = ProfileReporter::new(config);
370        assert!(reporter.start_periodic_reporting().is_none());
371    }
372
373    #[test]
374    fn test_buffer_overflow_discards_oldest() {
375        use crate::profiler::common_format::{RuntimeMetrics, StaticMetrics};
376        use std::collections::HashMap;
377
378        // Create a config with small buffer (3 profiles max)
379        let config = ReporterConfig {
380            enabled: true,
381            max_buffer_size: 3,
382            ..Default::default()
383        };
384
385        let reporter = ProfileReporter::new(config);
386
387        // Add 5 profiles (should discard 2 oldest)
388        for i in 0..5 {
389            let profile = CommonProfileData {
390                language: "Python".to_string(),
391                source_file: format!("test_{}.py", i),
392                timestamp: chrono::Utc::now().to_rfc3339(),
393                static_analysis: StaticMetrics {
394                    file_size_bytes: 100,
395                    line_count: 10,
396                    function_count: 1,
397                    class_count: 0,
398                    import_count: 0,
399                    complexity_score: 1,
400                },
401                runtime_analysis: Some(RuntimeMetrics {
402                    total_samples: i as u64,
403                    execution_duration_secs: 1,
404                    functions_executed: 0,
405                    function_stats: HashMap::new(),
406                    hot_functions: Vec::new(),
407                }),
408            };
409            reporter.add_profile(profile);
410        }
411
412        // Buffer should only have 3 profiles (2, 3, 4)
413        let buffer = reporter.profile_buffer.lock().unwrap();
414        assert_eq!(buffer.len(), 3);
415        assert_eq!(buffer[0].source_file, "test_2.py");
416        assert_eq!(buffer[1].source_file, "test_3.py");
417        assert_eq!(buffer[2].source_file, "test_4.py");
418    }
419
420    // ==================== ReporterConfig format parsing tests ====================
421
422    #[test]
423    fn test_reporter_config_format_json() {
424        let config = ReporterConfig::from_env_and_args(
425            Some("http://example.com".to_string()),
426            None,
427            None,
428            None,
429            Some("json".to_string()),
430        );
431        assert_eq!(config.format, ReportFormat::Json);
432    }
433
434    #[test]
435    fn test_reporter_config_format_protobuf() {
436        let config = ReporterConfig::from_env_and_args(
437            Some("http://example.com".to_string()),
438            None,
439            None,
440            None,
441            Some("protobuf".to_string()),
442        );
443        assert_eq!(config.format, ReportFormat::Protobuf);
444    }
445
446    #[test]
447    fn test_reporter_config_format_proto_alias() {
448        let config = ReporterConfig::from_env_and_args(
449            Some("http://example.com".to_string()),
450            None,
451            None,
452            None,
453            Some("proto".to_string()),
454        );
455        assert_eq!(config.format, ReportFormat::Protobuf);
456    }
457
458    #[test]
459    fn test_reporter_config_format_case_insensitive() {
460        let config1 = ReporterConfig::from_env_and_args(
461            Some("http://example.com".to_string()),
462            None,
463            None,
464            None,
465            Some("JSON".to_string()),
466        );
467        assert_eq!(config1.format, ReportFormat::Json);
468
469        let config2 = ReporterConfig::from_env_and_args(
470            Some("http://example.com".to_string()),
471            None,
472            None,
473            None,
474            Some("PROTOBUF".to_string()),
475        );
476        assert_eq!(config2.format, ReportFormat::Protobuf);
477    }
478
479    #[test]
480    fn test_reporter_config_format_invalid_keeps_default() {
481        let config = ReporterConfig::from_env_and_args(
482            Some("http://example.com".to_string()),
483            None,
484            None,
485            None,
486            Some("invalid_format".to_string()),
487        );
488        // Default format is Protobuf
489        assert_eq!(config.format, ReportFormat::Protobuf);
490    }
491
492    // ==================== add_profile() when disabled tests ====================
493
494    #[test]
495    fn test_add_profile_when_disabled_buffer_stays_empty() {
496        use crate::profiler::common_format::StaticMetrics;
497
498        // Reporter with enabled=false (default)
499        let config = ReporterConfig {
500            enabled: false,
501            ..Default::default()
502        };
503        let reporter = ProfileReporter::new(config);
504
505        let profile = CommonProfileData {
506            language: "Rust".to_string(),
507            source_file: "test.rs".to_string(),
508            timestamp: chrono::Utc::now().to_rfc3339(),
509            static_analysis: StaticMetrics {
510                file_size_bytes: 100,
511                line_count: 10,
512                function_count: 1,
513                class_count: 0,
514                import_count: 0,
515                complexity_score: 1,
516            },
517            runtime_analysis: None,
518        };
519
520        reporter.add_profile(profile);
521
522        let buffer = reporter.profile_buffer.lock().unwrap();
523        assert!(
524            buffer.is_empty(),
525            "Buffer should remain empty when reporter is disabled"
526        );
527    }
528
529    #[test]
530    fn test_add_profile_when_enabled() {
531        use crate::profiler::common_format::StaticMetrics;
532
533        let config = ReporterConfig {
534            enabled: true,
535            ..Default::default()
536        };
537        let reporter = ProfileReporter::new(config);
538
539        let profile = CommonProfileData {
540            language: "Rust".to_string(),
541            source_file: "test.rs".to_string(),
542            timestamp: chrono::Utc::now().to_rfc3339(),
543            static_analysis: StaticMetrics {
544                file_size_bytes: 100,
545                line_count: 10,
546                function_count: 1,
547                class_count: 0,
548                import_count: 0,
549                complexity_score: 1,
550            },
551            runtime_analysis: None,
552        };
553
554        reporter.add_profile(profile);
555
556        let buffer = reporter.profile_buffer.lock().unwrap();
557        assert_eq!(
558            buffer.len(),
559            1,
560            "Buffer should have 1 profile when reporter is enabled"
561        );
562        assert_eq!(buffer[0].source_file, "test.rs");
563    }
564
565    // ==================== send_now() tests ====================
566
567    #[test]
568    fn test_send_now_when_disabled() {
569        let config = ReporterConfig {
570            enabled: false,
571            ..Default::default()
572        };
573        let reporter = ProfileReporter::new(config);
574
575        let result = reporter.send_now();
576
577        assert!(result.is_err());
578        assert_eq!(result.unwrap_err(), "Reporting is disabled");
579    }
580
581    #[test]
582    fn test_send_now_when_buffer_empty() {
583        let config = ReporterConfig {
584            enabled: true,
585            endpoint: "http://test.invalid".to_string(),
586            ..Default::default()
587        };
588        let reporter = ProfileReporter::new(config);
589
590        let result = reporter.send_now();
591
592        assert!(result.is_err());
593        assert_eq!(result.unwrap_err(), "No profiles to send");
594    }
595
596    // ==================== ReportFormat equality tests ====================
597
598    #[test]
599    fn test_report_format_equality() {
600        assert_eq!(ReportFormat::Json, ReportFormat::Json);
601        assert_eq!(ReportFormat::Protobuf, ReportFormat::Protobuf);
602        assert_ne!(ReportFormat::Json, ReportFormat::Protobuf);
603    }
604
605    #[test]
606    fn test_report_format_clone() {
607        let format = ReportFormat::Json;
608        let format_clone = format;
609        assert_eq!(format_clone, ReportFormat::Json);
610    }
611
612    #[test]
613    fn test_report_format_copy() {
614        let format1 = ReportFormat::Protobuf;
615        let format2 = format1; // Copy
616        assert_eq!(format1, format2);
617    }
618
619    // ==================== ReporterConfig struct tests ====================
620
621    #[test]
622    fn test_reporter_config_clone() {
623        let config = ReporterConfig {
624            endpoint: "http://example.com".to_string(),
625            interval_secs: 120,
626            enabled: true,
627            api_key: Some("my-api-key".to_string()),
628            max_buffer_size: 50,
629            format: ReportFormat::Json,
630        };
631
632        let config_clone = config.clone();
633
634        assert_eq!(config_clone.endpoint, "http://example.com");
635        assert_eq!(config_clone.interval_secs, 120);
636        assert!(config_clone.enabled);
637        assert_eq!(config_clone.api_key, Some("my-api-key".to_string()));
638        assert_eq!(config_clone.max_buffer_size, 50);
639        assert_eq!(config_clone.format, ReportFormat::Json);
640    }
641
642    #[test]
643    fn test_reporter_config_all_parameters() {
644        let config = ReporterConfig::from_env_and_args(
645            Some("https://custom.endpoint/profiles".to_string()),
646            Some(60),
647            Some("custom-api-key".to_string()),
648            Some(200),
649            Some("json".to_string()),
650        );
651
652        assert_eq!(config.endpoint, "https://custom.endpoint/profiles");
653        assert_eq!(config.interval_secs, 60);
654        assert_eq!(config.api_key, Some("custom-api-key".to_string()));
655        assert_eq!(config.max_buffer_size, 200);
656        assert_eq!(config.format, ReportFormat::Json);
657        assert!(config.enabled);
658    }
659
660    #[test]
661    fn test_reporter_enabled_when_endpoint_provided() {
662        let config = ReporterConfig::from_env_and_args(
663            Some("http://example.com".to_string()),
664            None,
665            None,
666            None,
667            None,
668        );
669
670        assert!(
671            config.enabled,
672            "Reporter should be enabled when endpoint is provided"
673        );
674    }
675
676    #[test]
677    fn test_reporter_disabled_without_endpoint() {
678        let config = ReporterConfig::from_env_and_args(None, None, None, None, None);
679
680        assert!(
681            !config.enabled,
682            "Reporter should be disabled when no endpoint is provided"
683        );
684    }
685
686    // ==================== ProfileReporter creation tests ====================
687
688    #[test]
689    fn test_profile_reporter_new() {
690        let config = ReporterConfig::default();
691        let reporter = ProfileReporter::new(config);
692
693        // Buffer should be empty initially
694        let buffer = reporter.profile_buffer.lock().unwrap();
695        assert!(buffer.is_empty());
696    }
697
698    #[test]
699    fn test_start_periodic_reporting_returns_none_when_disabled() {
700        let config = ReporterConfig {
701            enabled: false,
702            ..Default::default()
703        };
704        let reporter = ProfileReporter::new(config);
705
706        let handle = reporter.start_periodic_reporting();
707        assert!(
708            handle.is_none(),
709            "Should return None when reporting is disabled"
710        );
711    }
712
713    // ==================== Multiple profiles test ====================
714
715    #[test]
716    fn test_multiple_profiles_added_correctly() {
717        use crate::profiler::common_format::StaticMetrics;
718
719        let config = ReporterConfig {
720            enabled: true,
721            max_buffer_size: 10,
722            ..Default::default()
723        };
724        let reporter = ProfileReporter::new(config);
725
726        for i in 0..5 {
727            let profile = CommonProfileData {
728                language: format!("Lang{}", i),
729                source_file: format!("file{}.rs", i),
730                timestamp: chrono::Utc::now().to_rfc3339(),
731                static_analysis: StaticMetrics {
732                    file_size_bytes: i * 100,
733                    line_count: i * 10,
734                    function_count: i,
735                    class_count: 0,
736                    import_count: 0,
737                    complexity_score: i as u32,
738                },
739                runtime_analysis: None,
740            };
741            reporter.add_profile(profile);
742        }
743
744        let buffer = reporter.profile_buffer.lock().unwrap();
745        assert_eq!(buffer.len(), 5);
746
747        // Verify order is preserved
748        for i in 0..5 {
749            assert_eq!(buffer[i].language, format!("Lang{}", i));
750            assert_eq!(buffer[i].source_file, format!("file{}.rs", i));
751        }
752    }
753}