testlint_sdk/
test_uploader.rs

1use chrono::Utc;
2use flate2::write::GzEncoder;
3use flate2::Compression;
4use lazy_static::lazy_static;
5use quick_xml::events::{attributes::Attribute, BytesEnd, BytesStart, BytesText, Event};
6use quick_xml::{Reader, Writer};
7use regex::Regex;
8use sha2::{Digest, Sha256};
9use std::fs;
10use std::io::Cursor;
11use std::path::Path;
12
13#[cfg(test)]
14use std::fs::File;
15#[cfg(test)]
16use std::io::Read;
17
18// Cached regex patterns for path anonymization
19lazy_static! {
20    // Python-style: File "/path/to/file.py", line 123
21    static ref PYTHON_FILE_RE: Regex = Regex::new(r#"File "([^"]+)""#).unwrap();
22
23    // Java/JavaScript-style: at /path/to/file:123 or at /path/to/file.js:123:45
24    static ref STACKTRACE_PATH_RE: Regex = Regex::new(r"(?:at |in )(/[^\s:]+|[A-Z]:[^\s:]+):").unwrap();
25
26    // Generic absolute Unix paths in text
27    static ref UNIX_PATH_RE: Regex = Regex::new(r"(/[^\s]+\.(?:py|js|ts|java|rs|go|cpp|c|h)[:\s\)])").unwrap();
28
29    // Generic absolute Windows paths in text
30    static ref WINDOWS_PATH_RE: Regex = Regex::new(r"([A-Z]:[^\s]+\.(?:py|js|ts|java|rs|go|cpp|c|h)[:\s\)])").unwrap();
31
32    // XML attribute patterns for file paths
33    static ref XML_FILENAME_RE: Regex = Regex::new(r#"filename="([^"]+)""#).unwrap();
34    static ref XML_PATH_RE: Regex = Regex::new(r#"path="([^"]+)""#).unwrap();
35    static ref XML_SOURCE_RE: Regex = Regex::new(r#"source="([^"]+)""#).unwrap();
36}
37
38// Coverage report structures (no longer using protobuf)
39#[derive(Debug, Clone)]
40pub struct CoverageReport {
41    pub language: String,
42    pub format: String,
43    pub git_commit: String,
44    pub git_branch: String,
45    pub timestamp: String,
46    pub environment: String,
47    pub content: Option<CoverageContent>,
48    pub file_path: String,
49    pub content_type: String,
50    pub file_size_bytes: u64,
51}
52
53#[derive(Debug, Clone)]
54pub enum CoverageContent {
55    TextContent(String),
56    BinaryContent(Vec<u8>),
57}
58
59#[derive(Debug, Clone)]
60pub struct CoverageBatch {
61    pub reports: Vec<CoverageReport>,
62    pub reported_at: String,
63    pub agent_version: String,
64}
65
66#[derive(Debug, Clone)]
67pub struct TestUploaderConfig {
68    pub endpoint: String,
69    pub api_key: Option<String>,
70    pub format: UploadFormat,
71    pub git_commit: Option<String>,
72    pub git_branch: Option<String>,
73    pub git_commit_timestamp: Option<String>, // ISO 8601 timestamp when commit was created
74    pub environment: Option<String>,
75    pub test_type: Option<String>,
76    pub compression_level: u32,   // 0-9, where 0=none, 6=default, 9=best
77    pub upload_timeout_secs: u64, // HTTP upload timeout in seconds (default: 60)
78    pub max_retry_attempts: u32,  // Number of retry attempts for failed uploads (default: 3)
79    pub retry_delay_ms: u64,      // Initial retry delay in milliseconds (default: 1000)
80    pub anonymize_paths: bool,    // Anonymize file paths for privacy (default: true)
81    pub project_root: Option<String>, // Project root for path relativization (auto-detected if not set)
82}
83
84#[derive(Debug, Clone)]
85pub enum UploadFormat {
86    Json,
87    Protobuf,
88}
89
90impl Default for TestUploaderConfig {
91    fn default() -> Self {
92        // Use localhost in debug builds, production URL in release builds
93        #[cfg(debug_assertions)]
94        let default_endpoint = "http://localhost:8001/tests/upload";
95        #[cfg(not(debug_assertions))]
96        let default_endpoint = "https://api.testlint.com/tests/upload";
97
98        Self {
99            endpoint: default_endpoint.to_string(),
100            api_key: None,
101            format: UploadFormat::Protobuf,
102            git_commit: None,
103            git_branch: None,
104            git_commit_timestamp: None,
105            environment: Some("development".to_string()),
106            test_type: Some("unit".to_string()),
107            compression_level: 6,    // Standard compression (good balance)
108            upload_timeout_secs: 60, // 1 minute for uploads
109            max_retry_attempts: 3,   // 3 retry attempts
110            retry_delay_ms: 1000,    // 1 second initial delay
111            anonymize_paths: true,   // Anonymize paths by default for privacy
112            project_root: None,      // Auto-detect from git or cwd
113        }
114    }
115}
116
117impl TestUploaderConfig {
118    #[allow(clippy::too_many_arguments)]
119    pub fn from_env_and_args(
120        endpoint: Option<String>,
121        api_key: Option<String>,
122        format: Option<String>,
123        git_commit: Option<String>,
124        git_branch: Option<String>,
125        git_commit_timestamp: Option<String>,
126        environment: Option<String>,
127        test_type: Option<String>,
128        compression_level: Option<u32>,
129    ) -> Self {
130        let mut config = Self::default();
131
132        // Override with environment variables
133        if let Ok(env_endpoint) = std::env::var("TESTLINT_UPLOAD_ENDPOINT") {
134            config.endpoint = env_endpoint;
135        }
136        if let Ok(env_api_key) = std::env::var("TESTLINT_UPLOAD_API_KEY") {
137            config.api_key = Some(env_api_key);
138        }
139        if let Ok(env_format) = std::env::var("TESTLINT_UPLOAD_FORMAT") {
140            config.format = match env_format.to_lowercase().as_str() {
141                "json" => UploadFormat::Json,
142                _ => UploadFormat::Protobuf,
143            };
144        }
145        if let Ok(env_environment) = std::env::var("TESTLINT_UPLOAD_ENVIRONMENT") {
146            config.environment = Some(env_environment);
147        }
148        if let Ok(env_test_type) = std::env::var("TESTLINT_UPLOAD_TEST_TYPE") {
149            config.test_type = Some(env_test_type);
150        }
151        if let Ok(env_compression) = std::env::var("TESTLINT_UPLOAD_COMPRESSION") {
152            if let Ok(level) = env_compression.parse::<u32>() {
153                config.compression_level = level.min(9); // Cap at 9
154            }
155        }
156        if let Ok(env_commit_timestamp) = std::env::var("TESTLINT_COMMIT_TIMESTAMP") {
157            config.git_commit_timestamp = Some(env_commit_timestamp);
158        }
159
160        // Override with CLI arguments (highest priority)
161        if let Some(ep) = endpoint {
162            config.endpoint = ep;
163        }
164        if let Some(key) = api_key {
165            config.api_key = Some(key);
166        }
167        if let Some(fmt) = format {
168            config.format = match fmt.to_lowercase().as_str() {
169                "json" => UploadFormat::Json,
170                _ => UploadFormat::Protobuf,
171            };
172        }
173        if let Some(commit) = git_commit {
174            config.git_commit = Some(commit);
175        }
176        if let Some(branch) = git_branch {
177            config.git_branch = Some(branch);
178        }
179        if let Some(timestamp) = git_commit_timestamp {
180            config.git_commit_timestamp = Some(timestamp);
181        }
182        if let Some(env) = environment {
183            config.environment = Some(env);
184        }
185        if let Some(test_type) = test_type {
186            config.test_type = Some(test_type);
187        }
188        if let Some(level) = compression_level {
189            config.compression_level = level.min(9); // Cap at 9
190        }
191
192        // Auto-detect git commit and branch if not provided
193        if config.git_commit.is_none() {
194            config.git_commit = Self::detect_git_commit();
195        }
196        if config.git_branch.is_none() {
197            config.git_branch = Self::detect_git_branch();
198        }
199        // Auto-detect commit timestamp if not provided but commit is available
200        if config.git_commit_timestamp.is_none() {
201            if let Some(ref commit) = config.git_commit {
202                config.git_commit_timestamp = Self::get_commit_timestamp(commit);
203            }
204        }
205
206        config
207    }
208
209    /// Auto-detect current git commit hash
210    fn detect_git_commit() -> Option<String> {
211        std::process::Command::new("git")
212            .args(["rev-parse", "HEAD"])
213            .output()
214            .ok()
215            .and_then(|output| {
216                if output.status.success() {
217                    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
218                } else {
219                    None
220                }
221            })
222    }
223
224    /// Get commit timestamp for a specific commit SHA
225    fn get_commit_timestamp(commit_sha: &str) -> Option<String> {
226        std::process::Command::new("git")
227            .args(["show", "-s", "--format=%cI", commit_sha])
228            .output()
229            .ok()
230            .and_then(|output| {
231                if output.status.success() {
232                    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
233                } else {
234                    None
235                }
236            })
237    }
238
239    /// Auto-detect current git branch
240    fn detect_git_branch() -> Option<String> {
241        std::process::Command::new("git")
242            .args(["rev-parse", "--abbrev-ref", "HEAD"])
243            .output()
244            .ok()
245            .and_then(|output| {
246                if output.status.success() {
247                    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
248                } else {
249                    None
250                }
251            })
252    }
253
254    /// Auto-detect project root (git root or current directory)
255    fn detect_project_root() -> Option<String> {
256        // Try git root first
257        if let Ok(output) = std::process::Command::new("git")
258            .args(["rev-parse", "--show-toplevel"])
259            .output()
260        {
261            if output.status.success() {
262                return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
263            }
264        }
265
266        // Fallback to current directory
267        std::env::current_dir()
268            .ok()
269            .and_then(|p| p.to_str().map(|s| s.to_string()))
270    }
271
272    /// Get the project root, using provided value or auto-detecting
273    pub fn get_project_root(&self) -> String {
274        self.project_root
275            .clone()
276            .or_else(Self::detect_project_root)
277            .unwrap_or_else(|| ".".to_string())
278    }
279}
280
281pub struct TestUploader {
282    config: TestUploaderConfig,
283    client: reqwest::blocking::Client,
284    upload_salt: String, // Random salt per upload for test name hashing
285}
286
287impl TestUploader {
288    pub fn new(config: TestUploaderConfig) -> Result<Self, String> {
289        let client = reqwest::blocking::Client::builder()
290            .timeout(std::time::Duration::from_secs(30))
291            .build()
292            .expect("Failed to create HTTP client");
293
294        // Register salt with server using the API client
295        let upload_salt = Self::register_salt_with_server(&config)?;
296
297        Ok(Self {
298            config,
299            client,
300            upload_salt,
301        })
302    }
303
304    /// Register salt with /tests/salt endpoint using the generated API client
305    fn register_salt_with_server(config: &TestUploaderConfig) -> Result<String, String> {
306        let commit_sha = config
307            .git_commit
308            .as_ref()
309            .ok_or("No git commit specified")?;
310        let api_key = config.api_key.as_ref().ok_or("No API key specified")?;
311
312        // Generate a local salt to propose
313        let proposed_salt = Self::generate_local_salt();
314
315        // Build base URL from upload endpoint
316        let base_url = config.endpoint.replace("/tests/upload", "");
317
318        // Create API client with auth header
319        let reqwest_client = reqwest::Client::builder()
320            .default_headers({
321                let mut headers = reqwest::header::HeaderMap::new();
322                headers.insert(
323                    reqwest::header::AUTHORIZATION,
324                    reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
325                        .map_err(|e| format!("Invalid API key: {}", e))?,
326                );
327                headers
328            })
329            .build()
330            .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
331
332        let api_client = crate::api_client::Client::new_with_client(&base_url, reqwest_client);
333
334        // Use tokio runtime to run async call
335        let rt = tokio::runtime::Runtime::new()
336            .map_err(|e| format!("Failed to create tokio runtime: {}", e))?;
337
338        let response = rt
339            .block_on(async {
340                api_client
341                    .register_upload_salt_tests_salt_post()
342                    .commit_sha(commit_sha)
343                    .salt(&proposed_salt)
344                    .send()
345                    .await
346            })
347            .map_err(|e| format!("Failed to register salt: {}", e))?;
348
349        Ok(response.into_inner().salt)
350    }
351
352    /// Generate a local salt
353    fn generate_local_salt() -> String {
354        use std::time::{SystemTime, UNIX_EPOCH};
355        let timestamp = SystemTime::now()
356            .duration_since(UNIX_EPOCH)
357            .unwrap()
358            .as_nanos();
359
360        let mut hasher = Sha256::new();
361        hasher.update(timestamp.to_le_bytes());
362        hasher.update(std::process::id().to_le_bytes());
363        format!("{:x}", hasher.finalize())
364    }
365
366    /// Create a TestUploader with a pre-specified salt (for testing/benchmarking only)
367    #[doc(hidden)]
368    pub fn new_with_salt(config: TestUploaderConfig, salt: String) -> Self {
369        let client = reqwest::blocking::Client::builder()
370            .timeout(std::time::Duration::from_secs(30))
371            .build()
372            .expect("Failed to create HTTP client");
373
374        Self {
375            config,
376            client,
377            upload_salt: salt,
378        }
379    }
380
381    /// Anonymize file paths in coverage data for privacy
382    /// Converts absolute paths to relative paths from project root
383    ///
384    /// **Note:** This is public for benchmarking only. Not part of stable API.
385    #[doc(hidden)]
386    pub fn anonymize_coverage_content(&self, content: &str, format: &str) -> String {
387        if !self.config.anonymize_paths {
388            return content.to_string();
389        }
390
391        let project_root = self.config.get_project_root();
392        let project_root = std::path::Path::new(&project_root);
393
394        match format {
395            "coverage.py" | "jest" | "istanbul" => {
396                // JSON-based formats - anonymize file paths in the JSON
397                self.anonymize_json_paths(content, project_root)
398            }
399            "jacoco" | "cobertura" => {
400                // XML-based formats - anonymize file paths in XML
401                self.anonymize_xml_paths(content, project_root)
402            }
403            "lcov" | "golang" => {
404                // LCOV format - anonymize SF: (source file) lines
405                self.anonymize_lcov_paths(content, project_root)
406            }
407            _ => content.to_string(),
408        }
409    }
410
411    /// Anonymize paths in JSON coverage data
412    fn anonymize_json_paths(&self, content: &str, project_root: &Path) -> String {
413        // Parse JSON and replace all absolute paths with relative ones
414        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(content) {
415            self.anonymize_json_value(&mut json, project_root);
416            serde_json::to_string_pretty(&json).unwrap_or_else(|_| content.to_string())
417        } else {
418            content.to_string()
419        }
420    }
421
422    /// Recursively anonymize paths in JSON values
423    fn anonymize_json_value(&self, value: &mut serde_json::Value, project_root: &Path) {
424        match value {
425            serde_json::Value::Object(map) => {
426                // First pass: identify and rename keys that look like file paths
427                let keys_to_rename: Vec<(String, String)> = map
428                    .keys()
429                    .filter(|key| {
430                        key.starts_with('/')
431                            || key.contains(":\\")
432                            || key.contains('/')
433                            || key.contains('\\')
434                    })
435                    .map(|key| {
436                        let relative_path = self.make_relative_path(key, project_root);
437                        let hashed_path = self.hash_file_path(&relative_path);
438                        (key.clone(), hashed_path)
439                    })
440                    .collect();
441
442                // Apply the renames
443                for (old_key, new_key) in keys_to_rename {
444                    if let Some(val) = map.remove(&old_key) {
445                        map.insert(new_key, val);
446                    }
447                }
448
449                // Second pass: recurse into all values (now with updated keys)
450                for val in map.values_mut() {
451                    self.anonymize_json_value(val, project_root);
452                }
453            }
454            serde_json::Value::Array(arr) => {
455                for item in arr.iter_mut() {
456                    self.anonymize_json_value(item, project_root);
457                }
458            }
459            serde_json::Value::String(s) => {
460                // Replace string if it looks like a file path
461                if s.starts_with('/')
462                    || s.contains(":\\")
463                    || (s.contains('/') && !s.starts_with("http"))
464                {
465                    let relative_path = self.make_relative_path(s, project_root);
466                    *s = self.hash_file_path(&relative_path);
467                }
468            }
469            _ => {}
470        }
471    }
472
473    /// Anonymize paths in XML coverage data
474    fn anonymize_xml_paths(&self, content: &str, project_root: &Path) -> String {
475        let mut result = content.to_string();
476
477        // Process filename attribute
478        result = XML_FILENAME_RE
479            .replace_all(&result, |caps: &regex::Captures| {
480                if let Some(path_match) = caps.get(1) {
481                    let original_path = path_match.as_str();
482                    let relative_path = self.make_relative_path(original_path, project_root);
483                    let hashed_path = self.hash_file_path(&relative_path);
484                    format!(r#"filename="{}""#, hashed_path)
485                } else {
486                    caps[0].to_string()
487                }
488            })
489            .to_string();
490
491        // Process path attribute
492        result = XML_PATH_RE
493            .replace_all(&result, |caps: &regex::Captures| {
494                if let Some(path_match) = caps.get(1) {
495                    let original_path = path_match.as_str();
496                    let relative_path = self.make_relative_path(original_path, project_root);
497                    let hashed_path = self.hash_file_path(&relative_path);
498                    format!(r#"path="{}""#, hashed_path)
499                } else {
500                    caps[0].to_string()
501                }
502            })
503            .to_string();
504
505        // Process source attribute
506        result = XML_SOURCE_RE
507            .replace_all(&result, |caps: &regex::Captures| {
508                if let Some(path_match) = caps.get(1) {
509                    let original_path = path_match.as_str();
510                    let relative_path = self.make_relative_path(original_path, project_root);
511                    let hashed_path = self.hash_file_path(&relative_path);
512                    format!(r#"source="{}""#, hashed_path)
513                } else {
514                    caps[0].to_string()
515                }
516            })
517            .to_string();
518
519        result
520    }
521
522    /// Anonymize paths in LCOV format coverage data
523    fn anonymize_lcov_paths(&self, content: &str, project_root: &Path) -> String {
524        // Pre-allocate string with estimated capacity to avoid reallocations
525        let mut result = String::with_capacity(content.len());
526        let mut first = true;
527
528        for line in content.lines() {
529            if !first {
530                result.push('\n');
531            }
532            first = false;
533
534            if line.starts_with("SF:") {
535                let path = line.strip_prefix("SF:").unwrap_or(line);
536                // First make relative, then hash
537                let relative_path = self.make_relative_path(path, project_root);
538                let hashed_path = self.hash_file_path(&relative_path);
539                result.push_str("SF:");
540                result.push_str(&hashed_path);
541            } else if line.starts_with("TN:") && line.contains('/') {
542                // Test name might contain path - anonymize it
543                let test_name = line.strip_prefix("TN:").unwrap_or(line);
544                let relative_path = self.make_relative_path(test_name, project_root);
545                let hashed_path = self.hash_file_path(&relative_path);
546                result.push_str("TN:");
547                result.push_str(&hashed_path);
548            } else {
549                // Push unmodified line directly without allocation
550                result.push_str(line);
551            }
552        }
553
554        result
555    }
556
557    /// Convert absolute path to relative path from project root
558    fn make_relative_path(&self, path_str: &str, project_root: &Path) -> String {
559        let path = Path::new(path_str);
560
561        // Try to make path relative to project root
562        if let Ok(relative) = path.strip_prefix(project_root) {
563            relative.to_string_lossy().to_string()
564        } else {
565            // Path is already relative from repo root, keep as-is
566            path_str.to_string()
567        }
568    }
569
570    /// Hash a file path for anonymization
571    /// Creates a stable hash based on salt + commit_sha + file path
572    ///
573    /// **Note:** This is public for benchmarking only. Not part of stable API.
574    #[doc(hidden)]
575    pub fn hash_file_path(&self, file_path: &str) -> String {
576        let git_commit = self.config.git_commit.as_deref().unwrap_or("unknown");
577
578        // Create deterministic hash with salt: SHA256(salt + commit + file_path)
579        // Write directly to hasher to avoid intermediate string allocation
580        let mut hasher = Sha256::new();
581        hasher.update(self.upload_salt.as_bytes());
582        hasher.update(b"/");
583        hasher.update(git_commit.as_bytes());
584        hasher.update(b"/");
585        hasher.update(file_path.as_bytes());
586        let result = hasher.finalize();
587
588        // Use full hash for maximum uniqueness and collision resistance
589        format!("{:x}", result)
590    }
591
592    /// Hash a test name for anonymization
593    /// Creates a stable hash based on salt + commit_sha + file path + test name
594    ///
595    /// **Note:** This is public for benchmarking only. Not part of stable API.
596    #[doc(hidden)]
597    pub fn hash_test_name(&self, test_name: &str, file_path: &str) -> String {
598        let git_commit = self.config.git_commit.as_deref().unwrap_or("unknown");
599
600        // Create deterministic hash with salt: SHA256(salt + commit + file + test_name)
601        // Write directly to hasher to avoid intermediate string allocation
602        let mut hasher = Sha256::new();
603        hasher.update(self.upload_salt.as_bytes());
604        hasher.update(b"/");
605        hasher.update(git_commit.as_bytes());
606        hasher.update(b"/");
607        hasher.update(file_path.as_bytes());
608        hasher.update(b"/");
609        hasher.update(test_name.as_bytes());
610        let result = hasher.finalize();
611
612        // Use full hash for maximum uniqueness and collision resistance
613        format!("test_{:x}", result)
614    }
615
616    /// Anonymize text content by replacing absolute paths with relative ones
617    /// Used for error messages, stack traces, system-out, and system-err
618    fn anonymize_text_content(&self, text: &str, project_root: &Path) -> String {
619        let mut result = text.to_string();
620
621        // Python-style: File "/path/to/file.py", line 123
622        result = PYTHON_FILE_RE
623            .replace_all(&result, |caps: &regex::Captures| {
624                if let Some(path_match) = caps.get(1) {
625                    let original_path = path_match.as_str();
626                    let relative_path = self.make_relative_path(original_path, project_root);
627                    format!(r#"File "{}""#, relative_path)
628                } else {
629                    caps[0].to_string()
630                }
631            })
632            .to_string();
633
634        // Java/JavaScript-style: at /path/to/file:123 or at /path/to/file.js:123:45
635        result = STACKTRACE_PATH_RE
636            .replace_all(&result, |caps: &regex::Captures| {
637                if let Some(path_match) = caps.get(1) {
638                    let original_path = path_match.as_str();
639                    let relative_path = self.make_relative_path(original_path, project_root);
640                    caps[0].replace(original_path, &relative_path)
641                } else {
642                    caps[0].to_string()
643                }
644            })
645            .to_string();
646
647        // Generic absolute Unix paths in text
648        result = UNIX_PATH_RE
649            .replace_all(&result, |caps: &regex::Captures| {
650                if let Some(path_match) = caps.get(1) {
651                    let original_path = path_match.as_str();
652                    let suffix = original_path.chars().last().unwrap_or(' ');
653                    let path_without_suffix = &original_path[..original_path.len() - 1];
654                    let relative_path = self.make_relative_path(path_without_suffix, project_root);
655                    format!("{}{}", relative_path, suffix)
656                } else {
657                    caps[0].to_string()
658                }
659            })
660            .to_string();
661
662        // Generic absolute Windows paths in text
663        result = WINDOWS_PATH_RE
664            .replace_all(&result, |caps: &regex::Captures| {
665                if let Some(path_match) = caps.get(1) {
666                    let original_path = path_match.as_str();
667                    let suffix = original_path.chars().last().unwrap_or(' ');
668                    let path_without_suffix = &original_path[..original_path.len() - 1];
669                    let relative_path = self.make_relative_path(path_without_suffix, project_root);
670                    format!("{}{}", relative_path, suffix)
671                } else {
672                    caps[0].to_string()
673                }
674            })
675            .to_string();
676
677        result
678    }
679
680    /// Anonymize JUnit XML test results for privacy
681    /// Removes hostnames and anonymizes file paths and test names in test attributes
682    pub fn anonymize_junit_xml(&self, xml_content: &[u8]) -> Vec<u8> {
683        if !self.config.anonymize_paths {
684            return xml_content.to_vec();
685        }
686
687        let project_root = self.config.get_project_root();
688        let project_root = Path::new(&project_root);
689
690        let mut reader = Reader::from_reader(xml_content);
691        reader.config_mut().trim_text(false); // Preserve whitespace
692
693        let mut writer = Writer::new(Cursor::new(Vec::new()));
694        let mut buf = Vec::new();
695
696        // Track which element we're inside for text content anonymization
697        let mut inside_anonymize_element = None::<String>;
698
699        loop {
700            match reader.read_event_into(&mut buf) {
701                Ok(Event::Eof) => break,
702
703                Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
704                    let elem_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
705
706                    // Track if we're entering an element whose text content should be anonymized
707                    if matches!(
708                        elem_name.as_str(),
709                        "failure" | "error" | "system-out" | "system-err"
710                    ) {
711                        inside_anonymize_element = Some(elem_name.clone());
712                    }
713
714                    // Process and anonymize attributes
715                    let mut new_elem = BytesStart::new(elem_name.clone());
716
717                    // Keep track of file path for test name hashing
718                    let mut file_path_for_hash = String::new();
719                    let mut test_name = String::new();
720
721                    // First pass: collect file path and test name
722                    for attr in e.attributes().flatten() {
723                        let key = String::from_utf8_lossy(attr.key.as_ref());
724                        if key == "file" || key == "filepath" {
725                            file_path_for_hash = String::from_utf8_lossy(&attr.value).to_string();
726                        } else if key == "name" && elem_name == "testcase" {
727                            test_name = String::from_utf8_lossy(&attr.value).to_string();
728                        }
729                    }
730
731                    // Second pass: modify attributes
732                    for attr in e.attributes().flatten() {
733                        let key = String::from_utf8_lossy(attr.key.as_ref());
734                        let value = String::from_utf8_lossy(&attr.value);
735
736                        match key.as_ref() {
737                            "hostname" => {
738                                // Skip hostname attribute entirely
739                                continue;
740                            }
741                            "file" | "filepath" => {
742                                // Anonymize file paths
743                                let relative_path = self.make_relative_path(&value, project_root);
744                                new_elem.push_attribute(Attribute::from((
745                                    key.as_bytes(),
746                                    relative_path.as_bytes(),
747                                )));
748                            }
749                            "classname" => {
750                                // Anonymize classname if it looks like a file path
751                                if value.contains('/') || value.contains('\\') {
752                                    let relative_path =
753                                        self.make_relative_path(&value, project_root);
754                                    let anonymized = relative_path.replace(['/', '\\'], ".");
755                                    new_elem.push_attribute(Attribute::from((
756                                        key.as_bytes(),
757                                        anonymized.as_bytes(),
758                                    )));
759                                } else {
760                                    new_elem.push_attribute(attr);
761                                }
762                            }
763                            "name" if elem_name == "testcase" => {
764                                // Hash test name
765                                let hashed_name =
766                                    self.hash_test_name(&test_name, &file_path_for_hash);
767                                new_elem.push_attribute(Attribute::from((
768                                    key.as_bytes(),
769                                    hashed_name.as_bytes(),
770                                )));
771                            }
772                            _ => {
773                                // Keep other attributes as-is
774                                new_elem.push_attribute(attr);
775                            }
776                        }
777                    }
778
779                    // Write the modified element
780                    if matches!(e, BytesStart { .. }) {
781                        writer.write_event(Event::Start(new_elem)).ok();
782                    } else {
783                        writer.write_event(Event::Empty(new_elem)).ok();
784                    }
785                }
786
787                Ok(Event::End(ref e)) => {
788                    let elem_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
789
790                    // Check if we're exiting an element whose content we were anonymizing
791                    if let Some(ref current_elem) = inside_anonymize_element {
792                        if current_elem == &elem_name {
793                            inside_anonymize_element = None;
794                        }
795                    }
796
797                    writer
798                        .write_event(Event::End(BytesEnd::new(elem_name)))
799                        .ok();
800                }
801
802                Ok(Event::Text(ref e)) => {
803                    // Anonymize text content if we're inside an error/failure/system element
804                    if inside_anonymize_element.is_some() {
805                        let text = reader.decoder().decode(e).unwrap_or_default().into_owned();
806                        let anonymized = self.anonymize_text_content(&text, project_root);
807                        writer
808                            .write_event(Event::Text(BytesText::new(&anonymized)))
809                            .ok();
810                    } else {
811                        writer.write_event(Event::Text(e.clone())).ok();
812                    }
813                }
814
815                Ok(e) => {
816                    // Pass through all other events unchanged
817                    writer.write_event(e).ok();
818                }
819
820                Err(e) => {
821                    eprintln!(
822                        "Error parsing XML at position {}: {:?}",
823                        reader.buffer_position(),
824                        e
825                    );
826                    // On error, return original content
827                    return xml_content.to_vec();
828                }
829            }
830
831            buf.clear();
832        }
833
834        writer.into_inner().into_inner()
835    }
836
837    /// Upload a single coverage file
838    pub fn upload_file(
839        &self,
840        file_path: &str,
841        language: &str,
842        coverage_format: &str,
843    ) -> Result<(), String> {
844        let reports = vec![self.read_coverage_file(file_path, language, coverage_format)?];
845        self.upload_reports(reports)
846    }
847
848    /// Upload multiple coverage files
849    pub fn upload_files(
850        &self,
851        files: Vec<(String, String, String)>, // (file_path, language, format)
852    ) -> Result<(), String> {
853        let mut reports = Vec::new();
854        for (file_path, language, format) in files {
855            reports.push(self.read_coverage_file(&file_path, &language, &format)?);
856        }
857        self.upload_reports(reports)
858    }
859
860    /// Upload JUnit XML test result files
861    ///
862    /// This method uploads JUnit XML test result files to the backend for analysis.
863    /// It handles anonymization of file paths and test names if enabled in the config.
864    ///
865    /// # Arguments
866    /// * `files` - Slice of PathBuf pointing to JUnit XML files to upload
867    ///
868    /// # Returns
869    /// * `Ok(())` on successful upload
870    /// * `Err(String)` with error message on failure
871    pub fn upload_test_files(&self, files: &[std::path::PathBuf]) -> Result<(), String> {
872        use std::path::PathBuf;
873
874        // Validate files exist and are XML
875        for file in files {
876            if !file.exists() {
877                return Err(format!("Test file not found: {}", file.display()));
878            }
879            if file.extension().map(|e| e.to_str()) != Some(Some("xml")) {
880                return Err(format!("Test file must be an XML file: {}", file.display()));
881            }
882        }
883
884        println!(
885            "Uploading {} test result file(s) to {}",
886            files.len(),
887            self.config.endpoint
888        );
889
890        // Read and anonymize each file
891        let mut test_contents: Vec<(PathBuf, Vec<u8>)> = Vec::new();
892        for file in files {
893            let content = std::fs::read(file)
894                .map_err(|e| format!("Failed to read test file {}: {}", file.display(), e))?;
895
896            // Anonymize JUnit XML for privacy (removes hostname, anonymizes paths and test names)
897            let anonymized_content = self.anonymize_junit_xml(&content);
898            test_contents.push((file.clone(), anonymized_content));
899        }
900
901        if self.config.anonymize_paths {
902            println!("🔒 Anonymized file paths and test names in test results for privacy");
903        }
904
905        // Create tar.gz archive in memory
906        let tarball_buffer = Vec::new();
907        let encoder = GzEncoder::new(
908            tarball_buffer,
909            Compression::new(self.config.compression_level),
910        );
911        let mut tar_builder = tar::Builder::new(encoder);
912
913        // Add each test file to the archive
914        for (idx, (original_path, content)) in test_contents.iter().enumerate() {
915            let filename = if files.len() == 1 {
916                // Single file: use "test_results.xml"
917                "test_results.xml".to_string()
918            } else {
919                // Multiple files: use "test_results_0.xml", "test_results_1.xml", etc.
920                // Or use the original filename if unique
921                let original_name = original_path
922                    .file_name()
923                    .and_then(|n| n.to_str())
924                    .unwrap_or("test_results.xml");
925                format!("test_results_{}.xml", idx).replace(
926                    &format!("test_results_{}.xml", idx),
927                    &format!("{}_{}", idx, original_name),
928                )
929            };
930
931            let mut header = tar::Header::new_gnu();
932            header.set_size(content.len() as u64);
933            header.set_mode(0o644);
934            header.set_cksum();
935
936            tar_builder
937                .append_data(&mut header, &filename, content.as_slice())
938                .map_err(|e| format!("Failed to add file to tar: {}", e))?;
939        }
940
941        // Finish tar archive and get the encoder back
942        let encoder = tar_builder
943            .into_inner()
944            .map_err(|e| format!("Failed to finish tar archive: {}", e))?;
945
946        // Finish gzip compression and get the compressed data
947        let tarball_data = encoder
948            .finish()
949            .map_err(|e| format!("Failed to finish gzip compression: {}", e))?;
950
951        println!("📦 Created tarball: {} bytes", tarball_data.len());
952
953        // Upload via multipart/form-data with "tests" field
954        let commit_sha = self
955            .config
956            .git_commit
957            .as_deref()
958            .unwrap_or("unknown")
959            .to_owned();
960
961        let mut form = reqwest::blocking::multipart::Form::new()
962            .part(
963                "tests",
964                reqwest::blocking::multipart::Part::bytes(tarball_data)
965                    .file_name("tests.tar.gz")
966                    .mime_str("application/gzip")
967                    .map_err(|e| format!("Failed to set mime type: {}", e))?,
968            )
969            .text("commit_sha", commit_sha)
970            .text(
971                "branch",
972                self.config.git_branch.as_deref().unwrap_or("").to_owned(),
973            )
974            .text(
975                "environment",
976                self.config
977                    .environment
978                    .as_deref()
979                    .unwrap_or("development")
980                    .to_owned(),
981            )
982            .text(
983                "test_type",
984                self.config
985                    .test_type
986                    .as_deref()
987                    .unwrap_or("combined")
988                    .to_owned(),
989            )
990            .text("agent_version", env!("CARGO_PKG_VERSION").to_string())
991            .text("salt", self.upload_salt.clone());
992
993        // Add commit timestamp if available
994        if let Some(ref timestamp) = self.config.git_commit_timestamp {
995            form = form.text("commit_timestamp", timestamp.clone());
996        }
997
998        let mut request = self
999            .client
1000            .post(&self.config.endpoint)
1001            .multipart(form)
1002            .header(
1003                "User-Agent",
1004                format!("testlint-sdk/{}", env!("CARGO_PKG_VERSION")),
1005            );
1006
1007        if let Some(ref api_key) = self.config.api_key {
1008            request = request.header("Authorization", format!("Bearer {}", api_key));
1009        }
1010
1011        let response = request
1012            .send()
1013            .map_err(|e| format!("Failed to send test results: {}", e))?;
1014
1015        if response.status().is_success() {
1016            println!("✓ Test results uploaded successfully");
1017            // Try to extract test_id from response
1018            if let Ok(text) = response.text() {
1019                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
1020                    if let Some(test_id) = json.get("test_id").and_then(|v| v.as_str()) {
1021                        println!("  Test ID: {}", test_id);
1022                    }
1023                }
1024            }
1025            Ok(())
1026        } else {
1027            Err(format!(
1028                "Server returned error: {} - {}",
1029                response.status(),
1030                response.text().unwrap_or_default()
1031            ))
1032        }
1033    }
1034
1035    /// Read coverage file and create CoverageReport
1036    fn read_coverage_file(
1037        &self,
1038        file_path: &str,
1039        language: &str,
1040        coverage_format: &str,
1041    ) -> Result<CoverageReport, String> {
1042        let path = Path::new(file_path);
1043
1044        if !path.exists() {
1045            return Err(format!("Coverage file not found: {}", file_path));
1046        }
1047
1048        let content =
1049            fs::read_to_string(path).map_err(|e| format!("Failed to read coverage file: {}", e))?;
1050
1051        // Anonymize file paths in coverage data if enabled
1052        let anonymized_content = self.anonymize_coverage_content(&content, coverage_format);
1053
1054        let file_size = anonymized_content.len() as u64;
1055
1056        // Determine content type based on format
1057        let content_type = match coverage_format.to_lowercase().as_str() {
1058            "coverage.py" | "jest" | "istanbul" => "application/json",
1059            "jacoco" | "cobertura" => "application/xml",
1060            "lcov" | "golang" => "text/plain",
1061            _ => "text/plain",
1062        };
1063
1064        let timestamp = Utc::now().to_rfc3339();
1065
1066        if self.config.anonymize_paths {
1067            println!("🔒 Anonymized file paths in coverage data for privacy");
1068        }
1069
1070        Ok(CoverageReport {
1071            language: language.to_string(),
1072            format: coverage_format.to_string(),
1073            git_commit: self.config.git_commit.clone().unwrap_or_default(),
1074            git_branch: self.config.git_branch.clone().unwrap_or_default(),
1075            timestamp,
1076            environment: self.config.environment.clone().unwrap_or_default(),
1077            content: Some(CoverageContent::TextContent(anonymized_content)),
1078            file_path: file_path.to_string(),
1079            content_type: content_type.to_string(),
1080            file_size_bytes: file_size,
1081        })
1082    }
1083
1084    /// Upload coverage reports to backend
1085    fn upload_reports(&self, reports: Vec<CoverageReport>) -> Result<(), String> {
1086        let batch = CoverageBatch {
1087            reports,
1088            reported_at: Utc::now().to_rfc3339(),
1089            agent_version: env!("CARGO_PKG_VERSION").to_string(),
1090        };
1091
1092        println!(
1093            "Uploading {} coverage report(s) to {}",
1094            batch.reports.len(),
1095            self.config.endpoint
1096        );
1097
1098        // Create tar.gz file and upload via multipart
1099        self.upload_as_tarball(&batch)
1100    }
1101
1102    /// Create tar.gz and upload via multipart/form-data
1103    fn upload_as_tarball(&self, batch: &CoverageBatch) -> Result<(), String> {
1104        // Create tar.gz directly in memory instead of using temporary file
1105        // This avoids double I/O (write-to-disk then read-from-disk)
1106        let tarball_buffer = Vec::new();
1107        let encoder = GzEncoder::new(
1108            tarball_buffer,
1109            Compression::new(self.config.compression_level),
1110        );
1111        let mut tar_builder = tar::Builder::new(encoder);
1112
1113        // Add files to tar archive sequentially (tar format requires sequential writes)
1114        // Note: Benchmark testing showed parallel data preparation is 9-30x slower due to
1115        // thread spawning overhead being larger than the actual work (which is in microseconds)
1116        for (idx, report) in batch.reports.iter().enumerate() {
1117            // Get content as slice without cloning - append_data accepts &[u8]
1118            let (content_slice, content_len): (&[u8], usize) = match &report.content {
1119                Some(CoverageContent::TextContent(text)) => {
1120                    let bytes = text.as_bytes();
1121                    (bytes, bytes.len())
1122                }
1123                Some(CoverageContent::BinaryContent(bytes)) => (bytes.as_slice(), bytes.len()),
1124                None => (&[], 0),
1125            };
1126
1127            // Determine filename based on format and language
1128            let extension = match report.format.as_str() {
1129                "coverage.py" | "jest" | "istanbul" => "json",
1130                "jacoco" | "cobertura" => "xml",
1131                "lcov" | "golang" => "lcov",
1132                _ => "txt",
1133            };
1134
1135            let filename = format!("coverage_{}.{}", idx, extension);
1136            let mut header = tar::Header::new_gnu();
1137            header.set_size(content_len as u64);
1138            header.set_mode(0o644);
1139            header.set_cksum();
1140
1141            tar_builder
1142                .append_data(&mut header, &filename, content_slice)
1143                .map_err(|e| format!("Failed to add file to tar: {}", e))?;
1144        }
1145
1146        // Add JUnit XML test results file if it exists
1147        if let Some(first_report) = batch.reports.first() {
1148            let file_path = Path::new(&first_report.file_path);
1149            if let Some(parent_dir) = file_path.parent() {
1150                let junit_xml_path = parent_dir.join("test_results.xml");
1151                if junit_xml_path.exists() {
1152                    let junit_xml_content = fs::read(&junit_xml_path)
1153                        .map_err(|e| format!("Failed to read JUnit XML: {}", e))?;
1154
1155                    // Anonymize JUnit XML for privacy (removes hostname, anonymizes paths)
1156                    let anonymized_junit = self.anonymize_junit_xml(&junit_xml_content);
1157
1158                    let mut test_header = tar::Header::new_gnu();
1159                    test_header.set_size(anonymized_junit.len() as u64);
1160                    test_header.set_mode(0o644);
1161                    test_header.set_cksum();
1162
1163                    tar_builder
1164                        .append_data(
1165                            &mut test_header,
1166                            "test_results.xml",
1167                            anonymized_junit.as_slice(),
1168                        )
1169                        .map_err(|e| format!("Failed to add JUnit XML to tar: {}", e))?;
1170
1171                    if self.config.anonymize_paths {
1172                        println!(
1173                            "📝 Including JUnit XML test results ({} bytes, anonymized)",
1174                            anonymized_junit.len()
1175                        );
1176                    } else {
1177                        println!(
1178                            "📝 Including JUnit XML test results ({} bytes)",
1179                            anonymized_junit.len()
1180                        );
1181                    }
1182                }
1183            }
1184        }
1185
1186        // Finish tar archive and get the encoder back
1187        let encoder = tar_builder
1188            .into_inner()
1189            .map_err(|e| format!("Failed to finish tar archive: {}", e))?;
1190
1191        // Finish gzip compression and get the compressed data
1192        let tarball_data = encoder
1193            .finish()
1194            .map_err(|e| format!("Failed to finish gzip compression: {}", e))?;
1195
1196        println!("📦 Created tarball: {} bytes", tarball_data.len());
1197
1198        // Upload via multipart/form-data with metadata as form parameters
1199        let commit_sha = self
1200            .config
1201            .git_commit
1202            .as_deref()
1203            .unwrap_or("unknown")
1204            .to_owned();
1205
1206        let mut form = reqwest::blocking::multipart::Form::new()
1207            .part(
1208                "file",
1209                reqwest::blocking::multipart::Part::bytes(tarball_data)
1210                    .file_name("coverage.tar.gz")
1211                    .mime_str("application/gzip")
1212                    .map_err(|e| format!("Failed to set mime type: {}", e))?,
1213            )
1214            .text("commit_sha", commit_sha.clone())
1215            .text(
1216                "branch_name",
1217                self.config
1218                    .git_branch
1219                    .as_deref()
1220                    .unwrap_or("unknown")
1221                    .to_owned(),
1222            )
1223            .text(
1224                "environment",
1225                self.config
1226                    .environment
1227                    .as_deref()
1228                    .unwrap_or("development")
1229                    .to_owned(),
1230            )
1231            .text(
1232                "test_type",
1233                self.config
1234                    .test_type
1235                    .as_deref()
1236                    .unwrap_or("unit")
1237                    .to_owned(),
1238            )
1239            .text("reported_at", batch.reported_at.clone())
1240            .text("agent_version", batch.agent_version.clone())
1241            .text("salt", self.upload_salt.clone())
1242            .text("anonymization_version", "1");
1243
1244        // Add commit timestamp if available
1245        if let Some(ref timestamp) = self.config.git_commit_timestamp {
1246            form = form.text("commit_timestamp", timestamp.clone());
1247        }
1248
1249        let mut request = self
1250            .client
1251            .post(&self.config.endpoint)
1252            .multipart(form)
1253            .header(
1254                "User-Agent",
1255                format!("testlint-sdk/{}", env!("CARGO_PKG_VERSION")),
1256            );
1257
1258        if let Some(ref api_key) = self.config.api_key {
1259            request = request.header("Authorization", format!("Bearer {}", api_key));
1260        }
1261
1262        let response = request
1263            .send()
1264            .map_err(|e| format!("Failed to send coverage report: {}", e))?;
1265
1266        if response.status().is_success() {
1267            println!("✓ Coverage report uploaded successfully");
1268            Ok(())
1269        } else {
1270            Err(format!(
1271                "Server returned error: {} - {}",
1272                response.status(),
1273                response.text().unwrap_or_default()
1274            ))
1275        }
1276    }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use std::path::Path;
1283
1284    /// Helper to create a test uploader with anonymization enabled
1285    fn create_test_uploader(project_root: &str) -> TestUploader {
1286        let config = TestUploaderConfig {
1287            endpoint: "http://test.invalid/upload".to_string(),
1288            api_key: Some("test_key".to_string()),
1289            format: UploadFormat::Protobuf,
1290            git_commit: Some("abc123def456".to_string()),
1291            git_branch: Some("main".to_string()),
1292            git_commit_timestamp: None,
1293            environment: Some("test".to_string()),
1294            test_type: Some("unit".to_string()),
1295            compression_level: 6,
1296            upload_timeout_secs: 60,
1297            max_retry_attempts: 3,
1298            retry_delay_ms: 1000,
1299            anonymize_paths: true,
1300            project_root: Some(project_root.to_string()),
1301        };
1302        TestUploader::new_with_salt(config, "test_salt_12345".to_string())
1303    }
1304
1305    /// Helper to create a test uploader with anonymization disabled
1306    fn create_test_uploader_no_anonymization(project_root: &str) -> TestUploader {
1307        let config = TestUploaderConfig {
1308            endpoint: "http://test.invalid/upload".to_string(),
1309            api_key: Some("test_key".to_string()),
1310            format: UploadFormat::Protobuf,
1311            git_commit: Some("abc123def456".to_string()),
1312            git_branch: Some("main".to_string()),
1313            git_commit_timestamp: None,
1314            environment: Some("test".to_string()),
1315            test_type: Some("unit".to_string()),
1316            compression_level: 6,
1317            upload_timeout_secs: 60,
1318            max_retry_attempts: 3,
1319            retry_delay_ms: 1000,
1320            anonymize_paths: false,
1321            project_root: Some(project_root.to_string()),
1322        };
1323        TestUploader::new_with_salt(config, "test_salt_12345".to_string())
1324    }
1325
1326    #[test]
1327    fn test_config_default() {
1328        let config = TestUploaderConfig::default();
1329        // In debug builds, default is localhost; in release builds, it's production
1330        #[cfg(debug_assertions)]
1331        let expected_endpoint = "http://localhost:8001/tests/upload";
1332        #[cfg(not(debug_assertions))]
1333        let expected_endpoint = "https://api.testlint.com/tests/upload";
1334
1335        assert_eq!(config.endpoint, expected_endpoint);
1336        assert_eq!(config.environment, Some("development".to_string()));
1337    }
1338
1339    #[test]
1340    fn test_config_from_args() {
1341        let config = TestUploaderConfig::from_env_and_args(
1342            Some("https://custom.endpoint/coverage".to_string()),
1343            Some("test-key".to_string()),
1344            Some("json".to_string()),
1345            Some("abc123".to_string()),
1346            Some("main".to_string()),
1347            None, // git_commit_timestamp
1348            Some("production".to_string()),
1349            Some("e2e".to_string()),
1350            Some(9), // compression_level
1351        );
1352
1353        assert_eq!(config.endpoint, "https://custom.endpoint/coverage");
1354        assert_eq!(config.api_key, Some("test-key".to_string()));
1355        assert_eq!(config.git_commit, Some("abc123".to_string()));
1356        assert_eq!(config.git_branch, Some("main".to_string()));
1357        assert_eq!(config.environment, Some("production".to_string()));
1358        assert_eq!(config.test_type, Some("e2e".to_string()));
1359    }
1360
1361    #[test]
1362    fn test_tarball_integrity() -> Result<(), Box<dyn std::error::Error>> {
1363        use flate2::read::GzDecoder;
1364        use tar::Archive;
1365
1366        // Create a test coverage report
1367        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1368        let coverage_file = temp_dir.path().join("coverage.json");
1369        let test_results_file = temp_dir.path().join("test_results.xml");
1370
1371        // Write test coverage data
1372        let coverage_data = r#"{
1373            "files": {
1374                "test.py": {
1375                    "executed_lines": [1, 2, 3],
1376                    "missing_lines": [4, 5],
1377                    "summary": {
1378                        "covered_lines": 3,
1379                        "num_statements": 5,
1380                        "percent_covered": 60.0
1381                    }
1382                }
1383            },
1384            "totals": {
1385                "covered_lines": 3,
1386                "num_statements": 5,
1387                "percent_covered": 60.0
1388            }
1389        }"#;
1390        fs::write(&coverage_file, coverage_data).expect("Failed to write coverage file");
1391
1392        // Write test JUnit XML data
1393        let junit_data = r#"<?xml version="1.0" encoding="utf-8"?>
1394<testsuites>
1395    <testsuite name="pytest" errors="0" failures="1" skipped="0" tests="3" time="0.123">
1396        <testcase classname="test_example" name="test_pass" time="0.001" />
1397        <testcase classname="test_example" name="test_fail" time="0.002">
1398            <failure message="assert False">test failed</failure>
1399        </testcase>
1400        <testcase classname="test_example" name="test_skip" time="0.000">
1401            <skipped message="skipped" />
1402        </testcase>
1403    </testsuite>
1404</testsuites>"#;
1405        fs::write(&test_results_file, junit_data).expect("Failed to write test results file");
1406
1407        // Create uploader config
1408        let config = TestUploaderConfig {
1409            compression_level: 6,
1410            upload_timeout_secs: 60,
1411            max_retry_attempts: 3,
1412            retry_delay_ms: 1000,
1413            ..Default::default()
1414        };
1415
1416        let uploader = TestUploader::new_with_salt(config, "test_salt_1234".to_string());
1417
1418        // Create a coverage report
1419        let coverage_path = coverage_file
1420            .to_str()
1421            .ok_or("Invalid UTF-8 in coverage file path")?;
1422        let report = uploader
1423            .read_coverage_file(coverage_path, "python", "coverage.py")
1424            .expect("Failed to read coverage file");
1425
1426        // Create batch and tarball
1427        let batch = CoverageBatch {
1428            reports: vec![report],
1429            reported_at: Utc::now().to_rfc3339(),
1430            agent_version: env!("CARGO_PKG_VERSION").to_string(),
1431        };
1432
1433        // Create the tarball using the same logic as upload_as_tarball
1434        let temp_tar_dir = tempfile::tempdir().expect("Failed to create temp dir");
1435        let tarball_path = temp_tar_dir.path().join("test_coverage.tar.gz");
1436
1437        let tar_file = File::create(&tarball_path).expect("Failed to create tarball");
1438        let encoder = GzEncoder::new(tar_file, Compression::best());
1439        let mut tar_builder = tar::Builder::new(encoder);
1440
1441        // Add coverage file
1442        let content = match &batch.reports[0].content {
1443            Some(CoverageContent::TextContent(text)) => text.as_bytes().to_vec(),
1444            _ => panic!("Expected text content"),
1445        };
1446
1447        let mut header = tar::Header::new_gnu();
1448        header.set_size(content.len() as u64);
1449        header.set_mode(0o644);
1450        header.set_cksum();
1451        tar_builder
1452            .append_data(&mut header, "coverage_0.json", content.as_slice())
1453            .expect("Failed to add coverage to tar");
1454
1455        // Add JUnit XML
1456        let junit_content = fs::read(&test_results_file).expect("Failed to read JUnit XML");
1457        let mut junit_header = tar::Header::new_gnu();
1458        junit_header.set_size(junit_content.len() as u64);
1459        junit_header.set_mode(0o644);
1460        junit_header.set_cksum();
1461        tar_builder
1462            .append_data(
1463                &mut junit_header,
1464                "test_results.xml",
1465                junit_content.as_slice(),
1466            )
1467            .expect("Failed to add JUnit XML to tar");
1468
1469        // Add metadata
1470        let metadata = serde_json::json!({
1471            "git_commit": "abc123",
1472            "git_branch": "main",
1473            "environment": "test",
1474            "test_type": "unit",
1475        });
1476        let metadata_bytes = serde_json::to_vec_pretty(&metadata).unwrap();
1477        let mut meta_header = tar::Header::new_gnu();
1478        meta_header.set_size(metadata_bytes.len() as u64);
1479        meta_header.set_mode(0o644);
1480        meta_header.set_cksum();
1481        tar_builder
1482            .append_data(&mut meta_header, "metadata.json", metadata_bytes.as_slice())
1483            .expect("Failed to add metadata to tar");
1484
1485        // THIS IS THE CRITICAL FIX: Finish tar AND gzip properly
1486        let encoder = tar_builder
1487            .into_inner()
1488            .expect("Failed to get encoder from tar builder");
1489        encoder.finish().expect("Failed to finish gzip encoder");
1490
1491        // Verify the tarball exists and has content
1492        assert!(tarball_path.exists(), "Tarball file should exist");
1493        let tarball_size = fs::metadata(&tarball_path)
1494            .expect("Failed to get tarball metadata")
1495            .len();
1496        assert!(tarball_size > 0, "Tarball should not be empty");
1497        println!("✓ Created tarball: {} bytes", tarball_size);
1498
1499        // Now verify the tarball can be decompressed and extracted
1500        let tarball_file = File::open(&tarball_path).expect("Failed to open tarball");
1501        let decoder = GzDecoder::new(tarball_file);
1502        let mut archive = Archive::new(decoder);
1503
1504        // Extract and verify all files
1505        let mut found_coverage = false;
1506        let mut found_junit = false;
1507        let mut found_metadata = false;
1508
1509        for entry in archive.entries().expect("Failed to read archive entries") {
1510            let mut entry = entry.expect("Failed to read entry");
1511            let path = entry.path().expect("Failed to get entry path");
1512            let filename = path.to_str().ok_or("Invalid UTF-8 in tarball entry path")?;
1513
1514            println!("✓ Found file in tarball: {}", filename);
1515
1516            match filename {
1517                "coverage_0.json" => {
1518                    found_coverage = true;
1519                    let mut contents = String::new();
1520                    entry
1521                        .read_to_string(&mut contents)
1522                        .expect("Failed to read coverage file");
1523                    assert!(
1524                        contents.contains("test.py"),
1525                        "Coverage file should contain test.py"
1526                    );
1527                }
1528                "test_results.xml" => {
1529                    found_junit = true;
1530                    let mut contents = String::new();
1531                    entry
1532                        .read_to_string(&mut contents)
1533                        .expect("Failed to read JUnit XML file");
1534                    assert!(
1535                        contents.contains("testsuite"),
1536                        "JUnit XML should contain testsuite"
1537                    );
1538                    assert!(
1539                        contents.contains("test_fail"),
1540                        "JUnit XML should contain test cases"
1541                    );
1542                }
1543                "metadata.json" => {
1544                    found_metadata = true;
1545                    let mut contents = String::new();
1546                    entry
1547                        .read_to_string(&mut contents)
1548                        .expect("Failed to read metadata file");
1549                    assert!(
1550                        contents.contains("abc123"),
1551                        "Metadata should contain git commit"
1552                    );
1553                }
1554                _ => panic!("Unexpected file in tarball: {}", filename),
1555            }
1556        }
1557
1558        // Verify all expected files were found
1559        assert!(found_coverage, "Coverage file should be in tarball");
1560        assert!(found_junit, "JUnit XML should be in tarball");
1561        assert!(found_metadata, "Metadata should be in tarball");
1562
1563        println!("✓ All files verified in tarball");
1564        println!("✓ Tarball integrity test passed!");
1565        Ok(())
1566    }
1567
1568    #[test]
1569    fn test_git_commit_detection() {
1570        // This test will pass if we're in a git repo, otherwise it returns None
1571        let commit = TestUploaderConfig::detect_git_commit();
1572
1573        // If we got a commit, verify it's a valid SHA-1 hash (40 hex chars)
1574        if let Some(ref hash) = commit {
1575            assert_eq!(hash.len(), 40, "Git commit hash should be 40 characters");
1576            assert!(
1577                hash.chars().all(|c| c.is_ascii_hexdigit()),
1578                "Git commit hash should only contain hex digits"
1579            );
1580        }
1581
1582        // The function should always return Some or None, never panic
1583        println!("Git commit detection: {:?}", commit);
1584    }
1585
1586    #[test]
1587    fn test_git_branch_detection() {
1588        // This test will pass if we're in a git repo, otherwise it returns None
1589        let branch = TestUploaderConfig::detect_git_branch();
1590
1591        // If we got a branch, verify it's not empty
1592        if let Some(ref branch_name) = branch {
1593            assert!(
1594                !branch_name.is_empty(),
1595                "Git branch name should not be empty"
1596            );
1597            assert!(
1598                !branch_name.contains('\n'),
1599                "Git branch name should not contain newlines"
1600            );
1601        }
1602
1603        // The function should always return Some or None, never panic
1604        println!("Git branch detection: {:?}", branch);
1605    }
1606
1607    #[test]
1608    fn test_config_auto_detects_git_info() {
1609        // Create config without providing git info
1610        let config = TestUploaderConfig::from_env_and_args(
1611            Some("https://test.endpoint/coverage".to_string()),
1612            Some("test-key".to_string()),
1613            None,
1614            None, // git_commit - should auto-detect
1615            None, // git_branch - should auto-detect
1616            None, // git_commit_timestamp
1617            None,
1618            None,
1619            None,
1620        );
1621
1622        // If we're in a git repo, these should be auto-detected
1623        // If not, they'll be None which is also fine
1624        if config.git_commit.is_some() {
1625            let commit = config.git_commit.as_ref().unwrap();
1626            assert_eq!(
1627                commit.len(),
1628                40,
1629                "Auto-detected commit should be valid SHA-1"
1630            );
1631            println!("Auto-detected commit: {}", commit);
1632        }
1633
1634        if config.git_branch.is_some() {
1635            let branch = config.git_branch.as_ref().unwrap();
1636            assert!(
1637                !branch.is_empty(),
1638                "Auto-detected branch should not be empty"
1639            );
1640            println!("Auto-detected branch: {}", branch);
1641        }
1642    }
1643
1644    #[test]
1645    fn test_config_explicit_git_info_overrides_detection() {
1646        // Provide explicit git info - should NOT auto-detect
1647        let config = TestUploaderConfig::from_env_and_args(
1648            Some("https://test.endpoint/coverage".to_string()),
1649            Some("test-key".to_string()),
1650            None,
1651            Some("0123456789abcdef0123456789abcdef01234567".to_string()), // explicit commit
1652            Some("feature-branch".to_string()),                           // explicit branch
1653            None,                                                         // git_commit_timestamp
1654            None,
1655            None,
1656            None,
1657        );
1658
1659        // Should use the explicit values, not auto-detect
1660        assert_eq!(
1661            config.git_commit,
1662            Some("0123456789abcdef0123456789abcdef01234567".to_string())
1663        );
1664        assert_eq!(config.git_branch, Some("feature-branch".to_string()));
1665    }
1666
1667    // ==================== hash_file_path tests ====================
1668
1669    #[test]
1670    fn test_hash_file_path_deterministic() {
1671        let uploader = create_test_uploader("/project");
1672
1673        // Same input should produce same output
1674        let hash1 = uploader.hash_file_path("src/main.rs");
1675        let hash2 = uploader.hash_file_path("src/main.rs");
1676        assert_eq!(hash1, hash2, "Hashing should be deterministic");
1677    }
1678
1679    #[test]
1680    fn test_hash_file_path_different_inputs_different_outputs() {
1681        let uploader = create_test_uploader("/project");
1682
1683        let hash1 = uploader.hash_file_path("src/main.rs");
1684        let hash2 = uploader.hash_file_path("src/lib.rs");
1685        let hash3 = uploader.hash_file_path("tests/test.rs");
1686
1687        assert_ne!(hash1, hash2, "Different files should have different hashes");
1688        assert_ne!(hash2, hash3, "Different files should have different hashes");
1689        assert_ne!(hash1, hash3, "Different files should have different hashes");
1690    }
1691
1692    #[test]
1693    fn test_hash_file_path_is_hex_string() {
1694        let uploader = create_test_uploader("/project");
1695
1696        let hash = uploader.hash_file_path("src/main.rs");
1697
1698        // SHA256 produces 64 hex characters
1699        assert_eq!(hash.len(), 64, "Hash should be 64 hex characters");
1700        assert!(
1701            hash.chars().all(|c| c.is_ascii_hexdigit()),
1702            "Hash should only contain hex digits"
1703        );
1704    }
1705
1706    #[test]
1707    fn test_hash_file_path_different_salt_different_hash() {
1708        let config1 = TestUploaderConfig {
1709            git_commit: Some("abc123".to_string()),
1710            project_root: Some("/project".to_string()),
1711            ..Default::default()
1712        };
1713        let config2 = config1.clone();
1714
1715        let uploader1 = TestUploader::new_with_salt(config1, "salt_one".to_string());
1716        let uploader2 = TestUploader::new_with_salt(config2, "salt_two".to_string());
1717
1718        let hash1 = uploader1.hash_file_path("src/main.rs");
1719        let hash2 = uploader2.hash_file_path("src/main.rs");
1720
1721        assert_ne!(
1722            hash1, hash2,
1723            "Different salts should produce different hashes"
1724        );
1725    }
1726
1727    // ==================== hash_test_name tests ====================
1728
1729    #[test]
1730    fn test_hash_test_name_deterministic() {
1731        let uploader = create_test_uploader("/project");
1732
1733        let hash1 = uploader.hash_test_name("test_something", "tests/test_main.py");
1734        let hash2 = uploader.hash_test_name("test_something", "tests/test_main.py");
1735        assert_eq!(hash1, hash2, "Test name hashing should be deterministic");
1736    }
1737
1738    #[test]
1739    fn test_hash_test_name_includes_file_context() {
1740        let uploader = create_test_uploader("/project");
1741
1742        // Same test name, different file should produce different hash
1743        let hash1 = uploader.hash_test_name("test_something", "tests/test_main.py");
1744        let hash2 = uploader.hash_test_name("test_something", "tests/test_other.py");
1745
1746        assert_ne!(
1747            hash1, hash2,
1748            "Same test name in different files should have different hashes"
1749        );
1750    }
1751
1752    #[test]
1753    fn test_hash_test_name_different_tests_different_hashes() {
1754        let uploader = create_test_uploader("/project");
1755
1756        let hash1 = uploader.hash_test_name("test_one", "tests/test.py");
1757        let hash2 = uploader.hash_test_name("test_two", "tests/test.py");
1758
1759        assert_ne!(
1760            hash1, hash2,
1761            "Different test names should have different hashes"
1762        );
1763    }
1764
1765    #[test]
1766    fn test_hash_test_name_prefix() {
1767        let uploader = create_test_uploader("/project");
1768
1769        let hash = uploader.hash_test_name("test_something", "tests/test.py");
1770        assert!(
1771            hash.starts_with("test_"),
1772            "Hashed test name should start with 'test_'"
1773        );
1774    }
1775
1776    // ==================== make_relative_path tests ====================
1777
1778    #[test]
1779    fn test_make_relative_path_absolute_to_relative() {
1780        let uploader = create_test_uploader("/home/user/project");
1781        let project_root = Path::new("/home/user/project");
1782
1783        let relative = uploader.make_relative_path("/home/user/project/src/main.rs", project_root);
1784        assert_eq!(relative, "src/main.rs");
1785    }
1786
1787    #[test]
1788    fn test_make_relative_path_already_relative() {
1789        let uploader = create_test_uploader("/home/user/project");
1790        let project_root = Path::new("/home/user/project");
1791
1792        let relative = uploader.make_relative_path("src/main.rs", project_root);
1793        assert_eq!(
1794            relative, "src/main.rs",
1795            "Already relative paths should be unchanged"
1796        );
1797    }
1798
1799    #[test]
1800    fn test_make_relative_path_outside_project() {
1801        let uploader = create_test_uploader("/home/user/project");
1802        let project_root = Path::new("/home/user/project");
1803
1804        // Path outside project root should be kept as-is
1805        let result = uploader.make_relative_path("/other/path/file.rs", project_root);
1806        assert_eq!(result, "/other/path/file.rs");
1807    }
1808
1809    #[test]
1810    fn test_make_relative_path_nested_path() {
1811        let uploader = create_test_uploader("/home/user/project");
1812        let project_root = Path::new("/home/user/project");
1813
1814        let relative = uploader.make_relative_path(
1815            "/home/user/project/src/deeply/nested/module/file.rs",
1816            project_root,
1817        );
1818        assert_eq!(relative, "src/deeply/nested/module/file.rs");
1819    }
1820
1821    // ==================== anonymize_lcov_paths tests ====================
1822
1823    #[test]
1824    fn test_anonymize_lcov_paths_basic() {
1825        let uploader = create_test_uploader("/home/user/project");
1826        let project_root = Path::new("/home/user/project");
1827
1828        let lcov_content = "TN:\nSF:/home/user/project/src/main.rs\nDA:1,1\nend_of_record";
1829        let result = uploader.anonymize_lcov_paths(lcov_content, project_root);
1830
1831        // Should not contain absolute path
1832        assert!(
1833            !result.contains("/home/user/project/"),
1834            "Absolute path should be removed"
1835        );
1836        // Should still contain SF: prefix
1837        assert!(result.contains("SF:"), "SF: prefix should remain");
1838        // DA lines should be unchanged
1839        assert!(result.contains("DA:1,1"), "DA lines should be unchanged");
1840    }
1841
1842    #[test]
1843    fn test_anonymize_lcov_paths_multiple_files() {
1844        let uploader = create_test_uploader("/project");
1845        let project_root = Path::new("/project");
1846
1847        let lcov_content = r#"TN:
1848SF:/project/src/main.rs
1849DA:1,1
1850DA:2,0
1851end_of_record
1852TN:
1853SF:/project/src/lib.rs
1854DA:1,5
1855end_of_record"#;
1856
1857        let result = uploader.anonymize_lcov_paths(lcov_content, project_root);
1858
1859        assert!(
1860            !result.contains("/project/src/main.rs"),
1861            "First absolute path should be hashed"
1862        );
1863        assert!(
1864            !result.contains("/project/src/lib.rs"),
1865            "Second absolute path should be hashed"
1866        );
1867        assert_eq!(result.matches("SF:").count(), 2, "Should have 2 SF: lines");
1868        assert_eq!(
1869            result.matches("end_of_record").count(),
1870            2,
1871            "Should have 2 end_of_record"
1872        );
1873    }
1874
1875    #[test]
1876    fn test_anonymize_lcov_paths_preserves_non_path_lines() {
1877        let uploader = create_test_uploader("/project");
1878        let project_root = Path::new("/project");
1879
1880        let lcov_content = r#"TN:
1881SF:/project/src/main.rs
1882FN:10,my_function
1883FNDA:5,my_function
1884DA:10,5
1885DA:11,5
1886LF:2
1887LH:2
1888end_of_record"#;
1889
1890        let result = uploader.anonymize_lcov_paths(lcov_content, project_root);
1891
1892        assert!(
1893            result.contains("FN:10,my_function"),
1894            "FN lines should be preserved"
1895        );
1896        assert!(
1897            result.contains("FNDA:5,my_function"),
1898            "FNDA lines should be preserved"
1899        );
1900        assert!(result.contains("DA:10,5"), "DA lines should be preserved");
1901        assert!(result.contains("LF:2"), "LF lines should be preserved");
1902        assert!(result.contains("LH:2"), "LH lines should be preserved");
1903    }
1904
1905    #[test]
1906    fn test_anonymize_lcov_paths_tn_with_path() {
1907        let uploader = create_test_uploader("/project");
1908        let project_root = Path::new("/project");
1909
1910        // TN: can contain paths in some tools
1911        let lcov_content =
1912            "TN:/project/tests/test_main.py::test_func\nSF:/project/src/main.py\nend_of_record";
1913        let result = uploader.anonymize_lcov_paths(lcov_content, project_root);
1914
1915        // TN: with path should be anonymized
1916        assert!(
1917            !result.contains("/project/tests/test_main.py"),
1918            "TN: with path should be anonymized"
1919        );
1920    }
1921
1922    // ==================== anonymize_xml_paths tests ====================
1923
1924    #[test]
1925    fn test_anonymize_xml_paths_filename_attribute() {
1926        let uploader = create_test_uploader("/project");
1927        let project_root = Path::new("/project");
1928
1929        let xml_content = r#"<coverage><class filename="/project/src/main.py" /></coverage>"#;
1930        let result = uploader.anonymize_xml_paths(xml_content, project_root);
1931
1932        assert!(
1933            !result.contains("/project/src/main.py"),
1934            "filename attribute should be anonymized"
1935        );
1936        assert!(
1937            result.contains("filename="),
1938            "filename attribute key should remain"
1939        );
1940    }
1941
1942    #[test]
1943    fn test_anonymize_xml_paths_path_attribute() {
1944        let uploader = create_test_uploader("/project");
1945        let project_root = Path::new("/project");
1946
1947        let xml_content = r#"<coverage><source path="/project/src" /></coverage>"#;
1948        let result = uploader.anonymize_xml_paths(xml_content, project_root);
1949
1950        assert!(
1951            !result.contains("\"/project/src\""),
1952            "path attribute should be anonymized"
1953        );
1954        assert!(result.contains("path="), "path attribute key should remain");
1955    }
1956
1957    #[test]
1958    fn test_anonymize_xml_paths_source_attribute() {
1959        let uploader = create_test_uploader("/project");
1960        let project_root = Path::new("/project");
1961
1962        let xml_content = r#"<coverage><sources><source="/project/src" /></sources></coverage>"#;
1963        let result = uploader.anonymize_xml_paths(xml_content, project_root);
1964
1965        // source= attribute should be processed
1966        assert!(result.contains("source="), "source attribute should exist");
1967    }
1968
1969    #[test]
1970    fn test_anonymize_xml_paths_preserves_structure() {
1971        let uploader = create_test_uploader("/project");
1972        let project_root = Path::new("/project");
1973
1974        let xml_content = r#"<?xml version="1.0" ?>
1975<coverage version="1.0">
1976    <packages>
1977        <package name="main" filename="/project/src/main.py">
1978            <classes>
1979                <class name="MyClass" filename="/project/src/main.py" line-rate="0.8"/>
1980            </classes>
1981        </package>
1982    </packages>
1983</coverage>"#;
1984
1985        let result = uploader.anonymize_xml_paths(xml_content, project_root);
1986
1987        assert!(
1988            result.contains("<coverage"),
1989            "XML structure should be preserved"
1990        );
1991        assert!(
1992            result.contains("</coverage>"),
1993            "XML structure should be preserved"
1994        );
1995        assert!(
1996            result.contains("line-rate=\"0.8\""),
1997            "Other attributes should be preserved"
1998        );
1999        assert!(
2000            result.contains("name=\"main\""),
2001            "Non-path attributes should be preserved"
2002        );
2003    }
2004
2005    // ==================== anonymize_json_paths tests ====================
2006
2007    #[test]
2008    fn test_anonymize_json_paths_file_keys() {
2009        let uploader = create_test_uploader("/project");
2010        let project_root = Path::new("/project");
2011
2012        let json_content = r#"{"files": {"/project/src/main.py": {"covered": 10}}}"#;
2013        let result = uploader.anonymize_json_paths(json_content, project_root);
2014
2015        assert!(
2016            !result.contains("/project/src/main.py"),
2017            "File path key should be anonymized"
2018        );
2019        assert!(
2020            result.contains("covered"),
2021            "Other content should be preserved"
2022        );
2023    }
2024
2025    #[test]
2026    fn test_anonymize_json_paths_string_values() {
2027        let uploader = create_test_uploader("/project");
2028        let project_root = Path::new("/project");
2029
2030        let json_content = r#"{"path": "/project/src/main.py", "lines": 100}"#;
2031        let result = uploader.anonymize_json_paths(json_content, project_root);
2032
2033        assert!(
2034            !result.contains("/project/src/main.py"),
2035            "File path value should be anonymized"
2036        );
2037        assert!(result.contains("100"), "Numeric values should be preserved");
2038    }
2039
2040    #[test]
2041    fn test_anonymize_json_paths_nested_objects() {
2042        let uploader = create_test_uploader("/project");
2043        let project_root = Path::new("/project");
2044
2045        let json_content = r#"{
2046            "meta": {"version": "1.0"},
2047            "files": {
2048                "/project/src/main.py": {
2049                    "summary": {"lines": 100, "path": "/project/src/main.py"}
2050                }
2051            }
2052        }"#;
2053        let result = uploader.anonymize_json_paths(json_content, project_root);
2054
2055        // Should not contain any absolute paths
2056        assert!(
2057            !result.contains("/project/"),
2058            "All absolute paths should be anonymized"
2059        );
2060        assert!(
2061            result.contains("version"),
2062            "Non-path content should be preserved"
2063        );
2064    }
2065
2066    #[test]
2067    fn test_anonymize_json_paths_array_values() {
2068        let uploader = create_test_uploader("/project");
2069        let project_root = Path::new("/project");
2070
2071        let json_content = r#"{"files": ["/project/a.py", "/project/b.py"]}"#;
2072        let result = uploader.anonymize_json_paths(json_content, project_root);
2073
2074        assert!(
2075            !result.contains("/project/a.py"),
2076            "Array path values should be anonymized"
2077        );
2078        assert!(
2079            !result.contains("/project/b.py"),
2080            "Array path values should be anonymized"
2081        );
2082    }
2083
2084    #[test]
2085    fn test_anonymize_json_paths_preserves_urls() {
2086        let uploader = create_test_uploader("/project");
2087        let project_root = Path::new("/project");
2088
2089        let json_content = r#"{"url": "https://example.com/path"}"#;
2090        let result = uploader.anonymize_json_paths(json_content, project_root);
2091
2092        assert!(
2093            result.contains("https://example.com/path"),
2094            "URLs should not be anonymized"
2095        );
2096    }
2097
2098    #[test]
2099    fn test_anonymize_json_paths_invalid_json() {
2100        let uploader = create_test_uploader("/project");
2101        let project_root = Path::new("/project");
2102
2103        let invalid_json = "not valid json {{{";
2104        let result = uploader.anonymize_json_paths(invalid_json, project_root);
2105
2106        assert_eq!(
2107            result, invalid_json,
2108            "Invalid JSON should be returned unchanged"
2109        );
2110    }
2111
2112    // ==================== anonymize_text_content tests ====================
2113
2114    #[test]
2115    fn test_anonymize_text_content_python_traceback() {
2116        let uploader = create_test_uploader("/project");
2117        let project_root = Path::new("/project");
2118
2119        let text = r#"Traceback (most recent call last):
2120  File "/project/src/main.py", line 42, in test
2121    raise ValueError("test")
2122ValueError: test"#;
2123
2124        let result = uploader.anonymize_text_content(text, project_root);
2125
2126        assert!(
2127            !result.contains("/project/src/main.py"),
2128            "Python file path should be relativized"
2129        );
2130        assert!(
2131            result.contains("src/main.py"),
2132            "Relative path should be present"
2133        );
2134        assert!(
2135            result.contains("line 42"),
2136            "Line number should be preserved"
2137        );
2138    }
2139
2140    #[test]
2141    fn test_anonymize_text_content_java_stacktrace() {
2142        let uploader = create_test_uploader("/project");
2143        let project_root = Path::new("/project");
2144
2145        let text = "at /project/src/Main.java:123\nat /project/src/Utils.java:456";
2146
2147        let result = uploader.anonymize_text_content(text, project_root);
2148
2149        assert!(
2150            !result.contains("/project/src/Main.java"),
2151            "Java file path should be relativized"
2152        );
2153        assert!(result.contains(":123"), "Line numbers should be preserved");
2154    }
2155
2156    #[test]
2157    fn test_anonymize_text_content_javascript_stacktrace() {
2158        let uploader = create_test_uploader("/project");
2159        let project_root = Path::new("/project");
2160
2161        let text = "at /project/src/index.js:10:5";
2162
2163        let result = uploader.anonymize_text_content(text, project_root);
2164
2165        assert!(
2166            !result.contains("/project/src/index.js"),
2167            "JavaScript file path should be relativized"
2168        );
2169    }
2170
2171    #[test]
2172    fn test_anonymize_text_content_generic_unix_path() {
2173        let uploader = create_test_uploader("/project");
2174        let project_root = Path::new("/project");
2175
2176        let text = "Error in /project/src/lib.rs: something failed";
2177
2178        let result = uploader.anonymize_text_content(text, project_root);
2179
2180        assert!(
2181            !result.contains("/project/src/lib.rs"),
2182            "Unix path should be relativized"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_anonymize_text_content_preserves_non_paths() {
2188        let uploader = create_test_uploader("/project");
2189        let project_root = Path::new("/project");
2190
2191        let text = "Error: expected 5 but got 10";
2192
2193        let result = uploader.anonymize_text_content(text, project_root);
2194
2195        assert_eq!(result, text, "Non-path text should be unchanged");
2196    }
2197
2198    // ==================== anonymize_coverage_content tests ====================
2199
2200    #[test]
2201    fn test_anonymize_coverage_content_json_format() {
2202        let uploader = create_test_uploader("/project");
2203
2204        let content = r#"{"files": {"/project/src/main.py": {}}}"#;
2205        let result = uploader.anonymize_coverage_content(content, "coverage.py");
2206
2207        assert!(
2208            !result.contains("/project/"),
2209            "JSON paths should be anonymized for coverage.py"
2210        );
2211    }
2212
2213    #[test]
2214    fn test_anonymize_coverage_content_lcov_format() {
2215        let uploader = create_test_uploader("/project");
2216
2217        let content = "SF:/project/src/main.rs\nend_of_record";
2218        let result = uploader.anonymize_coverage_content(content, "lcov");
2219
2220        assert!(
2221            !result.contains("/project/"),
2222            "LCOV paths should be anonymized"
2223        );
2224    }
2225
2226    #[test]
2227    fn test_anonymize_coverage_content_xml_format() {
2228        let uploader = create_test_uploader("/project");
2229
2230        let content = r#"<coverage filename="/project/src/main.py"/>"#;
2231        let result = uploader.anonymize_coverage_content(content, "cobertura");
2232
2233        assert!(
2234            !result.contains("/project/"),
2235            "XML paths should be anonymized for cobertura"
2236        );
2237    }
2238
2239    #[test]
2240    fn test_anonymize_coverage_content_unknown_format() {
2241        let uploader = create_test_uploader("/project");
2242
2243        let content = "some content with /project/path";
2244        let result = uploader.anonymize_coverage_content(content, "unknown_format");
2245
2246        assert_eq!(
2247            result, content,
2248            "Unknown format should return content unchanged"
2249        );
2250    }
2251
2252    #[test]
2253    fn test_anonymize_coverage_content_disabled() {
2254        let uploader = create_test_uploader_no_anonymization("/project");
2255
2256        let content = r#"{"files": {"/project/src/main.py": {}}}"#;
2257        let result = uploader.anonymize_coverage_content(content, "coverage.py");
2258
2259        assert_eq!(
2260            result, content,
2261            "With anonymization disabled, content should be unchanged"
2262        );
2263    }
2264
2265    // ==================== anonymize_junit_xml tests ====================
2266
2267    #[test]
2268    fn test_anonymize_junit_xml_removes_hostname() {
2269        let uploader = create_test_uploader("/project");
2270
2271        let xml = r#"<?xml version="1.0"?><testsuite hostname="my-machine.local"><testcase name="test1"/></testsuite>"#;
2272        let result = uploader.anonymize_junit_xml(xml.as_bytes());
2273        let result_str = String::from_utf8_lossy(&result);
2274
2275        assert!(
2276            !result_str.contains("hostname"),
2277            "hostname attribute should be removed"
2278        );
2279        assert!(
2280            !result_str.contains("my-machine.local"),
2281            "hostname value should be removed"
2282        );
2283    }
2284
2285    #[test]
2286    fn test_anonymize_junit_xml_hashes_test_names() {
2287        let uploader = create_test_uploader("/project");
2288
2289        let xml = r#"<?xml version="1.0"?><testsuite><testcase name="test_my_function" file="tests/test.py"/></testsuite>"#;
2290        let result = uploader.anonymize_junit_xml(xml.as_bytes());
2291        let result_str = String::from_utf8_lossy(&result);
2292
2293        assert!(
2294            !result_str.contains(r#"name="test_my_function""#),
2295            "Original test name should be hashed"
2296        );
2297        assert!(
2298            result_str.contains(r#"name="test_"#),
2299            "Hashed test name should start with test_"
2300        );
2301    }
2302
2303    #[test]
2304    fn test_anonymize_junit_xml_relativizes_file_paths() {
2305        let uploader = create_test_uploader("/project");
2306
2307        let xml = r#"<?xml version="1.0"?><testsuite><testcase name="test1" file="/project/tests/test.py"/></testsuite>"#;
2308        let result = uploader.anonymize_junit_xml(xml.as_bytes());
2309        let result_str = String::from_utf8_lossy(&result);
2310
2311        assert!(
2312            !result_str.contains("/project/tests/test.py"),
2313            "Absolute file path should be relativized"
2314        );
2315        assert!(
2316            result_str.contains("tests/test.py"),
2317            "Relative path should be present"
2318        );
2319    }
2320
2321    #[test]
2322    fn test_anonymize_junit_xml_handles_classname_paths() {
2323        let uploader = create_test_uploader("/project");
2324
2325        let xml = r#"<?xml version="1.0"?><testsuite><testcase name="test1" classname="/project/src/utils/Helper"/></testsuite>"#;
2326        let result = uploader.anonymize_junit_xml(xml.as_bytes());
2327        let result_str = String::from_utf8_lossy(&result);
2328
2329        assert!(
2330            !result_str.contains("/project/src/utils/Helper"),
2331            "Classname with path should be anonymized"
2332        );
2333        // Should convert slashes to dots
2334        assert!(
2335            result_str.contains("src.utils.Helper"),
2336            "Classname paths should use dots: {}",
2337            result_str
2338        );
2339    }
2340
2341    #[test]
2342    fn test_anonymize_junit_xml_preserves_structure() {
2343        let uploader = create_test_uploader("/project");
2344
2345        let xml = r#"<?xml version="1.0"?>
2346<testsuites>
2347    <testsuite name="MyTests" tests="2" failures="0">
2348        <testcase name="test1" time="0.123"/>
2349        <testcase name="test2" time="0.456">
2350            <failure message="assertion failed"/>
2351        </testcase>
2352    </testsuite>
2353</testsuites>"#;
2354
2355        let result = uploader.anonymize_junit_xml(xml.as_bytes());
2356        let result_str = String::from_utf8_lossy(&result);
2357
2358        assert!(
2359            result_str.contains("<testsuites>"),
2360            "Root element should be preserved"
2361        );
2362        assert!(
2363            result_str.contains("</testsuites>"),
2364            "Closing tag should be preserved"
2365        );
2366        assert!(
2367            result_str.contains("tests=\"2\""),
2368            "Test count should be preserved"
2369        );
2370        assert!(
2371            result_str.contains("failures=\"0\""),
2372            "Failure count should be preserved"
2373        );
2374        assert!(
2375            result_str.contains("<failure"),
2376            "Failure element should be preserved"
2377        );
2378    }
2379
2380    #[test]
2381    fn test_anonymize_junit_xml_disabled() {
2382        let uploader = create_test_uploader_no_anonymization("/project");
2383
2384        let xml = r#"<?xml version="1.0"?><testsuite hostname="my-machine"><testcase name="test_func"/></testsuite>"#;
2385        let result = uploader.anonymize_junit_xml(xml.as_bytes());
2386
2387        assert_eq!(
2388            result,
2389            xml.as_bytes(),
2390            "With anonymization disabled, XML should be unchanged"
2391        );
2392    }
2393
2394    #[test]
2395    fn test_anonymize_junit_xml_invalid_xml() {
2396        let uploader = create_test_uploader("/project");
2397
2398        let invalid_xml = b"not valid xml <<<<";
2399        let result = uploader.anonymize_junit_xml(invalid_xml);
2400
2401        assert_eq!(
2402            result, invalid_xml,
2403            "Invalid XML should be returned unchanged"
2404        );
2405    }
2406
2407    #[test]
2408    fn test_anonymize_junit_xml_deterministic() {
2409        let uploader = create_test_uploader("/project");
2410
2411        let xml = r#"<?xml version="1.0"?><testsuite><testcase name="test1" file="test.py"/></testsuite>"#;
2412
2413        let result1 = uploader.anonymize_junit_xml(xml.as_bytes());
2414        let result2 = uploader.anonymize_junit_xml(xml.as_bytes());
2415
2416        assert_eq!(result1, result2, "Anonymization should be deterministic");
2417    }
2418
2419    // ==================== Additional edge cases ====================
2420
2421    #[test]
2422    fn test_upload_format_enum() {
2423        // Test that UploadFormat can be cloned and compared via Debug
2424        let format1 = UploadFormat::Json;
2425        let format2 = UploadFormat::Protobuf;
2426
2427        let format1_clone = format1.clone();
2428        assert!(matches!(format1_clone, UploadFormat::Json));
2429        assert!(matches!(format2, UploadFormat::Protobuf));
2430    }
2431
2432    #[test]
2433    fn test_coverage_content_enum() {
2434        let text_content = CoverageContent::TextContent("hello".to_string());
2435        let binary_content = CoverageContent::BinaryContent(vec![1, 2, 3]);
2436
2437        // Test that they can be cloned
2438        let _text_clone = text_content.clone();
2439        let _binary_clone = binary_content.clone();
2440    }
2441
2442    #[test]
2443    fn test_coverage_report_struct() {
2444        let report = CoverageReport {
2445            language: "rust".to_string(),
2446            format: "lcov".to_string(),
2447            git_commit: "abc123".to_string(),
2448            git_branch: "main".to_string(),
2449            timestamp: "2024-01-01T00:00:00Z".to_string(),
2450            environment: "test".to_string(),
2451            content: Some(CoverageContent::TextContent("test".to_string())),
2452            file_path: "/path/to/file".to_string(),
2453            content_type: "text/plain".to_string(),
2454            file_size_bytes: 100,
2455        };
2456
2457        let report_clone = report.clone();
2458        assert_eq!(report_clone.language, "rust");
2459        assert_eq!(report_clone.format, "lcov");
2460    }
2461
2462    #[test]
2463    fn test_coverage_batch_struct() {
2464        let batch = CoverageBatch {
2465            reports: vec![],
2466            reported_at: "2024-01-01T00:00:00Z".to_string(),
2467            agent_version: "1.0.0".to_string(),
2468        };
2469
2470        let batch_clone = batch.clone();
2471        assert_eq!(batch_clone.reported_at, "2024-01-01T00:00:00Z");
2472        assert_eq!(batch_clone.agent_version, "1.0.0");
2473    }
2474
2475    #[test]
2476    fn test_config_compression_level_capped() {
2477        let config = TestUploaderConfig::from_env_and_args(
2478            None,
2479            None,
2480            None,
2481            None,
2482            None,
2483            None,
2484            None,
2485            None,
2486            Some(100), // Should be capped at 9
2487        );
2488
2489        assert_eq!(
2490            config.compression_level, 9,
2491            "Compression level should be capped at 9"
2492        );
2493    }
2494
2495    #[test]
2496    fn test_config_get_project_root_explicit() {
2497        let config = TestUploaderConfig {
2498            project_root: Some("/my/explicit/root".to_string()),
2499            ..Default::default()
2500        };
2501
2502        assert_eq!(
2503            config.get_project_root(),
2504            "/my/explicit/root",
2505            "Explicit project root should be used"
2506        );
2507    }
2508
2509    #[test]
2510    fn test_generate_local_salt() {
2511        let salt1 = TestUploader::generate_local_salt();
2512        let salt2 = TestUploader::generate_local_salt();
2513
2514        // Salts should be different (timestamp + pid based)
2515        // Note: There's a very small chance they could be the same if called in same nanosecond
2516        assert_eq!(salt1.len(), 64, "Salt should be 64 hex characters (SHA256)");
2517        assert!(
2518            salt1.chars().all(|c| c.is_ascii_hexdigit()),
2519            "Salt should only contain hex digits"
2520        );
2521
2522        // Most likely different, but not guaranteed
2523        // Just check format is correct
2524        assert_eq!(
2525            salt2.len(),
2526            64,
2527            "Second salt should also be 64 hex characters"
2528        );
2529    }
2530}