Skip to main content

testx/adapters/
dotnet.rs

1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::{combined_output, duration_from_secs_safe, has_marker_in_subdirs, truncate};
8use super::{
9    ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus,
10    TestSuite,
11};
12
13pub struct DotnetAdapter;
14
15impl Default for DotnetAdapter {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl DotnetAdapter {
22    pub fn new() -> Self {
23        Self
24    }
25
26    fn has_dotnet_project(project_dir: &Path) -> bool {
27        // Check root directory first
28        if let Ok(entries) = std::fs::read_dir(project_dir) {
29            for entry in entries.flatten() {
30                let name = entry.file_name();
31                let name = name.to_string_lossy();
32                if name.ends_with(".csproj") || name.ends_with(".fsproj") || name.ends_with(".sln")
33                {
34                    return true;
35                }
36            }
37        }
38        // Fallback: check up to 2 levels deep (e.g., Src/Project/Project.csproj)
39        has_marker_in_subdirs(project_dir, 2, |name| {
40            name.ends_with(".csproj") || name.ends_with(".fsproj") || name.ends_with(".sln")
41        })
42    }
43
44    fn detect_project_type(project_dir: &Path) -> &'static str {
45        if let Ok(entries) = std::fs::read_dir(project_dir) {
46            for entry in entries.flatten() {
47                let name = entry.file_name();
48                let name = name.to_string_lossy();
49                if name.ends_with(".fsproj") {
50                    return "F#";
51                }
52            }
53        }
54        // Also check subdirectories for F# projects
55        if has_marker_in_subdirs(project_dir, 2, |name| name.ends_with(".fsproj")) {
56            return "F#";
57        }
58        "C#"
59    }
60
61    /// Find the best project/solution file to pass to `dotnet test`.
62    ///
63    /// Priority: .sln at root > .sln in subdirs > .csproj/.fsproj at root > in subdirs.
64    /// Returns None if a project file exists at root (dotnet discovers it automatically).
65    fn find_project_file(project_dir: &Path) -> Option<std::path::PathBuf> {
66        // If root has .sln or .csproj/.fsproj, dotnet test auto-discovers — no path needed
67        if let Ok(entries) = std::fs::read_dir(project_dir) {
68            for entry in entries.flatten() {
69                let name = entry.file_name();
70                let name_str = name.to_string_lossy();
71                if name_str.ends_with(".sln")
72                    || name_str.ends_with(".csproj")
73                    || name_str.ends_with(".fsproj")
74                {
75                    return None;
76                }
77            }
78        }
79
80        // Search subdirectories: prefer .sln files over .csproj/.fsproj
81        let mut best_sln: Option<std::path::PathBuf> = None;
82        let mut best_proj: Option<std::path::PathBuf> = None;
83        Self::scan_for_project_files(project_dir, 2, &mut best_sln, &mut best_proj);
84        best_sln.or(best_proj)
85    }
86
87    fn scan_for_project_files(
88        dir: &Path,
89        depth: u8,
90        best_sln: &mut Option<std::path::PathBuf>,
91        best_proj: &mut Option<std::path::PathBuf>,
92    ) {
93        if best_sln.is_some() {
94            return;
95        }
96        let entries = match std::fs::read_dir(dir) {
97            Ok(e) => e,
98            Err(_) => return,
99        };
100        for entry in entries.flatten() {
101            let name = entry.file_name();
102            let name_str = name.to_string_lossy();
103            let path = entry.path();
104            if path.is_file() {
105                if name_str.ends_with(".sln") {
106                    *best_sln = Some(path);
107                    return; // .sln always wins
108                }
109                if name_str.ends_with(".csproj") || name_str.ends_with(".fsproj") {
110                    // Prefer test projects over library projects
111                    let is_test_proj = name_str.contains("Test") || name_str.contains("test");
112                    if is_test_proj || best_proj.is_none() {
113                        *best_proj = Some(path);
114                    }
115                }
116            } else if depth > 0 && path.is_dir() && !name_str.starts_with('.') {
117                let skip = matches!(
118                    name_str.as_ref(),
119                    "node_modules" | "vendor" | "target" | "bin" | "obj" | "packages"
120                );
121                if !skip {
122                    Self::scan_for_project_files(&path, depth - 1, best_sln, best_proj);
123                }
124            }
125        }
126    }
127}
128
129impl TestAdapter for DotnetAdapter {
130    fn name(&self) -> &str {
131        "C#/.NET"
132    }
133
134    fn check_runner(&self) -> Option<String> {
135        if which::which("dotnet").is_err() {
136            return Some("dotnet not found. Install .NET SDK.".into());
137        }
138        None
139    }
140
141    fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
142        if !Self::has_dotnet_project(project_dir) {
143            return None;
144        }
145
146        let lang = Self::detect_project_type(project_dir);
147
148        // Check for .sln at root or in subdirectories
149        let has_sln = std::fs::read_dir(project_dir)
150            .ok()
151            .map(|entries| {
152                entries
153                    .flatten()
154                    .any(|e| e.file_name().to_string_lossy().ends_with(".sln"))
155            })
156            .unwrap_or(false)
157            || has_marker_in_subdirs(project_dir, 2, |name| name.ends_with(".sln"));
158
159        // Check for build artifacts at root or in subdirs
160        let has_build_artifacts = project_dir.join("obj").is_dir()
161            || project_dir.join("bin").is_dir()
162            || has_marker_in_subdirs(project_dir, 2, |name| name == "obj" || name == "bin");
163
164        let confidence = ConfidenceScore::base(0.50)
165            .signal(0.15, has_sln)
166            .signal(0.10, has_build_artifacts)
167            .signal(0.15, which::which("dotnet").is_ok())
168            .finish();
169
170        Some(DetectionResult {
171            language: lang.into(),
172            framework: "dotnet test".into(),
173            confidence,
174        })
175    }
176
177    fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
178        let mut cmd = Command::new("dotnet");
179        cmd.arg("test");
180
181        // If project files are in subdirectories, pass the path explicitly
182        if let Some(project_file) = Self::find_project_file(project_dir) {
183            cmd.arg(&project_file);
184        }
185
186        cmd.arg("--verbosity");
187        cmd.arg("normal");
188
189        for arg in extra_args {
190            cmd.arg(arg);
191        }
192
193        cmd.current_dir(project_dir);
194        Ok(cmd)
195    }
196
197    fn filter_args(&self, pattern: &str) -> Vec<String> {
198        vec!["--filter".to_string(), pattern.to_string()]
199    }
200
201    fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
202        let combined = combined_output(stdout, stderr);
203
204        let mut suites = parse_dotnet_output(&combined, exit_code);
205
206        // Enrich failed tests with error details from stack traces
207        let failures = parse_dotnet_failures(&combined);
208        if !failures.is_empty() {
209            enrich_with_errors(&mut suites, &failures);
210        }
211
212        let duration = parse_dotnet_duration(&combined).unwrap_or(Duration::from_secs(0));
213
214        TestRunResult {
215            suites,
216            duration,
217            raw_exit_code: exit_code,
218        }
219    }
220}
221
222/// Parse dotnet test output.
223///
224/// Format:
225/// ```text
226/// Starting test execution, please wait...
227/// A total of 1 test files matched the specified pattern.
228///
229///   Passed test_add [< 1 ms]
230///   Passed test_subtract [2 ms]
231///   Failed test_divide [< 1 ms]
232///     Error Message:
233///       Assert.Equal() Failure
234///
235/// Test Run Successful.
236/// Total tests: 3
237///      Passed: 2
238///      Failed: 1
239///     Skipped: 0
240/// ```
241///
242/// Check if a line is a real dotnet test result (not MSBuild noise).
243///
244/// Real test results look like:
245///   `Passed Some.Test.Name [18 ms]`
246///   `Failed Some.Test.Name [< 1 ms]`
247///   `Skipped Some.Test.Name`
248///
249/// MSBuild noise looks like:
250///   `Failed to load prune package data from PrunePackageData folder, ...`
251///
252/// We require either a `[duration]` suffix or that the rest after the
253/// status prefix contains a dotted name (Namespace.Class.Method).
254fn is_dotnet_test_result_line(trimmed: &str) -> bool {
255    let rest = if let Some(r) = trimmed.strip_prefix("Passed ") {
256        r
257    } else if let Some(r) = trimmed.strip_prefix("Failed ") {
258        r
259    } else if let Some(r) = trimmed.strip_prefix("Skipped ") {
260        r
261    } else {
262        return false;
263    };
264
265    // Has a [duration] suffix — definitely a test result
266    if rest.ends_with(']') && rest.contains('[') {
267        return true;
268    }
269
270    // Has a dotted name like Namespace.Class.Method — test result
271    let name_part = rest.split_whitespace().next().unwrap_or("");
272    if name_part.contains('.') && !name_part.starts_with('.') {
273        return true;
274    }
275
276    false
277}
278
279fn parse_dotnet_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
280    let mut tests = Vec::new();
281    let mut found_summary = false;
282
283    // Try detailed output first ("  Passed test_name [duration]")
284    for line in output.lines() {
285        let trimmed = line.trim();
286
287        if !is_dotnet_test_result_line(trimmed) {
288            // Skip non-test lines (MSBuild noise, etc.)
289        } else if trimmed.starts_with("Passed ")
290            || trimmed.starts_with("Failed ")
291            || trimmed.starts_with("Skipped ")
292        {
293            let (status, rest) = if let Some(rest) = trimmed.strip_prefix("Passed ") {
294                (TestStatus::Passed, rest)
295            } else if let Some(rest) = trimmed.strip_prefix("Failed ") {
296                (TestStatus::Failed, rest)
297            } else if let Some(rest) = trimmed.strip_prefix("Skipped ") {
298                (TestStatus::Skipped, rest)
299            } else {
300                continue;
301            };
302
303            // Name might have " [duration]" suffix
304            let name = rest
305                .rfind('[')
306                .map(|i| rest[..i].trim())
307                .unwrap_or(rest)
308                .to_string();
309
310            // Parse duration from "[2 ms]" or "[< 1 ms]"
311            let duration = if let Some(bracket_start) = rest.rfind('[') {
312                let dur_str = &rest[bracket_start + 1..rest.len().saturating_sub(1)];
313                parse_dotnet_test_duration(dur_str)
314            } else {
315                Duration::from_millis(0)
316            };
317
318            tests.push(TestCase {
319                name,
320                status,
321                duration,
322                error: None,
323            });
324        }
325    }
326
327    // Fallback: parse summary section
328    if tests.is_empty() {
329        let mut total = 0usize;
330        let mut passed = 0usize;
331        let mut failed = 0usize;
332        let mut skipped = 0usize;
333
334        for line in output.lines() {
335            let trimmed = line.trim();
336            if let Some(rest) = trimmed.strip_prefix("Total tests:") {
337                total = rest.trim().parse().unwrap_or(0);
338                found_summary = true;
339            } else if let Some(rest) = trimmed.strip_prefix("Passed:") {
340                passed = rest.trim().parse().unwrap_or(0);
341            } else if let Some(rest) = trimmed.strip_prefix("Failed:") {
342                failed = rest.trim().parse().unwrap_or(0);
343            } else if let Some(rest) = trimmed.strip_prefix("Skipped:") {
344                skipped = rest.trim().parse().unwrap_or(0);
345            }
346        }
347
348        if found_summary && total > 0 {
349            // Use parsed counts; if passed wasn't explicitly listed, calculate it
350            if passed == 0 && failed + skipped < total {
351                passed = total - failed - skipped;
352            }
353            for i in 0..passed {
354                tests.push(TestCase {
355                    name: format!("test_{}", i + 1),
356                    status: TestStatus::Passed,
357                    duration: Duration::from_millis(0),
358                    error: None,
359                });
360            }
361            for i in 0..failed {
362                tests.push(TestCase {
363                    name: format!("failed_test_{}", i + 1),
364                    status: TestStatus::Failed,
365                    duration: Duration::from_millis(0),
366                    error: None,
367                });
368            }
369            for i in 0..skipped {
370                tests.push(TestCase {
371                    name: format!("skipped_test_{}", i + 1),
372                    status: TestStatus::Skipped,
373                    duration: Duration::from_millis(0),
374                    error: None,
375                });
376            }
377        }
378    }
379
380    if tests.is_empty() {
381        tests.push(TestCase {
382            name: "test_suite".into(),
383            status: if exit_code == 0 {
384                TestStatus::Passed
385            } else {
386                TestStatus::Failed
387            },
388            duration: Duration::from_millis(0),
389            error: None,
390        });
391    }
392
393    vec![TestSuite {
394        name: "tests".into(),
395        tests,
396    }]
397}
398
399fn parse_dotnet_test_duration(dur_str: &str) -> Duration {
400    // "2 ms", "< 1 ms", "1.5 s"
401    let clean = dur_str.trim().trim_start_matches("< ");
402    let parts: Vec<&str> = clean.split_whitespace().collect();
403    if parts.len() >= 2 {
404        let value: f64 = parts[0].parse().unwrap_or(0.0);
405        match parts[1] {
406            "ms" => duration_from_secs_safe(value / 1000.0),
407            "s" => duration_from_secs_safe(value),
408            _ => Duration::from_millis(0),
409        }
410    } else {
411        Duration::from_millis(0)
412    }
413}
414
415fn parse_dotnet_duration(output: &str) -> Option<Duration> {
416    // "Total time: 1.234 Seconds" or "Duration: 1.234 s"
417    for line in output.lines() {
418        let trimmed = line.trim();
419        if trimmed.starts_with("Total time:") || trimmed.starts_with("Duration:") {
420            let num_str: String = trimmed
421                .chars()
422                .filter(|c| c.is_ascii_digit() || *c == '.')
423                .collect();
424            if let Ok(secs) = num_str.parse::<f64>() {
425                return Some(duration_from_secs_safe(secs));
426            }
427        }
428    }
429    None
430}
431
432/// A parsed failure from dotnet test output.
433#[derive(Debug, Clone)]
434#[allow(dead_code)]
435struct DotnetFailure {
436    /// Test name
437    test_name: String,
438    /// Error/assertion message
439    message: String,
440    /// Stack trace lines
441    stack_trace: Option<String>,
442    /// File location extracted from stack trace
443    location: Option<String>,
444}
445
446/// Parse dotnet test failure blocks.
447///
448/// Format:
449/// ```text
450///   Failed test_divide [< 1 ms]
451///   Error Message:
452///    Assert.Equal() Failure
453///    Expected: 4
454///    Actual:   3
455///   Stack Trace:
456///    at MyApp.Tests.MathTest.TestDivide() in /path/MathTest.cs:line 42
457/// ```
458///
459/// Or xUnit/NUnit format:
460/// ```text
461///   X test_divide [< 1 ms]
462///     Error Message:
463///       Assert.Equal() Failure
464///     Stack Trace:
465///       at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42
466/// ```
467fn parse_dotnet_failures(output: &str) -> Vec<DotnetFailure> {
468    let mut failures = Vec::new();
469    let lines: Vec<&str> = output.lines().collect();
470    let mut i = 0;
471
472    while i < lines.len() {
473        let trimmed = lines[i].trim();
474
475        // Find "Failed test_name [duration]" lines (skip MSBuild noise)
476        let is_failed_test = if trimmed.starts_with("Failed ") {
477            is_dotnet_test_result_line(trimmed)
478        } else {
479            trimmed.starts_with("X ")
480        };
481        if is_failed_test {
482            let rest = if let Some(r) = trimmed.strip_prefix("Failed ") {
483                r
484            } else if let Some(r) = trimmed.strip_prefix("X ") {
485                r
486            } else {
487                i += 1;
488                continue;
489            };
490
491            // Extract test name (before the duration bracket)
492            let test_name = rest
493                .rfind('[')
494                .map(|idx| rest[..idx].trim())
495                .unwrap_or(rest)
496                .to_string();
497
498            i += 1;
499
500            // Collect error message and stack trace
501            let mut message_lines = Vec::new();
502            let mut stack_lines = Vec::new();
503            let mut in_message = false;
504            let mut in_stack = false;
505
506            while i < lines.len() {
507                let line = lines[i].trim();
508
509                // Detect section headers
510                if line == "Error Message:" || line.starts_with("Error Message:") {
511                    in_message = true;
512                    in_stack = false;
513                    i += 1;
514                    continue;
515                }
516                if line == "Stack Trace:" || line.starts_with("Stack Trace:") {
517                    in_message = false;
518                    in_stack = true;
519                    i += 1;
520                    continue;
521                }
522
523                // Stop at next test result or empty context
524                if is_dotnet_test_result_line(line)
525                    || line.starts_with("X ")
526                    || line.starts_with("Test Run")
527                    || line.starts_with("Total tests:")
528                {
529                    break;
530                }
531
532                if in_message && !line.is_empty() {
533                    message_lines.push(line.to_string());
534                } else if in_stack && !line.is_empty() {
535                    stack_lines.push(line.to_string());
536                }
537
538                i += 1;
539            }
540
541            let message = if message_lines.is_empty() {
542                "Test failed".to_string()
543            } else {
544                truncate(&message_lines.join("\n"), 500)
545            };
546
547            let stack_trace = if stack_lines.is_empty() {
548                None
549            } else {
550                Some(
551                    stack_lines
552                        .iter()
553                        .take(5)
554                        .cloned()
555                        .collect::<Vec<_>>()
556                        .join("\n"),
557                )
558            };
559
560            let location = stack_lines.iter().find_map(|l| extract_dotnet_location(l));
561
562            failures.push(DotnetFailure {
563                test_name,
564                message,
565                stack_trace,
566                location,
567            });
568            continue;
569        }
570
571        i += 1;
572    }
573
574    failures
575}
576
577/// Extract file location from a .NET stack trace line.
578/// "at Namespace.Class.Method() in /path/File.cs:line 42"
579fn extract_dotnet_location(line: &str) -> Option<String> {
580    // Look for " in " followed by a path and ":line N"
581    if let Some(in_idx) = line.find(" in ") {
582        let path_part = &line[in_idx + 4..];
583        let path = path_part.trim();
584        if !path.is_empty() {
585            return Some(path.to_string());
586        }
587    }
588    // Direct file:line pattern
589    if (line.contains(".cs:") || line.contains(".fs:")) && line.contains("line ") {
590        return Some(line.trim().to_string());
591    }
592    None
593}
594
595/// Enrich test cases with failure details.
596fn enrich_with_errors(suites: &mut [TestSuite], failures: &[DotnetFailure]) {
597    for suite in suites.iter_mut() {
598        for test in suite.tests.iter_mut() {
599            if test.status != TestStatus::Failed || test.error.is_some() {
600                continue;
601            }
602            if let Some(failure) = find_matching_dotnet_failure(&test.name, failures) {
603                test.error = Some(TestError {
604                    message: failure.message.clone(),
605                    location: failure.location.clone(),
606                });
607            }
608        }
609    }
610}
611
612/// Find a matching failure for a test name.
613fn find_matching_dotnet_failure<'a>(
614    test_name: &str,
615    failures: &'a [DotnetFailure],
616) -> Option<&'a DotnetFailure> {
617    for failure in failures {
618        if failure.test_name == test_name {
619            return Some(failure);
620        }
621        // Partial match: test name might be namespace-qualified
622        if failure.test_name.ends_with(test_name) || test_name.ends_with(&failure.test_name) {
623            return Some(failure);
624        }
625    }
626    if failures.len() == 1 {
627        return Some(&failures[0]);
628    }
629    None
630}
631
632/// Parse dotnet test TRX report files.
633///
634/// TRX files are XML test result files generated by `dotnet test --logger trx`.
635/// Located at: TestResults/*.trx
636pub fn parse_trx_report(project_dir: &Path) -> Vec<TestSuite> {
637    let results_dir = project_dir.join("TestResults");
638    if !results_dir.is_dir() {
639        return Vec::new();
640    }
641
642    let mut suites = Vec::new();
643
644    if let Ok(entries) = std::fs::read_dir(&results_dir) {
645        for entry in entries.flatten() {
646            let name = entry.file_name();
647            let name = name.to_string_lossy();
648            if name.ends_with(".trx")
649                && let Ok(content) = std::fs::read_to_string(entry.path())
650            {
651                let mut parsed = parse_trx_content(&content);
652                suites.append(&mut parsed);
653            }
654        }
655    }
656
657    suites
658}
659
660/// Parse TRX XML content.
661///
662/// TRX format (simplified):
663/// ```xml
664/// <TestRun>
665///   <Results>
666///     <UnitTestResult testName="TestAdd" outcome="Passed" duration="00:00:00.001">
667///     </UnitTestResult>
668///     <UnitTestResult testName="TestDiv" outcome="Failed" duration="00:00:00.002">
669///       <Output>
670///         <ErrorInfo>
671///           <Message>Assert.Equal failure</Message>
672///           <StackTrace>at Test.TestDiv() in Test.cs:line 42</StackTrace>
673///         </ErrorInfo>
674///       </Output>
675///     </UnitTestResult>
676///   </Results>
677/// </TestRun>
678/// ```
679fn parse_trx_content(content: &str) -> Vec<TestSuite> {
680    let mut tests = Vec::new();
681
682    // Find all <UnitTestResult> elements
683    let mut search_from = 0;
684
685    while let Some(start) = content[search_from..].find("<UnitTestResult") {
686        let abs_start = search_from + start;
687
688        // Find end of this element
689        let end = if let Some(close) = content[abs_start..].find("</UnitTestResult>") {
690            abs_start + close + 17
691        } else if let Some(self_close) = content[abs_start..].find("/>") {
692            abs_start + self_close + 2
693        } else {
694            break;
695        };
696
697        let element = &content[abs_start..end];
698
699        let test_name = extract_trx_attr(element, "testName").unwrap_or_else(|| "unknown".into());
700        let outcome = extract_trx_attr(element, "outcome").unwrap_or_default();
701        let duration_str = extract_trx_attr(element, "duration").unwrap_or_default();
702
703        let status = match outcome.as_str() {
704            "Passed" => TestStatus::Passed,
705            "Failed" => TestStatus::Failed,
706            "NotExecuted" | "Inconclusive" => TestStatus::Skipped,
707            _ => TestStatus::Failed,
708        };
709
710        let duration = parse_trx_duration(&duration_str);
711
712        let error = if status == TestStatus::Failed {
713            let message =
714                extract_trx_error_message(element).unwrap_or_else(|| "Test failed".into());
715            let location =
716                extract_trx_stack_trace(element).and_then(|st| extract_dotnet_location(&st));
717            Some(TestError { message, location })
718        } else {
719            None
720        };
721
722        tests.push(TestCase {
723            name: test_name,
724            status,
725            duration,
726            error,
727        });
728
729        search_from = end;
730    }
731
732    if tests.is_empty() {
733        return Vec::new();
734    }
735
736    vec![TestSuite {
737        name: "tests".into(),
738        tests,
739    }]
740}
741
742/// Extract an attribute from a TRX element.
743fn extract_trx_attr(element: &str, attr: &str) -> Option<String> {
744    let pattern = format!("{}=\"", attr);
745    let start = element.find(&pattern)?;
746    let value_start = start + pattern.len();
747    let value_end = element[value_start..].find('"')?;
748    Some(element[value_start..value_start + value_end].to_string())
749}
750
751/// Parse TRX duration format: "00:00:00.001"
752fn parse_trx_duration(s: &str) -> Duration {
753    let parts: Vec<&str> = s.split(':').collect();
754    if parts.len() == 3 {
755        let hours: f64 = parts[0].parse().unwrap_or(0.0);
756        let mins: f64 = parts[1].parse().unwrap_or(0.0);
757        let secs: f64 = parts[2].parse().unwrap_or(0.0);
758        duration_from_secs_safe(hours * 3600.0 + mins * 60.0 + secs)
759    } else {
760        Duration::from_millis(0)
761    }
762}
763
764/// Extract error message from TRX <ErrorInfo><Message> element.
765fn extract_trx_error_message(element: &str) -> Option<String> {
766    let msg_start = element.find("<Message>")?;
767    let msg_end = element[msg_start..].find("</Message>")?;
768    let message = &element[msg_start + 9..msg_start + msg_end];
769    Some(message.trim().to_string())
770}
771
772/// Extract stack trace from TRX <ErrorInfo><StackTrace> element.
773fn extract_trx_stack_trace(element: &str) -> Option<String> {
774    let st_start = element.find("<StackTrace>")?;
775    let st_end = element[st_start..].find("</StackTrace>")?;
776    let trace = &element[st_start + 12..st_start + st_end];
777    Some(trace.trim().to_string())
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn detect_csproj() {
786        let dir = tempfile::tempdir().unwrap();
787        std::fs::write(dir.path().join("MyApp.csproj"), "<Project/>").unwrap();
788        let adapter = DotnetAdapter::new();
789        let det = adapter.detect(dir.path()).unwrap();
790        assert_eq!(det.language, "C#");
791        assert_eq!(det.framework, "dotnet test");
792    }
793
794    #[test]
795    fn detect_fsproj() {
796        let dir = tempfile::tempdir().unwrap();
797        std::fs::write(dir.path().join("MyApp.fsproj"), "<Project/>").unwrap();
798        let adapter = DotnetAdapter::new();
799        let det = adapter.detect(dir.path()).unwrap();
800        assert_eq!(det.language, "F#");
801    }
802
803    #[test]
804    fn detect_sln() {
805        let dir = tempfile::tempdir().unwrap();
806        std::fs::write(dir.path().join("MyApp.sln"), "").unwrap();
807        let adapter = DotnetAdapter::new();
808        assert!(adapter.detect(dir.path()).is_some());
809    }
810
811    #[test]
812    fn detect_no_dotnet() {
813        let dir = tempfile::tempdir().unwrap();
814        let adapter = DotnetAdapter::new();
815        assert!(adapter.detect(dir.path()).is_none());
816    }
817
818    #[test]
819    fn parse_dotnet_detailed_output() {
820        let stdout = r#"
821Starting test execution, please wait...
822A total of 1 test files matched the specified pattern.
823
824  Passed test_add [2 ms]
825  Passed test_subtract [< 1 ms]
826  Failed test_divide [3 ms]
827
828Test Run Failed.
829Total tests: 3
830     Passed: 2
831     Failed: 1
832    Skipped: 0
833"#;
834        let adapter = DotnetAdapter::new();
835        let result = adapter.parse_output(stdout, "", 1);
836
837        assert_eq!(result.total_tests(), 3);
838        assert_eq!(result.total_passed(), 2);
839        assert_eq!(result.total_failed(), 1);
840    }
841
842    #[test]
843    fn parse_dotnet_all_pass() {
844        let stdout = r#"
845  Passed test_add [2 ms]
846  Passed test_subtract [1 ms]
847
848Test Run Successful.
849Total tests: 2
850     Passed: 2
851     Failed: 0
852    Skipped: 0
853"#;
854        let adapter = DotnetAdapter::new();
855        let result = adapter.parse_output(stdout, "", 0);
856
857        assert_eq!(result.total_tests(), 2);
858        assert_eq!(result.total_passed(), 2);
859        assert!(result.is_success());
860    }
861
862    #[test]
863    fn parse_dotnet_summary_only() {
864        let stdout = r#"
865Test Run Successful.
866Total tests: 5
867     Passed: 4
868     Failed: 0
869    Skipped: 1
870"#;
871        let adapter = DotnetAdapter::new();
872        let result = adapter.parse_output(stdout, "", 0);
873
874        assert_eq!(result.total_tests(), 5);
875        assert_eq!(result.total_passed(), 4);
876        assert_eq!(result.total_skipped(), 1);
877    }
878
879    #[test]
880    fn parse_dotnet_empty_output() {
881        let adapter = DotnetAdapter::new();
882        let result = adapter.parse_output("", "", 0);
883
884        assert_eq!(result.total_tests(), 1);
885        assert!(result.is_success());
886    }
887
888    #[test]
889    fn parse_test_duration_ms() {
890        assert_eq!(parse_dotnet_test_duration("2 ms"), Duration::from_millis(2));
891    }
892
893    #[test]
894    fn parse_test_duration_lt_ms() {
895        assert_eq!(
896            parse_dotnet_test_duration("< 1 ms"),
897            Duration::from_millis(1)
898        );
899    }
900
901    #[test]
902    fn parse_dotnet_failure_blocks() {
903        let output = r#"
904  Passed test_add [2 ms]
905  Failed test_divide [< 1 ms]
906  Error Message:
907   Assert.Equal() Failure
908   Expected: 4
909   Actual:   3
910  Stack Trace:
911   at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42
912
913Test Run Failed.
914Total tests: 2
915     Passed: 1
916     Failed: 1
917"#;
918        let failures = parse_dotnet_failures(output);
919        assert_eq!(failures.len(), 1);
920        assert_eq!(failures[0].test_name, "test_divide");
921        assert!(failures[0].message.contains("Assert.Equal"));
922        assert!(failures[0].location.is_some());
923        assert!(
924            failures[0]
925                .location
926                .as_ref()
927                .unwrap()
928                .contains("MathTest.cs:line 42")
929        );
930    }
931
932    #[test]
933    fn parse_dotnet_multiple_failures() {
934        let output = r#"
935  Failed test_a [1 ms]
936  Error Message:
937   Expected True but got False
938  Stack Trace:
939   at Tests.A() in /tests/Test.cs:line 10
940
941  Failed test_b [2 ms]
942  Error Message:
943   Null reference
944  Stack Trace:
945   at Tests.B() in /tests/Test.cs:line 20
946
947Test Run Failed.
948"#;
949        let failures = parse_dotnet_failures(output);
950        assert_eq!(failures.len(), 2);
951        assert_eq!(failures[0].test_name, "test_a");
952        assert_eq!(failures[1].test_name, "test_b");
953    }
954
955    #[test]
956    fn parse_dotnet_failure_no_stack() {
957        let output = r#"
958  Failed test_x [1 ms]
959  Error Message:
960   Something went wrong
961
962  Passed test_y [1 ms]
963"#;
964        let failures = parse_dotnet_failures(output);
965        assert_eq!(failures.len(), 1);
966        assert!(failures[0].stack_trace.is_none());
967    }
968
969    #[test]
970    fn extract_dotnet_location_test() {
971        assert_eq!(
972            extract_dotnet_location(
973                "at MyApp.Tests.MathTest.TestDivide() in /tests/MathTest.cs:line 42"
974            ),
975            Some("/tests/MathTest.cs:line 42".into())
976        );
977        assert!(extract_dotnet_location("no location here").is_none());
978    }
979
980    #[test]
981    fn enrich_with_errors_test() {
982        let mut suites = vec![TestSuite {
983            name: "tests".into(),
984            tests: vec![
985                TestCase {
986                    name: "test_add".into(),
987                    status: TestStatus::Passed,
988                    duration: Duration::from_millis(0),
989                    error: None,
990                },
991                TestCase {
992                    name: "test_divide".into(),
993                    status: TestStatus::Failed,
994                    duration: Duration::from_millis(0),
995                    error: None,
996                },
997            ],
998        }];
999        let failures = vec![DotnetFailure {
1000            test_name: "test_divide".into(),
1001            message: "Assert.Equal failure".into(),
1002            stack_trace: Some("at Test.TestDivide() in /tests/Test.cs:line 42".into()),
1003            location: Some("/tests/Test.cs:line 42".into()),
1004        }];
1005        enrich_with_errors(&mut suites, &failures);
1006        assert!(suites[0].tests[0].error.is_none());
1007        let err = suites[0].tests[1].error.as_ref().unwrap();
1008        assert_eq!(err.message, "Assert.Equal failure");
1009        assert!(err.location.as_ref().unwrap().contains("Test.cs:line 42"));
1010    }
1011
1012    #[test]
1013    fn truncate_test() {
1014        assert_eq!(truncate("short", 100), "short");
1015        let long = "m".repeat(600);
1016        let truncated = truncate(&long, 500);
1017        assert!(truncated.ends_with("..."));
1018    }
1019
1020    #[test]
1021    fn parse_trx_basic() {
1022        let content = r#"<?xml version="1.0" encoding="UTF-8"?>
1023<TestRun>
1024  <Results>
1025    <UnitTestResult testName="TestAdd" outcome="Passed" duration="00:00:00.001">
1026    </UnitTestResult>
1027    <UnitTestResult testName="TestDiv" outcome="Failed" duration="00:00:00.002">
1028      <Output>
1029        <ErrorInfo>
1030          <Message>Assert.Equal failure</Message>
1031          <StackTrace>at Test.TestDiv() in /tests/Test.cs:line 42</StackTrace>
1032        </ErrorInfo>
1033      </Output>
1034    </UnitTestResult>
1035  </Results>
1036</TestRun>"#;
1037        let suites = parse_trx_content(content);
1038        assert_eq!(suites.len(), 1);
1039        assert_eq!(suites[0].tests.len(), 2);
1040        assert_eq!(suites[0].tests[0].name, "TestAdd");
1041        assert_eq!(suites[0].tests[0].status, TestStatus::Passed);
1042        assert_eq!(suites[0].tests[1].name, "TestDiv");
1043        assert_eq!(suites[0].tests[1].status, TestStatus::Failed);
1044        assert!(suites[0].tests[1].error.is_some());
1045    }
1046
1047    #[test]
1048    fn parse_trx_skipped() {
1049        let content = r#"<TestRun><Results>
1050<UnitTestResult testName="TestSkip" outcome="NotExecuted" duration="00:00:00.000"/>
1051</Results></TestRun>"#;
1052        let suites = parse_trx_content(content);
1053        assert_eq!(suites[0].tests[0].status, TestStatus::Skipped);
1054    }
1055
1056    #[test]
1057    fn parse_trx_duration_test() {
1058        assert_eq!(
1059            parse_trx_duration("00:00:01.500"),
1060            Duration::from_millis(1500)
1061        );
1062        assert_eq!(parse_trx_duration("00:01:00.000"), Duration::from_secs(60));
1063    }
1064
1065    #[test]
1066    fn extract_trx_attr_test() {
1067        assert_eq!(
1068            extract_trx_attr(
1069                r#"<UnitTestResult testName="TestAdd" outcome="Passed">"#,
1070                "testName"
1071            ),
1072            Some("TestAdd".into())
1073        );
1074        assert_eq!(
1075            extract_trx_attr(
1076                r#"<UnitTestResult testName="TestAdd" outcome="Passed">"#,
1077                "outcome"
1078            ),
1079            Some("Passed".into())
1080        );
1081    }
1082
1083    #[test]
1084    fn extract_trx_error_message_test() {
1085        let element =
1086            "<Output><ErrorInfo><Message>Assert.Equal failure</Message></ErrorInfo></Output>";
1087        assert_eq!(
1088            extract_trx_error_message(element),
1089            Some("Assert.Equal failure".into())
1090        );
1091    }
1092
1093    #[test]
1094    fn extract_trx_stack_trace_test() {
1095        let element = "<Output><ErrorInfo><StackTrace>at Test.Run() in Test.cs:line 10</StackTrace></ErrorInfo></Output>";
1096        assert_eq!(
1097            extract_trx_stack_trace(element),
1098            Some("at Test.Run() in Test.cs:line 10".into())
1099        );
1100    }
1101
1102    #[test]
1103    fn parse_dotnet_failure_integration() {
1104        let stdout = r#"
1105Starting test execution, please wait...
1106
1107  Passed test_add [2 ms]
1108  Failed test_divide [< 1 ms]
1109  Error Message:
1110   Assert.Equal() Failure
1111   Expected: 4
1112   Actual:   3
1113  Stack Trace:
1114   at Tests.Divide() in /tests/MathTest.cs:line 42
1115
1116Test Run Failed.
1117Total tests: 2
1118     Passed: 1
1119     Failed: 1
1120"#;
1121        let adapter = DotnetAdapter::new();
1122        let result = adapter.parse_output(stdout, "", 1);
1123
1124        assert_eq!(result.total_tests(), 2);
1125        assert_eq!(result.total_passed(), 1);
1126        assert_eq!(result.total_failed(), 1);
1127        let failed = result.suites[0]
1128            .tests
1129            .iter()
1130            .find(|t| t.status == TestStatus::Failed)
1131            .unwrap();
1132        assert!(failed.error.is_some());
1133        assert!(
1134            failed
1135                .error
1136                .as_ref()
1137                .unwrap()
1138                .message
1139                .contains("Assert.Equal")
1140        );
1141    }
1142}