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
18lazy_static! {
20 static ref PYTHON_FILE_RE: Regex = Regex::new(r#"File "([^"]+)""#).unwrap();
22
23 static ref STACKTRACE_PATH_RE: Regex = Regex::new(r"(?:at |in )(/[^\s:]+|[A-Z]:[^\s:]+):").unwrap();
25
26 static ref UNIX_PATH_RE: Regex = Regex::new(r"(/[^\s]+\.(?:py|js|ts|java|rs|go|cpp|c|h)[:\s\)])").unwrap();
28
29 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 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#[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>, pub environment: Option<String>,
75 pub test_type: Option<String>,
76 pub compression_level: u32, pub upload_timeout_secs: u64, pub max_retry_attempts: u32, pub retry_delay_ms: u64, pub anonymize_paths: bool, pub project_root: Option<String>, }
83
84#[derive(Debug, Clone)]
85pub enum UploadFormat {
86 Json,
87 Protobuf,
88}
89
90impl Default for TestUploaderConfig {
91 fn default() -> Self {
92 #[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, upload_timeout_secs: 60, max_retry_attempts: 3, retry_delay_ms: 1000, anonymize_paths: true, project_root: None, }
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 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); }
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 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); }
191
192 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 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 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 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 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 fn detect_project_root() -> Option<String> {
256 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 std::env::current_dir()
268 .ok()
269 .and_then(|p| p.to_str().map(|s| s.to_string()))
270 }
271
272 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, }
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 let upload_salt = Self::register_salt_with_server(&config)?;
296
297 Ok(Self {
298 config,
299 client,
300 upload_salt,
301 })
302 }
303
304 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 let proposed_salt = Self::generate_local_salt();
314
315 let base_url = config.endpoint.replace("/tests/upload", "");
317
318 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 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 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 #[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 #[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 self.anonymize_json_paths(content, project_root)
398 }
399 "jacoco" | "cobertura" => {
400 self.anonymize_xml_paths(content, project_root)
402 }
403 "lcov" | "golang" => {
404 self.anonymize_lcov_paths(content, project_root)
406 }
407 _ => content.to_string(),
408 }
409 }
410
411 fn anonymize_json_paths(&self, content: &str, project_root: &Path) -> String {
413 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 fn anonymize_json_value(&self, value: &mut serde_json::Value, project_root: &Path) {
424 match value {
425 serde_json::Value::Object(map) => {
426 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 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 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 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 fn anonymize_xml_paths(&self, content: &str, project_root: &Path) -> String {
475 let mut result = content.to_string();
476
477 result = XML_FILENAME_RE
479 .replace_all(&result, |caps: ®ex::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 result = XML_PATH_RE
493 .replace_all(&result, |caps: ®ex::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 result = XML_SOURCE_RE
507 .replace_all(&result, |caps: ®ex::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 fn anonymize_lcov_paths(&self, content: &str, project_root: &Path) -> String {
524 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 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 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 result.push_str(line);
551 }
552 }
553
554 result
555 }
556
557 fn make_relative_path(&self, path_str: &str, project_root: &Path) -> String {
559 let path = Path::new(path_str);
560
561 if let Ok(relative) = path.strip_prefix(project_root) {
563 relative.to_string_lossy().to_string()
564 } else {
565 path_str.to_string()
567 }
568 }
569
570 #[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 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 format!("{:x}", result)
590 }
591
592 #[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 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 format!("test_{:x}", result)
614 }
615
616 fn anonymize_text_content(&self, text: &str, project_root: &Path) -> String {
619 let mut result = text.to_string();
620
621 result = PYTHON_FILE_RE
623 .replace_all(&result, |caps: ®ex::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 result = STACKTRACE_PATH_RE
636 .replace_all(&result, |caps: ®ex::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 result = UNIX_PATH_RE
649 .replace_all(&result, |caps: ®ex::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 result = WINDOWS_PATH_RE
664 .replace_all(&result, |caps: ®ex::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 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); let mut writer = Writer::new(Cursor::new(Vec::new()));
694 let mut buf = Vec::new();
695
696 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 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 let mut new_elem = BytesStart::new(elem_name.clone());
716
717 let mut file_path_for_hash = String::new();
719 let mut test_name = String::new();
720
721 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 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 continue;
740 }
741 "file" | "filepath" => {
742 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 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 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 new_elem.push_attribute(attr);
775 }
776 }
777 }
778
779 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 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 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 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 return xml_content.to_vec();
828 }
829 }
830
831 buf.clear();
832 }
833
834 writer.into_inner().into_inner()
835 }
836
837 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 pub fn upload_files(
850 &self,
851 files: Vec<(String, String, String)>, ) -> 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 pub fn upload_test_files(&self, files: &[std::path::PathBuf]) -> Result<(), String> {
872 use std::path::PathBuf;
873
874 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 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 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 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 for (idx, (original_path, content)) in test_contents.iter().enumerate() {
915 let filename = if files.len() == 1 {
916 "test_results.xml".to_string()
918 } else {
919 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 let encoder = tar_builder
943 .into_inner()
944 .map_err(|e| format!("Failed to finish tar archive: {}", e))?;
945
946 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 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 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 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 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 let anonymized_content = self.anonymize_coverage_content(&content, coverage_format);
1053
1054 let file_size = anonymized_content.len() as u64;
1055
1056 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 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 self.upload_as_tarball(&batch)
1100 }
1101
1102 fn upload_as_tarball(&self, batch: &CoverageBatch) -> Result<(), String> {
1104 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 for (idx, report) in batch.reports.iter().enumerate() {
1117 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 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 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 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 let encoder = tar_builder
1188 .into_inner()
1189 .map_err(|e| format!("Failed to finish tar archive: {}", e))?;
1190
1191 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 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 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 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 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 #[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, Some("production".to_string()),
1349 Some("e2e".to_string()),
1350 Some(9), );
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let commit = TestUploaderConfig::detect_git_commit();
1572
1573 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 println!("Git commit detection: {:?}", commit);
1584 }
1585
1586 #[test]
1587 fn test_git_branch_detection() {
1588 let branch = TestUploaderConfig::detect_git_branch();
1590
1591 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 println!("Git branch detection: {:?}", branch);
1605 }
1606
1607 #[test]
1608 fn test_config_auto_detects_git_info() {
1609 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, None, None, None,
1618 None,
1619 None,
1620 );
1621
1622 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 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()), Some("feature-branch".to_string()), None, None,
1655 None,
1656 None,
1657 );
1658
1659 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 #[test]
1670 fn test_hash_file_path_deterministic() {
1671 let uploader = create_test_uploader("/project");
1672
1673 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 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 #[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 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 #[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 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 #[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 assert!(
1833 !result.contains("/home/user/project/"),
1834 "Absolute path should be removed"
1835 );
1836 assert!(result.contains("SF:"), "SF: prefix should remain");
1838 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 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 assert!(
1917 !result.contains("/project/tests/test_main.py"),
1918 "TN: with path should be anonymized"
1919 );
1920 }
1921
1922 #[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 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 #[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 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 #[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 #[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 #[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 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 #[test]
2422 fn test_upload_format_enum() {
2423 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 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), );
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 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 assert_eq!(
2525 salt2.len(),
2526 64,
2527 "Second salt should also be 64 hex characters"
2528 );
2529 }
2530}