1use std::collections::{BTreeMap, BTreeSet, HashMap};
8
9use serde::{Deserialize, Serialize};
10
11use crate::interned::Id;
12use crate::{
13 agent_json::pagination,
14 coverage_analysis::{CoverageSummary, is_independence_pair},
15 coverage_index::{
16 CoverageDimension, CoverageIndex, CoverageIndexError, CoverageViewId, IndexedCoverageModel,
17 IndexedDecisionGap, IndexedDimensionCoverage, IndexedFileGap, IndexedGapDimensions,
18 IndexedHitMetadata, IndexedMeasurement, IndexedOutcomeCounts, IndexedScopeEntry,
19 IndexedSourceScope, IndexedSummaryConfidence, IndexedTestSummary,
20 },
21 coverage_report::{
22 CoverageConfidence, CoverageView, DecisionMeta, ReportError, SourceLine, TestAttempt,
23 TestProvenance, TransportStats, coverage_summary_for_tests,
24 },
25};
26use supercov_contracts::AgentPagination;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum MinimizeMetric {
31 All,
32 Lines,
33 Statements,
34 Functions,
35 Branches,
36 Mcdc,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct MinimumTestSetResult {
42 pub optimal: bool,
43 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
44 pub target: f64,
45 pub metric: MinimizeMetric,
46 pub selected: Vec<String>,
47 pub expanded: Vec<String>,
48 pub summary: CoverageSummary,
49 pub explored_states: usize,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53pub struct CoverageMinimizedTest {
54 pub id: String,
55 pub name: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub file: Option<String>,
58 pub runner: String,
59 pub kind: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CoverageMinimizeData {
65 pub run: String,
66 pub filters: CoverageQueryFilters,
67 pub optimal: bool,
68 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
69 pub target: f64,
70 pub metric: MinimizeMetric,
71 pub selected: Vec<String>,
72 pub expanded: Vec<String>,
73 pub summary: CoverageSummary,
74 pub explored_states: usize,
75 pub selected_count: usize,
76 pub total_candidate_tests: usize,
77 pub tests: Vec<CoverageMinimizedTest>,
78}
79
80#[derive(Debug, Clone, Copy)]
81pub struct CoverageMinimizeQueryOptions<'a> {
82 pub run: &'a str,
83 pub view_id: CoverageViewId,
84 pub kind: Option<&'a str>,
85 pub runner: Option<&'a str>,
86 pub target: f64,
87 pub metric: MinimizeMetric,
88 pub max_states: usize,
89 pub offset: usize,
90 pub limit: usize,
91}
92
93pub fn coverage_minimize_query(
94 view: &CoverageView,
95 options: CoverageMinimizeQueryOptions<'_>,
96) -> Result<(CoverageMinimizeData, AgentPagination), QueryError> {
97 if options.limit == 0 {
98 return Err(QueryError::InvalidPagination);
99 }
100 let selected_ids = if options.kind.is_none() && options.runner.is_none() {
101 None
102 } else {
103 let ids = view
104 .tests
105 .iter()
106 .filter(|test| {
107 options.kind.is_none_or(|kind| test.provenance.kind == kind)
108 && options
109 .runner
110 .is_none_or(|runner| test.provenance.runner == runner)
111 })
112 .map(|test| test.id.clone())
113 .collect::<BTreeSet<_>>();
114 if ids.is_empty() {
115 return Err(QueryError::TestFilterEmpty {
116 kind: options.kind.map(str::to_owned),
117 runner: options.runner.map(str::to_owned),
118 });
119 }
120 Some(ids)
121 };
122 let mut solver_view = view.clone();
123 if let Some(selected) = &selected_ids {
124 solver_view.tests.retain(|test| selected.contains(&test.id));
125 }
126 let minimized = minimum_test_set(
127 &solver_view,
128 options.target,
129 options.metric,
130 options.max_states,
131 )?;
132 let selected_details = minimized
133 .selected
134 .iter()
135 .map(|id| {
136 let test = view
137 .tests
138 .iter()
139 .find(|test| test.id == *id)
140 .ok_or(QueryError::InvalidRecordSelection)?;
141 Ok(CoverageMinimizedTest {
142 id: id.clone(),
143 name: test.name.clone(),
144 file: test.file.clone(),
145 runner: test.provenance.runner.clone(),
146 kind: test.provenance.kind.clone(),
147 })
148 })
149 .collect::<Result<Vec<_>, QueryError>>()?;
150 let total = selected_details.len();
151 let tests = selected_details
152 .iter()
153 .skip(options.offset)
154 .take(options.limit)
155 .cloned()
156 .collect::<Vec<_>>();
157 let returned = tests.len();
158 let total_candidate_tests = solver_view
159 .tests
160 .iter()
161 .filter(|test| test.role == "test")
162 .count();
163 Ok((
164 CoverageMinimizeData {
165 run: options.run.into(),
166 filters: query_filters(options.view_id, options.kind, options.runner),
167 optimal: minimized.optimal,
168 target: minimized.target,
169 metric: minimized.metric,
170 selected: minimized.selected,
171 expanded: minimized.expanded,
172 summary: minimized.summary,
173 explored_states: minimized.explored_states,
174 selected_count: total,
175 total_candidate_tests,
176 tests,
177 },
178 pagination(options.offset, options.limit, returned, total),
179 ))
180}
181
182#[derive(Debug)]
183pub enum QueryError {
184 InvalidTarget(f64),
185 UnattributedEvidence,
186 TargetUnreachable {
187 metric: MinimizeMetric,
188 target: f64,
189 reachable: f64,
190 },
191 ComplexityLimit {
192 candidate_tests: usize,
193 obligations: usize,
194 explored_states: usize,
195 max_states: usize,
196 target: f64,
197 metric: MinimizeMetric,
198 },
199 Analysis(ReportError),
200 Index(CoverageIndexError),
201 InvalidPagination,
202 TestFilterEmpty {
203 kind: Option<String>,
204 runner: Option<String>,
205 },
206 TestNotFound(String),
207 DecisionNotFound(String),
208 SourceNotFound(String),
209 AmbiguousSelector {
210 selector: String,
211 matches: Vec<String>,
212 },
213 InvalidRecordSelection,
214 ScopeUnavailable,
215}
216
217impl From<ReportError> for QueryError {
218 fn from(value: ReportError) -> Self {
219 Self::Analysis(value)
220 }
221}
222
223impl From<CoverageIndexError> for QueryError {
224 fn from(value: CoverageIndexError) -> Self {
225 Self::Index(value)
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
230#[serde(rename_all = "camelCase")]
231pub struct CoverageQueryFilters {
232 pub outcome: String,
233 pub kind: Option<String>,
234 pub runner: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize)]
238#[serde(rename_all = "camelCase")]
239pub struct CoverageFilesData {
240 pub run: String,
241 pub filters: CoverageQueryFilters,
242 pub metric: MinimizeMetric,
243 pub files: Vec<IndexedFileGap>,
244}
245
246#[derive(Debug, Clone, PartialEq, Serialize)]
247#[serde(rename_all = "camelCase")]
248pub struct CoverageGapsData {
249 pub run: String,
250 pub filters: CoverageQueryFilters,
251 pub metric: MinimizeMetric,
252 pub gaps: Vec<IndexedFileGap>,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize)]
256#[serde(rename_all = "camelCase")]
257pub struct CoverageKindsData {
258 pub run: String,
259 pub filters: CoverageQueryFilters,
260 pub kinds: Vec<IndexedDimensionCoverage>,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize)]
264#[serde(rename_all = "camelCase")]
265pub struct CoverageRunnersData {
266 pub run: String,
267 pub filters: CoverageQueryFilters,
268 pub runners: Vec<IndexedDimensionCoverage>,
269}
270
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
273#[serde(rename_all = "camelCase")]
274pub struct TestAttributionCounts {
275 pub exact: usize,
277 pub by_time: usize,
280 pub partial: usize,
282 pub run_wide: usize,
284}
285
286impl TestAttributionCounts {
287 pub fn total(&self) -> usize {
288 self.exact + self.by_time + self.partial + self.run_wide
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293#[serde(rename_all = "camelCase")]
294pub struct CoverageDiagnostic {
295 pub code: String,
296 pub severity: String,
297 pub message: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize)]
301#[serde(rename_all = "camelCase")]
302pub struct CoverageSummaryData {
303 pub test_kind_sources: BTreeMap<String, usize>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub assertion_coverage: Option<serde_json::Value>,
306 pub run: String,
307 #[serde(default)]
308 pub command: Vec<String>,
309 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub merged_across: Vec<String>,
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub companions: Vec<crate::run_store::CompanionRun>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub hints: Vec<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub workspace: Option<String>,
321 pub filters: CoverageQueryFilters,
322 pub model: IndexedCoverageModel,
323 pub generated_at: String,
324 pub valid: bool,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub test_exit_code: Option<i32>,
327 pub stale: bool,
328 pub stale_reasons: Vec<String>,
329 pub structurally_complete: bool,
330 pub complete: bool,
331 pub coverage: CoverageSummary,
332 pub measurement: IndexedMeasurement,
333 pub coverage_by_kind: Vec<IndexedDimensionCoverage>,
334 #[serde(skip_serializing_if = "Option::is_none")]
338 pub coverage_by_tests: Option<CoverageSummary>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 pub e2e_gap_context: Option<CoverageKindGapContext>,
341 pub coverage_by_runner: Vec<IndexedDimensionCoverage>,
342 pub attribution: crate::coverage_index::IndexedAttribution,
343 pub test_attribution: TestAttributionCounts,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub transport: Option<TransportStats>,
351 pub diagnostics: Vec<CoverageDiagnostic>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub confidence: Option<IndexedSummaryConfidence>,
354 pub files_with_gaps: usize,
355 pub files_with_coverage_gaps: usize,
356 pub files_with_measurement_limitations: usize,
357 pub tests: usize,
358 pub setups: usize,
359 pub test_outcomes: IndexedOutcomeCounts,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub source_scope: Option<IndexedSourceScope>,
362 #[serde(skip_serializing_if = "Option::is_none")]
365 pub unclassified_source: Option<UnclassifiedSource>,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
371#[serde(rename_all = "camelCase")]
372pub struct ScopeGroup {
373 pub status: String,
374 pub directory: String,
375 pub reason: String,
376 pub files: usize,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
380#[serde(rename_all = "camelCase")]
381pub struct ScopeDirectory {
382 pub directory: String,
383 pub files: usize,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390#[serde(rename_all = "camelCase")]
391pub struct UnclassifiedSource {
392 pub directories: Vec<ScopeDirectory>,
393 pub roots: Vec<String>,
394}
395
396fn scope_directory(entry: &IndexedScopeEntry) -> String {
400 let package = entry.package_root.as_deref().filter(|root| *root != ".");
401 let local = package
402 .and_then(|root| entry.file.strip_prefix(root))
403 .and_then(|rest| rest.strip_prefix('/'))
404 .unwrap_or(&entry.file);
405 match (package, local.split_once('/')) {
406 (Some(package), Some((first, _))) => format!("{package}/{first}"),
407 (Some(package), None) => package.to_owned(),
408 (None, Some((first, _))) => first.to_owned(),
409 (None, None) => ".".to_owned(),
410 }
411}
412
413fn scope_status_rank(status: &str) -> u8 {
414 match status {
415 "ambiguous" => 0,
416 "included" => 1,
417 "excluded" => 2,
418 _ => 3,
419 }
420}
421
422pub fn scope_groups(entries: &[IndexedScopeEntry]) -> Vec<ScopeGroup> {
425 let mut groups = BTreeMap::<(u8, String, String, String), usize>::new();
426 for entry in entries {
427 *groups
428 .entry((
429 scope_status_rank(&entry.status),
430 entry.status.clone(),
431 scope_directory(entry),
432 entry.reason.clone(),
433 ))
434 .or_default() += 1;
435 }
436 let mut groups = groups
437 .into_iter()
438 .map(|((rank, status, directory, reason), files)| {
439 (
440 rank,
441 ScopeGroup {
442 status,
443 directory,
444 reason,
445 files,
446 },
447 )
448 })
449 .collect::<Vec<_>>();
450 groups.sort_by(|(left_rank, left), (right_rank, right)| {
451 left_rank
452 .cmp(right_rank)
453 .then_with(|| right.files.cmp(&left.files))
454 .then_with(|| left.directory.cmp(&right.directory))
455 .then_with(|| left.reason.cmp(&right.reason))
456 });
457 groups.into_iter().map(|(_, group)| group).collect()
458}
459
460pub fn unclassified_source(
461 roots: &[String],
462 entries: &[IndexedScopeEntry],
463) -> Option<UnclassifiedSource> {
464 let mut places = BTreeMap::<String, usize>::new();
465 let mut named = Vec::<(String, usize)>::new();
466 for entry in entries.iter().filter(|entry| entry.status == "ambiguous") {
467 let directory = scope_directory(entry);
468 *places.entry(directory.clone()).or_default() += 1;
469 let root = if entry.file.contains('/') && directory != "." {
472 directory
473 } else {
474 entry.file.clone()
475 };
476 match named.iter_mut().find(|(name, _)| *name == root) {
477 Some((_, files)) => *files += 1,
478 None => named.push((root, 1)),
479 }
480 }
481 if places.is_empty() {
482 return None;
483 }
484 let mut directories = places
485 .into_iter()
486 .map(|(directory, files)| ScopeDirectory { directory, files })
487 .collect::<Vec<_>>();
488 directories.sort_by(|left, right| {
489 right
490 .files
491 .cmp(&left.files)
492 .then_with(|| left.directory.cmp(&right.directory))
493 });
494 named.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
495 let mut all = roots.to_vec();
496 for (root, _) in named {
497 if !all.contains(&root) {
498 all.push(root);
499 }
500 }
501 Some(UnclassifiedSource {
502 directories,
503 roots: all,
504 })
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508#[serde(rename_all = "camelCase")]
509pub struct CoverageKindGapContext {
510 pub kind: String,
511 pub other_kinds: Vec<String>,
512 pub covered_elsewhere: IndexedGapDimensions,
513 pub uncovered_everywhere: IndexedGapDimensions,
514}
515
516#[derive(Debug, Clone)]
517pub struct CoverageSummaryQueryOptions<'a> {
518 pub run: &'a str,
519 pub view: CoverageViewId,
520 pub kind: Option<&'a str>,
521 pub runner: Option<&'a str>,
522 pub valid: bool,
523 pub test_exit_code: Option<i32>,
524 pub stale: bool,
525 pub stale_reasons: Vec<String>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
529pub struct ScopeCounts {
530 pub included: usize,
531 pub excluded: usize,
532 pub ambiguous: usize,
533}
534
535#[derive(Debug, Clone, PartialEq, Serialize)]
536#[serde(rename_all = "camelCase")]
537pub struct CoverageScopeData {
538 pub run: String,
539 pub filters: CoverageQueryFilters,
540 pub kind: String,
541 pub language: String,
542 pub model: String,
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub mode: Option<String>,
545 pub roots: Vec<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub unit: Option<String>,
548 #[serde(skip_serializing_if = "Option::is_none")]
549 pub measurement_complete: Option<bool>,
550 pub counts: ScopeCounts,
551 pub measurement: IndexedMeasurement,
552 pub groups: Vec<ScopeGroup>,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub unclassified_source: Option<UnclassifiedSource>,
556 pub entries: Vec<IndexedScopeEntry>,
557}
558
559#[derive(Debug, Clone, Copy)]
560pub struct CoverageScopeQueryOptions<'a> {
561 pub run: &'a str,
562 pub view: CoverageViewId,
563 pub kind: Option<&'a str>,
564 pub runner: Option<&'a str>,
565 pub offset: usize,
566 pub limit: usize,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
570pub struct CoverageLocation {
571 pub file: String,
572 pub line: usize,
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
576pub struct CoverageCoveringTest {
577 pub id: String,
578 pub name: String,
579 pub provenance: TestProvenance,
580}
581
582#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
583#[serde(rename_all = "camelCase")]
584pub struct CoverageCoveringPhase {
585 pub id: String,
586 pub kind: String,
587 pub operation: String,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub source: Option<String>,
590 pub test: String,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 pub status: Option<String>,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 pub caused_by_phase_id: Option<String>,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "camelCase")]
599pub struct CoverageAnchor {
600 pub kind: String,
601 pub id: String,
602 pub column: usize,
603 pub covered: bool,
604 #[serde(skip_serializing_if = "Option::is_none")]
605 pub source: Option<String>,
606 #[serde(skip_serializing_if = "Option::is_none")]
607 pub missing: Option<String>,
608 pub covering_tests: usize,
609 #[serde(skip_serializing_if = "Option::is_none")]
610 pub covered_conditions: Option<usize>,
611 #[serde(skip_serializing_if = "Option::is_none")]
612 pub conditions: Option<usize>,
613}
614
615#[derive(Debug, Clone, PartialEq, Serialize)]
616#[serde(rename_all = "camelCase")]
617pub struct CoverageCoversLineData {
618 pub run: String,
619 pub filters: CoverageQueryFilters,
620 pub location: CoverageLocation,
621 #[serde(skip_serializing_if = "Option::is_none")]
622 pub source: Option<String>,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub source_origin: Option<String>,
625 pub covered: bool,
626 pub confidence: CoverageConfidence,
627 pub total_tests: usize,
628 pub total_phases: usize,
629 pub total_anchored: usize,
630 pub covered_anchored: usize,
631 pub total_limitations: usize,
632 pub total_remaining: usize,
633 pub tests: Vec<CoverageCoveringTest>,
634 pub phases: Vec<CoverageCoveringPhase>,
635 pub anchored: Vec<CoverageAnchor>,
636 pub limitations: Vec<CoverageFileLimitation>,
637 pub remaining: Vec<CoverageFileObligation>,
638 #[serde(skip_serializing_if = "Option::is_none")]
641 pub assertions: Option<serde_json::Value>,
642}
643
644#[derive(Debug, Clone, PartialEq, Serialize)]
645#[serde(rename_all = "camelCase")]
646pub struct CoverageCoversAnchorsData {
647 pub run: String,
648 pub filters: CoverageQueryFilters,
649 pub location: CoverageLocation,
650 #[serde(skip_serializing_if = "Option::is_none")]
651 pub source: Option<String>,
652 #[serde(skip_serializing_if = "Option::is_none")]
653 pub source_origin: Option<String>,
654 pub line_obligation: bool,
655 pub anchored: Vec<CoverageAnchor>,
656 pub total_anchored: usize,
657 pub covered_anchored: usize,
658 pub total_limitations: usize,
659 pub limitations: Vec<CoverageFileLimitation>,
660 pub total_remaining: usize,
661 pub remaining: Vec<CoverageFileObligation>,
662 pub total_tests: usize,
663 pub tests: Vec<CoverageCoveringTest>,
664 #[serde(skip_serializing_if = "Option::is_none")]
667 pub assertions: Option<serde_json::Value>,
668}
669
670#[derive(Debug, Clone, PartialEq, Serialize)]
671#[serde(untagged)]
672pub enum CoverageCoversData {
673 Line(CoverageCoversLineData),
674 Anchors(CoverageCoversAnchorsData),
675}
676
677#[derive(Debug, Clone, Copy)]
678pub struct CoverageCoversQueryOptions<'a> {
679 pub run: &'a str,
680 pub view: CoverageViewId,
681 pub kind: Option<&'a str>,
682 pub runner: Option<&'a str>,
683 pub file: &'a str,
684 pub line: usize,
685 pub offset: usize,
686 pub limit: usize,
687}
688
689fn query_filters(
690 view: CoverageViewId,
691 kind: Option<&str>,
692 runner: Option<&str>,
693) -> CoverageQueryFilters {
694 CoverageQueryFilters {
695 outcome: match view {
696 CoverageViewId::All => "all",
697 CoverageViewId::Passed => "passed",
698 CoverageViewId::Failed => "failed",
699 }
700 .into(),
701 kind: kind.map(str::to_owned),
702 runner: runner.map(str::to_owned),
703 }
704}
705
706fn selected_test_ids(
707 tests: &[IndexedTestSummary],
708 kind: Option<&str>,
709 runner: Option<&str>,
710) -> Result<Option<BTreeSet<String>>, QueryError> {
711 if kind.is_none() && runner.is_none() {
712 return Ok(None);
713 }
714 let selected = tests
715 .iter()
716 .filter(|test| {
717 kind.is_none_or(|kind| test.provenance.kind == kind)
718 && runner.is_none_or(|runner| test.provenance.runner == runner)
719 })
720 .map(|test| test.id.clone())
721 .collect::<BTreeSet<_>>();
722 if selected.is_empty() {
723 return Err(QueryError::TestFilterEmpty {
724 kind: kind.map(str::to_owned),
725 runner: runner.map(str::to_owned),
726 });
727 }
728 Ok(Some(selected))
729}
730
731#[derive(Default)]
736pub struct CoversCache {
737 tests: Vec<IndexedTestSummary>,
738 phases: HashMap<String, crate::coverage_index::IndexedPhaseSummary>,
739 limitations: Vec<crate::coverage_index::IndexedLimitation>,
740 read: bool,
741 files: HashMap<String, CoversFile>,
742}
743
744struct CoversFile {
745 metadata: HashMap<String, IndexedHitMetadata>,
746 decisions: HashMap<String, crate::coverage_report::DecisionResult>,
747 gap_lines: Vec<CoverageFileGapLine>,
748}
749
750impl CoversCache {
751 fn fill(
752 &mut self,
753 index: &CoverageIndex<'_>,
754 options: &CoverageCoversQueryOptions<'_>,
755 ) -> Result<(), QueryError> {
756 if !self.read {
757 self.tests = index.test_summaries(options.view)?;
758 self.phases = index
759 .phase_summaries(options.view)?
760 .into_iter()
761 .map(|phase| (phase.id.clone(), phase))
762 .collect();
763 self.limitations = index.limitations(options.view)?;
764 self.read = true;
765 }
766 if self.files.contains_key(options.file) {
767 return Ok(());
768 }
769 let metadata = index
771 .hit_metadata_in_file(options.view, options.file)?
772 .into_iter()
773 .map(|value| (value.id.clone(), value))
774 .collect();
775 let decisions = index
776 .decision_details_in_file(options.view, options.file)?
777 .into_iter()
778 .map(|value| (value.meta.id.clone(), value))
779 .collect();
780 let (detail, _) = coverage_file_detail_query(
781 index,
782 CoverageFileDetailOptions {
783 run: options.run,
784 view: options.view,
785 kind: options.kind,
786 runner: options.runner,
787 selector: options.file,
788 metric: MinimizeMetric::All,
789 offset: 0,
790 limit: usize::MAX,
791 },
792 )?;
793 self.files.insert(
794 options.file.into(),
795 CoversFile {
796 metadata,
797 decisions,
798 gap_lines: detail.gap_lines,
799 },
800 );
801 Ok(())
802 }
803}
804
805pub fn coverage_covers_query(
806 index: &CoverageIndex<'_>,
807 options: CoverageCoversQueryOptions<'_>,
808) -> Result<(CoverageCoversData, AgentPagination), QueryError> {
809 coverage_covers_query_cached(index, options, &mut CoversCache::default())
810}
811
812pub fn coverage_covers_query_cached(
815 index: &CoverageIndex<'_>,
816 options: CoverageCoversQueryOptions<'_>,
817 cache: &mut CoversCache,
818) -> Result<(CoverageCoversData, AgentPagination), QueryError> {
819 if options.limit == 0 {
820 return Err(QueryError::InvalidPagination);
821 }
822 cache.fill(index, &options)?;
823 let cache = &*cache;
824 let tests = &cache.tests;
825 let selected = selected_test_ids(tests, options.kind, options.runner)?;
826 let selected_includes = |id: &str| selected.as_ref().is_none_or(|ids| ids.contains(id));
827 let filters = query_filters(options.view, options.kind, options.runner);
828 let location = CoverageLocation {
829 file: options.file.into(),
830 line: options.line,
831 };
832 let file = &cache.files[options.file];
833 let (metadata, decisions) = (&file.metadata, &file.decisions);
834 let anchors = index.anchors(options.view, options.file, options.line)?;
835 let tests_by_id = tests
836 .iter()
837 .map(|test| (test.id.as_str(), test))
838 .collect::<HashMap<_, _>>();
839 let mut anchor_test_ids = anchors
840 .iter()
841 .flat_map(|anchor| anchor.tests.iter())
842 .filter(|id| selected_includes(id))
843 .cloned()
844 .collect::<BTreeSet<_>>();
845 for anchor in anchors.iter().filter(|anchor| anchor.kind == "branch") {
846 anchor_test_ids.extend(
847 metadata
848 .values()
849 .filter(|detail| detail.parent_id.as_deref() == Some(anchor.id.as_str()))
850 .flat_map(|detail| detail.tests.iter())
851 .filter(|id| selected_includes(id))
852 .cloned(),
853 );
854 }
855 let all_anchor_tests = anchor_test_ids
856 .iter()
857 .map(|id| {
858 let test = tests_by_id.get(id.as_str());
859 CoverageCoveringTest {
860 id: id.clone(),
861 name: test.map_or_else(|| id.clone(), |test| test.name.clone()),
862 provenance: test
863 .map_or_else(TestProvenance::default, |test| test.provenance.clone()),
864 }
865 })
866 .collect::<Vec<_>>();
867 let total_anchor_tests = all_anchor_tests.len();
868 let anchor_tests_page = all_anchor_tests
869 .iter()
870 .skip(options.offset)
871 .take(options.limit)
872 .cloned()
873 .collect::<Vec<_>>();
874 let render_anchor = |anchor: crate::coverage_index::IndexedAnchor| {
875 let branch_alternatives = if anchor.kind == "branch" {
876 metadata
877 .values()
878 .filter(|detail| {
879 detail.obligation == "branch"
880 && detail.parent_id.as_deref() == Some(anchor.id.as_str())
881 })
882 .collect::<Vec<_>>()
883 } else {
884 Vec::new()
885 };
886 let branch_tests = branch_alternatives
887 .iter()
888 .flat_map(|detail| detail.tests.iter())
889 .filter(|test| selected_includes(test))
890 .cloned()
891 .collect::<BTreeSet<_>>();
892 let covering_tests = if anchor.kind == "branch" {
893 branch_tests.len()
894 } else {
895 anchor
896 .tests
897 .iter()
898 .filter(|test| selected_includes(test))
899 .count()
900 };
901 let detail = metadata.get(&anchor.id);
902 let decision = decisions
903 .get(&anchor.id)
904 .cloned()
905 .map(|decision| selected_decision(decision, selected.as_ref()));
906 let conditions = decision.as_ref().map_or(anchor.conditions, |decision| {
907 Some(decision.conditions.len())
908 });
909 let covered_conditions = decision
910 .as_ref()
911 .map_or(anchor.covered_conditions, |decision| {
912 Some(
913 decision
914 .conditions
915 .iter()
916 .filter(|condition| condition.covered)
917 .count(),
918 )
919 });
920 let covered = match anchor.kind.as_str() {
921 "decision" => conditions == covered_conditions,
922 "branch" if !branch_alternatives.is_empty() => branch_alternatives
923 .iter()
924 .all(|detail| detail.tests.iter().any(|test| selected_includes(test))),
925 "branch" => anchor.covered,
926 _ => covering_tests > 0,
927 };
928 let branch_source = branch_alternatives
929 .first()
930 .map(|detail| detail.source.clone());
931 let missing_branch_alternatives = branch_alternatives
932 .iter()
933 .filter(|detail| !detail.tests.iter().any(|test| selected_includes(test)))
934 .filter_map(|detail| detail.alternative.clone())
935 .collect::<Vec<_>>();
936 CoverageAnchor {
937 kind: anchor.kind,
938 id: anchor.id,
939 column: anchor.column,
940 covered,
941 source: decision
942 .as_ref()
943 .map(|decision| decision.meta.source.clone())
944 .or(branch_source)
945 .or_else(|| {
946 detail.map(|detail| {
947 detail
948 .label
949 .clone()
950 .unwrap_or_else(|| detail.source.clone())
951 })
952 })
953 .and_then(|source| compact_source(&source)),
954 missing: if missing_branch_alternatives.is_empty() {
955 detail.and_then(|detail| detail.alternative.clone())
956 } else {
957 Some(missing_branch_alternatives.join("; "))
958 },
959 covering_tests,
960 covered_conditions,
961 conditions,
962 }
963 };
964 let rendered_anchors = anchors.into_iter().map(render_anchor).collect::<Vec<_>>();
965 let total_anchored = rendered_anchors.len();
966 let covered_anchored = rendered_anchors
967 .iter()
968 .filter(|anchor| anchor.covered)
969 .count();
970 let all_limitations = cache
971 .limitations
972 .iter()
973 .filter(|limitation| limitation.file == options.file && limitation.line == options.line)
974 .cloned()
975 .map(|limitation| CoverageFileLimitation {
976 id: limitation.id,
977 kind: limitation.kind,
978 file: limitation.file,
979 line: limitation.line,
980 column: limitation.column,
981 source: limitation.source,
982 reason: limitation.reason,
983 blocking: limitation.blocking,
984 effect: "outside-measured-denominator".into(),
985 })
986 .collect::<Vec<_>>();
987 let total_limitations = all_limitations.len();
988 let limitations_page = all_limitations
989 .iter()
990 .skip(options.offset)
991 .take(options.limit)
992 .cloned()
993 .collect::<Vec<_>>();
994 let gap_line = file
995 .gap_lines
996 .iter()
997 .find(|gap| gap.line == options.line)
998 .cloned();
999 let source = gap_line
1005 .as_ref()
1006 .and_then(|gap| gap.source.clone())
1007 .or_else(|| {
1008 rendered_anchors
1009 .iter()
1010 .filter_map(|anchor| anchor.source.clone())
1011 .min_by_key(|source| source.len())
1012 });
1013 let all_remaining = gap_line.map_or_else(Vec::new, |gap| gap.obligations);
1014 let total_remaining = all_remaining.len();
1015 let remaining_page = all_remaining
1016 .iter()
1017 .skip(options.offset)
1018 .take(options.limit)
1019 .cloned()
1020 .collect::<Vec<_>>();
1021 let Some(line) = index.line(options.view, options.file, options.line)? else {
1022 let anchored = rendered_anchors
1023 .iter()
1024 .skip(options.offset)
1025 .take(options.limit)
1026 .cloned()
1027 .collect::<Vec<_>>();
1028 let total = total_anchored.max(total_limitations).max(total_remaining);
1029 let total = total.max(total_anchor_tests);
1030 let returned = anchored
1031 .len()
1032 .max(limitations_page.len())
1033 .max(remaining_page.len())
1034 .max(anchor_tests_page.len());
1035 return Ok((
1036 CoverageCoversData::Anchors(CoverageCoversAnchorsData {
1037 run: options.run.into(),
1038 filters,
1039 location,
1040 source,
1041 source_origin: None,
1042 line_obligation: false,
1043 anchored,
1044 total_anchored,
1045 covered_anchored,
1046 total_limitations,
1047 limitations: limitations_page,
1048 total_remaining,
1049 remaining: remaining_page,
1050 total_tests: total_anchor_tests,
1051 tests: anchor_tests_page,
1052 assertions: None,
1053 }),
1054 pagination(options.offset, options.limit, returned, total),
1055 ));
1056 };
1057 let all_tests = line
1058 .tests
1059 .iter()
1060 .filter(|id| selected_includes(id))
1061 .map(|id| {
1062 let test = tests_by_id.get(id.as_str());
1063 CoverageCoveringTest {
1064 id: id.clone(),
1065 name: test.map_or_else(|| id.clone(), |test| test.name.clone()),
1066 provenance: test
1067 .map_or_else(TestProvenance::default, |test| test.provenance.clone()),
1068 }
1069 })
1070 .collect::<Vec<_>>();
1071 let phases_by_id = &cache.phases;
1072 let all_phases = line
1073 .phases
1074 .iter()
1075 .filter_map(|id| phases_by_id.get(id))
1076 .filter(|phase| selected_includes(&phase.test))
1077 .map(|phase| CoverageCoveringPhase {
1078 id: phase.id.clone(),
1079 kind: phase.kind.clone(),
1080 operation: phase.operation.clone(),
1081 source: phase.source.clone(),
1082 test: phase.test.clone(),
1083 status: phase.status.clone(),
1084 caused_by_phase_id: phase.caused_by_phase_id.clone(),
1085 })
1086 .collect::<Vec<_>>();
1087 let tests_page = all_tests
1088 .iter()
1089 .skip(options.offset)
1090 .take(options.limit)
1091 .cloned()
1092 .collect::<Vec<_>>();
1093 let phases_page = all_phases
1094 .iter()
1095 .skip(options.offset)
1096 .take(options.limit)
1097 .cloned()
1098 .collect::<Vec<_>>();
1099 let anchored_page = rendered_anchors
1100 .into_iter()
1101 .skip(options.offset)
1102 .take(options.limit)
1103 .collect::<Vec<_>>();
1104 let total = all_tests
1105 .len()
1106 .max(all_phases.len())
1107 .max(total_anchored)
1108 .max(total_limitations)
1109 .max(total_remaining);
1110 let returned = tests_page
1111 .len()
1112 .max(phases_page.len())
1113 .max(anchored_page.len())
1114 .max(limitations_page.len())
1115 .max(remaining_page.len());
1116 Ok((
1117 CoverageCoversData::Line(CoverageCoversLineData {
1118 run: options.run.into(),
1119 filters,
1120 location,
1121 source,
1122 source_origin: None,
1123 covered: line.tests.iter().any(|test| selected_includes(test)),
1124 confidence: line.confidence,
1125 total_tests: all_tests.len(),
1126 total_phases: all_phases.len(),
1127 total_anchored,
1128 covered_anchored,
1129 total_limitations,
1130 total_remaining,
1131 tests: tests_page,
1132 phases: phases_page,
1133 anchored: anchored_page,
1134 limitations: limitations_page,
1135 remaining: remaining_page,
1136 assertions: None,
1137 }),
1138 pagination(options.offset, options.limit, returned, total),
1139 ))
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1143pub struct CoverageTestMatch {
1144 pub id: String,
1145 pub name: String,
1146 pub outcome: String,
1147 pub provenance: TestProvenance,
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1151#[serde(rename_all = "camelCase")]
1152pub struct CoverageHitDetail {
1153 pub id: String,
1154 pub obligation: String,
1155 #[serde(skip_serializing_if = "Option::is_none")]
1156 pub branch_kind: Option<String>,
1157 #[serde(skip_serializing_if = "Option::is_none")]
1158 pub file: Option<String>,
1159 #[serde(skip_serializing_if = "Option::is_none")]
1160 pub line: Option<usize>,
1161 #[serde(skip_serializing_if = "Option::is_none")]
1162 pub column: Option<usize>,
1163 #[serde(skip_serializing_if = "Option::is_none")]
1164 pub label: Option<String>,
1165 #[serde(skip_serializing_if = "Option::is_none")]
1166 pub alternative: Option<String>,
1167}
1168
1169#[derive(Debug, Clone, PartialEq, Serialize)]
1170pub struct CoverageTestDecision {
1171 pub id: String,
1172 pub vectors: Vec<crate::coverage_analysis::McdcVector>,
1173 pub meta: DecisionMeta,
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1177#[serde(rename_all = "camelCase")]
1178pub struct CoverageTestPhase {
1179 pub id: String,
1180 pub kind: String,
1181 pub operation: String,
1182 #[serde(skip_serializing_if = "Option::is_none")]
1183 pub source: Option<String>,
1184 #[serde(skip_serializing_if = "Option::is_none")]
1185 pub status: Option<String>,
1186 #[serde(skip_serializing_if = "Option::is_none")]
1187 pub caused_by_phase_id: Option<String>,
1188 pub lines: usize,
1189 pub decisions: usize,
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1193pub struct CoverageTestTotals {
1194 pub lines: usize,
1195 pub hits: usize,
1196 pub decisions: usize,
1197 pub phases: usize,
1198}
1199
1200#[derive(Debug, Clone, PartialEq, Serialize)]
1201#[serde(rename_all = "camelCase")]
1202pub struct CoverageSelectedTest {
1203 pub id: String,
1204 pub name: String,
1205 #[serde(skip_serializing_if = "Option::is_none")]
1206 pub file: Option<String>,
1207 #[serde(skip_serializing_if = "Option::is_none")]
1208 pub title: Option<String>,
1209 pub retries: Vec<usize>,
1210 pub attempts: Vec<TestAttempt>,
1211 pub outcome: String,
1212 pub provenance: TestProvenance,
1213 pub role: String,
1214 pub attribution: String,
1218 pub hits: Vec<String>,
1219 pub decisions: Vec<CoverageTestDecision>,
1220 pub lines: Vec<SourceLine>,
1221 pub hit_details: Vec<CoverageHitDetail>,
1222 pub phases: Vec<CoverageTestPhase>,
1223 pub totals: CoverageTestTotals,
1224}
1225
1226#[derive(Debug, Clone, PartialEq, Serialize)]
1227pub struct CoverageTestMatchesData {
1228 pub run: String,
1229 pub filters: CoverageQueryFilters,
1230 pub tests: Vec<CoverageTestMatch>,
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Serialize)]
1234#[serde(rename_all = "camelCase")]
1235pub struct CoverageTestDetailData {
1236 pub run: String,
1237 pub filters: CoverageQueryFilters,
1238 pub pagination_applies_to: String,
1239 pub tests: Vec<CoverageSelectedTest>,
1240}
1241
1242#[derive(Debug, Clone, PartialEq, Serialize)]
1243#[serde(untagged)]
1244pub enum CoverageTestData {
1245 Matches(CoverageTestMatchesData),
1246 Detail(CoverageTestDetailData),
1247}
1248
1249#[derive(Debug, Clone, Copy)]
1250pub struct CoverageTestQueryOptions<'a> {
1251 pub run: &'a str,
1252 pub view: CoverageViewId,
1253 pub kind: Option<&'a str>,
1254 pub runner: Option<&'a str>,
1255 pub selector: &'a str,
1256 pub offset: usize,
1257 pub limit: usize,
1258}
1259
1260fn hit_detail(id: &str, metadata: Option<&IndexedHitMetadata>) -> CoverageHitDetail {
1261 match metadata {
1262 Some(metadata) => CoverageHitDetail {
1263 id: id.into(),
1264 obligation: metadata.obligation.clone(),
1265 branch_kind: metadata.branch_kind.clone(),
1266 file: Some(metadata.file.clone()),
1267 line: Some(metadata.line),
1268 column: Some(metadata.column),
1269 label: metadata.label.clone(),
1270 alternative: metadata.alternative.clone(),
1271 },
1272 None => CoverageHitDetail {
1273 id: id.into(),
1274 obligation: "unknown".into(),
1275 branch_kind: None,
1276 file: None,
1277 line: None,
1278 column: None,
1279 label: None,
1280 alternative: None,
1281 },
1282 }
1283}
1284
1285pub fn coverage_test_query(
1286 index: &CoverageIndex<'_>,
1287 options: CoverageTestQueryOptions<'_>,
1288) -> Result<(CoverageTestData, AgentPagination), QueryError> {
1289 if options.limit == 0 {
1290 return Err(QueryError::InvalidPagination);
1291 }
1292 let tests = index.test_details(options.view)?;
1293 let summaries = tests
1294 .iter()
1295 .map(|test| test.summary.clone())
1296 .collect::<Vec<_>>();
1297 let selected = selected_test_ids(&summaries, options.kind, options.runner)?;
1298 let selector = options.selector.to_lowercase();
1299 let matches = tests
1300 .into_iter()
1301 .filter(|test| {
1302 selected
1303 .as_ref()
1304 .is_none_or(|ids| ids.contains(&test.summary.id))
1305 })
1306 .filter(|test| {
1307 test.summary.id == selector || test.summary.name.to_lowercase().contains(&selector)
1308 })
1309 .collect::<Vec<_>>();
1310 if matches.is_empty() {
1311 return Err(QueryError::TestNotFound(options.selector.into()));
1312 }
1313 let filters = query_filters(options.view, options.kind, options.runner);
1314 if matches.len() > 1 {
1315 let total = matches.len();
1316 let page = matches
1317 .into_iter()
1318 .skip(options.offset)
1319 .take(options.limit)
1320 .map(|test| CoverageTestMatch {
1321 id: test.summary.id,
1322 name: test.summary.name,
1323 outcome: test.summary.outcome,
1324 provenance: test.summary.provenance,
1325 })
1326 .collect::<Vec<_>>();
1327 let returned = page.len();
1328 return Ok((
1329 CoverageTestData::Matches(CoverageTestMatchesData {
1330 run: options.run.into(),
1331 filters,
1332 tests: page,
1333 }),
1334 pagination(options.offset, options.limit, returned, total),
1335 ));
1336 }
1337 let test = matches.into_iter().next().expect("one test match");
1338 let metadata = index
1339 .hit_metadata(options.view)?
1340 .into_iter()
1341 .map(|metadata| (metadata.id.clone(), metadata))
1342 .collect::<HashMap<_, _>>();
1343 let decisions = index
1344 .decision_metadata(options.view)?
1345 .into_iter()
1346 .map(|decision| (decision.id.clone(), decision))
1347 .collect::<HashMap<_, _>>();
1348 let all_phases = index
1349 .phase_summaries(options.view)?
1350 .into_iter()
1351 .filter(|phase| phase.test == test.summary.id)
1352 .map(|phase| CoverageTestPhase {
1353 id: phase.id,
1354 kind: phase.kind,
1355 operation: phase.operation,
1356 source: phase.source,
1357 status: phase.status,
1358 caused_by_phase_id: phase.caused_by_phase_id,
1359 lines: phase.lines,
1360 decisions: phase.decisions,
1361 })
1362 .collect::<Vec<_>>();
1363 let totals = CoverageTestTotals {
1364 lines: test.lines.len(),
1365 hits: test.hits.len(),
1366 decisions: test.decisions.len(),
1367 phases: all_phases.len(),
1368 };
1369 let total = totals
1370 .lines
1371 .max(totals.hits)
1372 .max(totals.decisions)
1373 .max(totals.phases);
1374 let lines = test
1375 .lines
1376 .iter()
1377 .skip(options.offset)
1378 .take(options.limit)
1379 .cloned()
1380 .collect::<Vec<_>>();
1381 let hits = test
1382 .hits
1383 .iter()
1384 .skip(options.offset)
1385 .take(options.limit)
1386 .cloned()
1387 .collect::<Vec<_>>();
1388 let hit_details = test
1389 .hits
1390 .iter()
1391 .skip(options.offset)
1392 .take(options.limit)
1393 .map(|id| hit_detail(id, metadata.get(id)))
1394 .collect::<Vec<_>>();
1395 let test_decisions = test
1396 .decisions
1397 .iter()
1398 .skip(options.offset)
1399 .take(options.limit)
1400 .map(|decision| {
1401 Ok(CoverageTestDecision {
1402 id: decision.id.clone(),
1403 vectors: decision.vectors.clone(),
1404 meta: decisions
1405 .get(&decision.id)
1406 .cloned()
1407 .ok_or(QueryError::InvalidRecordSelection)?,
1408 })
1409 })
1410 .collect::<Result<Vec<_>, QueryError>>()?;
1411 let phases = all_phases
1412 .into_iter()
1413 .skip(options.offset)
1414 .take(options.limit)
1415 .collect::<Vec<_>>();
1416 let returned = lines
1417 .len()
1418 .max(hits.len())
1419 .max(test_decisions.len())
1420 .max(phases.len());
1421 Ok((
1422 CoverageTestData::Detail(CoverageTestDetailData {
1423 run: options.run.into(),
1424 filters,
1425 pagination_applies_to:
1426 "lines, hits/hitDetails, decisions, and phases independently within the test".into(),
1427 tests: vec![CoverageSelectedTest {
1428 id: test.summary.id,
1429 name: test.summary.name,
1430 file: test.summary.file,
1431 title: test.summary.title,
1432 retries: test.retries,
1433 attempts: test.attempts,
1434 outcome: test.summary.outcome,
1435 provenance: test.summary.provenance,
1436 role: test.summary.role,
1437 attribution: test.summary.attribution,
1438 hits,
1439 decisions: test_decisions,
1440 lines,
1441 hit_details,
1442 phases,
1443 totals,
1444 }],
1445 }),
1446 pagination(options.offset, options.limit, returned, total),
1447 ))
1448}
1449
1450#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1451pub struct CoverageDecisionMatch {
1452 pub id: String,
1453 pub file: String,
1454 pub line: usize,
1455 pub column: usize,
1456 pub source: String,
1457}
1458
1459#[derive(Debug, Clone, PartialEq, Serialize)]
1460#[serde(rename_all = "camelCase")]
1461pub struct CoverageDecisionCondition {
1462 pub index: usize,
1463 pub source: String,
1464 pub covered: bool,
1465 #[serde(skip_serializing_if = "Option::is_none")]
1466 pub assertion_covered: Option<bool>,
1467 #[serde(skip_serializing_if = "Option::is_none")]
1468 pub witness: Option<[crate::coverage_analysis::McdcVector; 2]>,
1469 #[serde(skip_serializing_if = "Option::is_none")]
1470 pub witness_tests: Option<[Vec<Id>; 2]>,
1471 #[serde(skip_serializing_if = "Option::is_none")]
1473 pub missing_outcomes: Option<Vec<String>>,
1474}
1475
1476#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1477#[serde(rename_all = "camelCase")]
1478pub struct CoverageDecisionTotals {
1479 pub conditions: usize,
1480 pub vector_observations: usize,
1481 pub tests: usize,
1482}
1483
1484#[derive(Debug, Clone, PartialEq, Serialize)]
1485#[serde(rename_all = "camelCase")]
1486pub struct CoverageSelectedDecision {
1487 pub meta: DecisionMeta,
1488 pub executed: bool,
1489 pub covered: bool,
1490 pub vectors: Vec<crate::coverage_analysis::McdcVector>,
1491 pub vector_observations: Vec<crate::coverage_report::VectorObservation>,
1492 pub conditions: Vec<CoverageDecisionCondition>,
1493 pub tests: Vec<Id>,
1494 pub confidence: CoverageConfidence,
1495 pub totals: CoverageDecisionTotals,
1496}
1497
1498#[derive(Debug, Clone, PartialEq, Serialize)]
1499pub struct CoverageDecisionMatchesData {
1500 pub run: String,
1501 pub filters: CoverageQueryFilters,
1502 pub decisions: Vec<CoverageDecisionMatch>,
1503}
1504
1505#[derive(Debug, Clone, PartialEq, Serialize)]
1506#[serde(rename_all = "camelCase")]
1507pub struct CoverageDecisionDetailData {
1508 pub run: String,
1509 pub filters: CoverageQueryFilters,
1510 pub pagination_applies_to: String,
1511 pub decisions: Vec<CoverageSelectedDecision>,
1512}
1513
1514#[derive(Debug, Clone, PartialEq, Serialize)]
1515#[serde(untagged)]
1516pub enum CoverageDecisionData {
1517 Matches(CoverageDecisionMatchesData),
1518 Detail(CoverageDecisionDetailData),
1519}
1520
1521#[derive(Debug, Clone, Copy)]
1522pub struct CoverageDecisionQueryOptions<'a> {
1523 pub run: &'a str,
1524 pub view: CoverageViewId,
1525 pub kind: Option<&'a str>,
1526 pub runner: Option<&'a str>,
1527 pub selector: &'a str,
1528 pub offset: usize,
1529 pub limit: usize,
1530}
1531
1532fn selector_location(selector: &str) -> Option<(&str, usize, Option<usize>)> {
1535 let (prefix, last) = selector.rsplit_once(':')?;
1536 let last = last.parse::<usize>().ok()?;
1537 if let Some((file, possible_line)) = prefix.rsplit_once(':')
1538 && let Ok(line) = possible_line.parse::<usize>()
1539 {
1540 return Some((file, line, Some(last)));
1541 }
1542 Some((prefix, last, None))
1543}
1544
1545fn selected_decision(
1546 decision: crate::coverage_report::DecisionResult,
1547 selected: Option<&BTreeSet<String>>,
1548) -> crate::coverage_report::DecisionResult {
1549 let Some(selected) = selected else {
1550 return decision;
1551 };
1552 let vector_observations = decision
1553 .vector_observations
1554 .into_iter()
1555 .filter_map(|mut observation| {
1556 observation
1557 .tests
1558 .retain(|test| selected.contains(test.as_str()));
1559 (!observation.tests.is_empty()).then_some(observation)
1560 })
1561 .collect::<Vec<_>>();
1562 let vectors = vector_observations
1563 .iter()
1564 .map(|observation| observation.vector.clone())
1565 .collect::<Vec<_>>();
1566 let conditions = decision
1567 .meta
1568 .conditions
1569 .iter()
1570 .enumerate()
1571 .map(|(index, source)| {
1572 let mut witness = None;
1573 let mut witness_tests = None;
1574 'pairs: for left in 0..vector_observations.len() {
1575 for right in (left + 1)..vector_observations.len() {
1576 let first = &vector_observations[left];
1577 let second = &vector_observations[right];
1578 if is_independence_pair(&first.vector, &second.vector, index) {
1579 witness = Some([first.vector.clone(), second.vector.clone()]);
1580 witness_tests = Some([first.tests.clone(), second.tests.clone()]);
1581 break 'pairs;
1582 }
1583 }
1584 }
1585 crate::coverage_report::ConditionResult {
1586 index,
1587 source: source.clone(),
1588 covered: witness.is_some(),
1589 assertion_covered: false,
1590 witness,
1591 witness_tests,
1592 }
1593 })
1594 .collect::<Vec<_>>();
1595 crate::coverage_report::DecisionResult {
1596 meta: decision.meta,
1597 executed: !vectors.is_empty(),
1598 covered: conditions.iter().all(|condition| condition.covered),
1599 vectors,
1600 vector_observations,
1601 conditions,
1602 tests: decision
1603 .tests
1604 .into_iter()
1605 .filter(|test| selected.contains(test.as_str()))
1606 .collect(),
1607 confidence: decision.confidence,
1608 }
1609}
1610
1611pub fn coverage_decision_query(
1612 index: &CoverageIndex<'_>,
1613 options: CoverageDecisionQueryOptions<'_>,
1614) -> Result<(CoverageDecisionData, AgentPagination), QueryError> {
1615 if options.limit == 0 {
1616 return Err(QueryError::InvalidPagination);
1617 }
1618 let tests = index.test_summaries(options.view)?;
1619 let selected = selected_test_ids(&tests, options.kind, options.runner)?;
1620 let decisions = index.decision_details(options.view)?;
1621 let mut matches = decisions
1622 .into_iter()
1623 .filter(|decision| decision.meta.id == options.selector)
1624 .collect::<Vec<_>>();
1625 if matches.is_empty()
1626 && let Some((file, line, column)) = selector_location(options.selector)
1627 {
1628 matches = index
1629 .decision_details(options.view)?
1630 .into_iter()
1631 .filter(|decision| {
1632 decision.meta.file == file
1633 && decision.meta.line == line
1634 && column.is_none_or(|column| decision.meta.column == column)
1635 })
1636 .collect();
1637 }
1638 if matches.is_empty() {
1639 return Err(QueryError::DecisionNotFound(options.selector.into()));
1640 }
1641 let filters = query_filters(options.view, options.kind, options.runner);
1642 if matches.len() > 1 {
1643 let total = matches.len();
1644 let page = matches
1645 .into_iter()
1646 .skip(options.offset)
1647 .take(options.limit)
1648 .map(|decision| CoverageDecisionMatch {
1649 id: decision.meta.id,
1650 file: decision.meta.file,
1651 line: decision.meta.line,
1652 column: decision.meta.column,
1653 source: decision.meta.source,
1654 })
1655 .collect::<Vec<_>>();
1656 let returned = page.len();
1657 return Ok((
1658 CoverageDecisionData::Matches(CoverageDecisionMatchesData {
1659 run: options.run.into(),
1660 filters,
1661 decisions: page,
1662 }),
1663 pagination(options.offset, options.limit, returned, total),
1664 ));
1665 }
1666 let filtered = selected_decision(
1667 matches.into_iter().next().expect("one decision match"),
1668 selected.as_ref(),
1669 );
1670 let totals = CoverageDecisionTotals {
1671 conditions: filtered.conditions.len(),
1672 vector_observations: filtered.vector_observations.len(),
1673 tests: filtered.tests.len(),
1674 };
1675 let total = totals
1676 .conditions
1677 .max(totals.vector_observations)
1678 .max(totals.tests);
1679 let vector_observations = filtered
1680 .vector_observations
1681 .iter()
1682 .skip(options.offset)
1683 .take(options.limit)
1684 .cloned()
1685 .collect::<Vec<_>>();
1686 let vectors = vector_observations
1687 .iter()
1688 .map(|observation| observation.vector.clone())
1689 .collect::<Vec<_>>();
1690 let observed = filtered
1691 .vector_observations
1692 .iter()
1693 .map(|observation| &observation.vector)
1694 .collect::<Vec<_>>();
1695 let conditions = filtered
1696 .conditions
1697 .iter()
1698 .skip(options.offset)
1699 .take(options.limit)
1700 .map(|condition| CoverageDecisionCondition {
1701 index: condition.index,
1702 source: condition.source.clone(),
1703 covered: condition.covered,
1704 assertion_covered: selected.is_none().then_some(condition.assertion_covered),
1705 witness: condition.witness.clone(),
1706 witness_tests: condition.witness_tests.clone(),
1707 missing_outcomes: (!condition.covered)
1708 .then(|| missing_outcomes(condition.index, &observed)),
1709 })
1710 .collect::<Vec<_>>();
1711 let tests = filtered
1712 .tests
1713 .iter()
1714 .skip(options.offset)
1715 .take(options.limit)
1716 .cloned()
1717 .collect::<Vec<_>>();
1718 let returned = vector_observations
1719 .len()
1720 .max(conditions.len())
1721 .max(tests.len());
1722 Ok((
1723 CoverageDecisionData::Detail(CoverageDecisionDetailData {
1724 run: options.run.into(),
1725 filters,
1726 pagination_applies_to:
1727 "conditions, vectorObservations, and tests independently within each decision"
1728 .into(),
1729 decisions: vec![CoverageSelectedDecision {
1730 meta: filtered.meta,
1731 executed: filtered.executed,
1732 covered: filtered.covered,
1733 vectors,
1734 vector_observations,
1735 conditions,
1736 tests,
1737 confidence: filtered.confidence,
1738 totals,
1739 }],
1740 }),
1741 pagination(options.offset, options.limit, returned, total),
1742 ))
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1746pub struct CoverageOtherTest {
1747 pub id: String,
1748 pub name: String,
1749}
1750
1751#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1752#[serde(rename_all = "camelCase")]
1753pub struct CoverageOtherCoverage {
1754 pub covered_elsewhere: bool,
1755 pub kinds: Vec<String>,
1756 pub runners: Vec<String>,
1757 pub tests: Vec<CoverageOtherTest>,
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1761#[serde(rename_all = "camelCase")]
1762pub struct CoverageLineObligation {
1763 pub kind: String,
1764 pub id: String,
1765 pub line: usize,
1766 pub other_coverage: CoverageOtherCoverage,
1767}
1768
1769#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1770#[serde(rename_all = "camelCase")]
1771pub struct CoveragePointObligation {
1772 pub kind: String,
1773 pub id: String,
1774 pub line: usize,
1775 pub column: usize,
1776 pub source: String,
1777 pub other_coverage: CoverageOtherCoverage,
1778}
1779
1780#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1781#[serde(rename_all = "camelCase")]
1782pub struct CoverageBranchObligation {
1783 pub kind: String,
1784 pub id: String,
1785 pub line: usize,
1786 pub column: usize,
1787 pub source: String,
1788 pub missing: String,
1789 pub other_coverage: CoverageOtherCoverage,
1790}
1791
1792#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1793#[serde(rename_all = "camelCase")]
1794pub struct CoverageMcdcObligation {
1795 pub kind: String,
1796 pub id: String,
1797 pub line: usize,
1798 pub column: usize,
1799 pub decision: String,
1800 pub missing_condition: String,
1801 #[serde(skip)]
1802 pub condition_index: usize,
1803 pub observed_vectors: Vec<String>,
1804 pub missing_outcomes: Vec<String>,
1807 pub needed_vector: Option<String>,
1812 pub needs: String,
1814 pub other_coverage: CoverageOtherCoverage,
1815}
1816
1817#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1818#[serde(untagged)]
1819pub enum CoverageFileObligation {
1820 Line(CoverageLineObligation),
1821 Point(CoveragePointObligation),
1822 Branch(CoverageBranchObligation),
1823 Mcdc(CoverageMcdcObligation),
1824}
1825
1826impl CoverageFileObligation {
1827 fn line(&self) -> usize {
1828 match self {
1829 Self::Line(value) => value.line,
1830 Self::Point(value) => value.line,
1831 Self::Branch(value) => value.line,
1832 Self::Mcdc(value) => value.line,
1833 }
1834 }
1835
1836 fn kind(&self) -> &str {
1837 match self {
1838 Self::Line(value) => &value.kind,
1839 Self::Point(value) => &value.kind,
1840 Self::Branch(value) => &value.kind,
1841 Self::Mcdc(value) => &value.kind,
1842 }
1843 }
1844}
1845
1846#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1847pub struct CoverageFileLimitation {
1848 pub id: String,
1849 pub kind: String,
1850 pub file: String,
1851 pub line: usize,
1852 pub column: usize,
1853 pub source: String,
1854 pub reason: String,
1855 pub blocking: bool,
1856 pub effect: String,
1857}
1858
1859#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1860#[serde(rename_all = "camelCase")]
1861pub struct CoverageFileCounts {
1862 pub total_lines: usize,
1865 pub covered_lines: usize,
1866 pub uncovered_lines: usize,
1867 pub uncovered_statements: usize,
1868 pub uncovered_functions: usize,
1869 pub missing_branches: usize,
1870 pub missing_mcdc_conditions: usize,
1871 pub measurement_limitations: usize,
1872}
1873
1874#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1875#[serde(rename_all = "camelCase")]
1876pub struct CoverageFileGapLine {
1877 pub line: usize,
1878 pub state: String,
1879 #[serde(skip_serializing_if = "Option::is_none")]
1880 pub source: Option<String>,
1881 pub obligations: Vec<CoverageFileObligation>,
1882 pub limitations: Vec<CoverageFileLimitation>,
1883}
1884
1885#[derive(Debug, Clone, PartialEq, Serialize)]
1886#[serde(rename_all = "camelCase")]
1887pub struct CoverageFileDetailData {
1888 pub run: String,
1889 pub filters: CoverageQueryFilters,
1890 pub file: String,
1891 pub metric: MinimizeMetric,
1892 pub counts: CoverageFileCounts,
1893 pub total_tests: usize,
1894 pub total_obligations: usize,
1895 pub total_gap_lines: usize,
1896 pub gap_lines: Vec<CoverageFileGapLine>,
1897 pub total_limitations: usize,
1898 #[serde(skip_serializing_if = "Option::is_none")]
1901 pub assertions: Option<serde_json::Value>,
1902 #[serde(skip_serializing_if = "Option::is_none")]
1905 pub page_cut: Option<PageCut>,
1906}
1907
1908#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1910#[serde(rename_all = "camelCase")]
1911pub struct PageCut {
1912 pub asked: usize,
1913 pub returned: usize,
1914 pub next_offset: Option<usize>,
1915}
1916
1917#[derive(Debug, Clone, Copy)]
1918pub struct CoverageFileDetailOptions<'a> {
1919 pub run: &'a str,
1920 pub view: CoverageViewId,
1921 pub kind: Option<&'a str>,
1922 pub runner: Option<&'a str>,
1923 pub selector: &'a str,
1924 pub metric: MinimizeMetric,
1925 pub offset: usize,
1926 pub limit: usize,
1927}
1928
1929fn other_coverage<T: AsRef<str>>(
1930 test_ids: &[T],
1931 selected: Option<&BTreeSet<String>>,
1932 tests: &HashMap<String, IndexedTestSummary>,
1933) -> CoverageOtherCoverage {
1934 let covered = selected.map_or_else(Vec::new, |selected| {
1935 test_ids
1936 .iter()
1937 .filter(|id| !selected.contains(id.as_ref()))
1938 .filter_map(|id| tests.get(id.as_ref()))
1939 .collect::<Vec<_>>()
1940 });
1941 CoverageOtherCoverage {
1942 covered_elsewhere: !covered.is_empty(),
1943 kinds: covered
1944 .iter()
1945 .map(|test| test.provenance.kind.clone())
1946 .collect::<BTreeSet<_>>()
1947 .into_iter()
1948 .collect(),
1949 runners: covered
1950 .iter()
1951 .map(|test| test.provenance.runner.clone())
1952 .collect::<BTreeSet<_>>()
1953 .into_iter()
1954 .collect(),
1955 tests: covered
1956 .into_iter()
1957 .map(|test| CoverageOtherTest {
1958 id: test.id.clone(),
1959 name: test.name.clone(),
1960 })
1961 .collect(),
1962 }
1963}
1964
1965fn missing_outcomes(
1969 index: usize,
1970 observed: &[&crate::coverage_analysis::McdcVector],
1971) -> Vec<String> {
1972 let seen = |value: bool| {
1973 observed
1974 .iter()
1975 .any(|vector| vector.values.get(index).copied().flatten() == Some(value))
1976 };
1977 [true, false]
1978 .into_iter()
1979 .filter(|value| !seen(*value))
1980 .map(|value| value.to_string())
1981 .collect()
1982}
1983
1984pub fn missing_outcome_words(condition: &str, missing: &[String]) -> String {
1987 match missing {
1988 [] => format!("`{condition}` was seen both ways but never deciding the result alone"),
1989 [value] => format!("`{condition}` was never {value}"),
1990 _ => format!("`{condition}` was never evaluated"),
1991 }
1992}
1993
1994fn needed(
1998 index: usize,
1999 observed: &[&crate::coverage_analysis::McdcVector],
2000 sources: &[&str],
2001 decision: &str,
2002) -> (Option<String>, String) {
2003 use crate::coverage_analysis::{McdcVector, find_witnesses_for_conditions};
2004 let condition = sources.get(index).copied().unwrap_or("the condition");
2005 let describe = |vector: &McdcVector| {
2006 let mut values = String::new();
2007 let mut words = Vec::new();
2008 for (position, value) in vector.values.iter().enumerate() {
2009 values.push(match value {
2010 None => '-',
2011 Some(false) => 'F',
2012 Some(true) => 'T',
2013 });
2014 if let (Some(value), Some(source)) = (value, sources.get(position)) {
2015 words.push(format!("`{source}` {value}"));
2016 }
2017 }
2018 words.push(format!("decision {}", vector.outcome));
2019 (
2020 format!("{values} -> {}", if vector.outcome { 'T' } else { 'F' }),
2021 words.join(", "),
2022 )
2023 };
2024 let Some(possible) = crate::decision_shape::possible_vectors(decision, sources) else {
2025 return if observed
2028 .iter()
2029 .any(|vector| vector.values.get(index).copied().flatten().is_some())
2030 {
2031 (
2032 None,
2033 format!("a run where `{condition}` alone changes the decision"),
2034 )
2035 } else {
2036 (None, format!("no test evaluated `{condition}`"))
2037 };
2038 };
2039 let seen = observed
2040 .iter()
2041 .map(|vector| (*vector).clone())
2042 .collect::<Vec<_>>();
2043 let unseen = possible
2044 .into_iter()
2045 .filter(|vector| !seen.contains(vector))
2046 .collect::<Vec<_>>();
2047 let shows = |added: &[&McdcVector]| {
2051 let mut vectors = seen.clone();
2052 vectors.extend(added.iter().map(|vector| (*vector).clone()));
2053 find_witnesses_for_conditions(&vectors, sources.len())
2054 .ok()
2055 .filter(|witnesses| witnesses.get(index).is_some_and(Option::is_some))
2056 .map(|witnesses| witnesses.iter().filter(|witness| witness.is_some()).count())
2057 };
2058 let best = unseen
2059 .iter()
2060 .filter_map(|vector| shows(&[vector]).map(|shown| (shown, vector)))
2061 .max_by_key(|(shown, vector)| {
2062 (
2063 *shown,
2064 std::cmp::Reverse(vector.values.iter().flatten().count()),
2065 )
2066 });
2067 if let Some((_, vector)) = best {
2068 let (values, words) = describe(vector);
2069 return (Some(values), words);
2070 }
2071 for (position, first) in unseen.iter().enumerate() {
2074 for second in &unseen[position + 1..] {
2075 if shows(&[first, second]).is_some() {
2076 let (first_values, first_words) = describe(first);
2077 let (second_values, second_words) = describe(second);
2078 return (
2079 Some(format!("{first_values} and {second_values}")),
2080 format!("{first_words}; and {second_words}"),
2081 );
2082 }
2083 }
2084 }
2085 (
2086 None,
2087 format!("`{condition}` cannot decide the result on its own"),
2088 )
2089}
2090
2091fn vector_text(vector: &crate::coverage_analysis::McdcVector) -> String {
2092 let values = vector
2093 .values
2094 .iter()
2095 .map(|value| match value {
2096 None => '-',
2097 Some(false) => 'F',
2098 Some(true) => 'T',
2099 })
2100 .collect::<String>();
2101 format!("{values} -> {}", if vector.outcome { 'T' } else { 'F' })
2102}
2103
2104fn obligation_matches_metric(obligation: &CoverageFileObligation, metric: MinimizeMetric) -> bool {
2105 metric == MinimizeMetric::All
2106 || matches!(
2107 (obligation.kind(), metric),
2108 ("line", MinimizeMetric::Lines)
2109 | ("statement", MinimizeMetric::Statements)
2110 | ("function", MinimizeMetric::Functions)
2111 | ("branch", MinimizeMetric::Branches)
2112 | ("mcdc", MinimizeMetric::Mcdc)
2113 )
2114}
2115
2116fn compact_source(value: &str) -> Option<String> {
2117 let line = value.lines().find(|line| !line.trim().is_empty())?.trim();
2118 let compact = line.split_whitespace().collect::<Vec<_>>().join(" ");
2119 if compact.is_empty() {
2120 None
2121 } else if compact.chars().count() > 120 {
2122 Some(format!(
2123 "{}…",
2124 compact.chars().take(119).collect::<String>()
2125 ))
2126 } else {
2127 Some(compact)
2128 }
2129}
2130
2131fn obligation_source(obligation: &CoverageFileObligation) -> Option<String> {
2132 match obligation {
2133 CoverageFileObligation::Line(_) => None,
2134 CoverageFileObligation::Point(value) => compact_source(&value.source),
2135 CoverageFileObligation::Branch(value) => compact_source(&value.source),
2136 CoverageFileObligation::Mcdc(value) => compact_source(&value.decision),
2137 }
2138}
2139
2140pub struct FileDetailSource {
2148 view: CoverageViewId,
2149 tests: Vec<crate::coverage_index::IndexedTestDetail>,
2150 test_summaries: Vec<IndexedTestSummary>,
2151 tests_by_id: HashMap<String, IndexedTestSummary>,
2152 tests_by_file: HashMap<String, Vec<String>>,
2154 lines: Vec<crate::coverage_index::IndexedLine>,
2155 lines_by_file: BTreeMap<String, Vec<usize>>,
2156 points: Vec<crate::coverage_index::IndexedHitMetadata>,
2157 points_by_file: HashMap<String, Vec<usize>>,
2158 decisions: Vec<crate::coverage_report::DecisionResult>,
2159 decisions_by_file: HashMap<String, Vec<usize>>,
2160 limitations: BTreeMap<String, Vec<crate::coverage_index::IndexedLimitation>>,
2161}
2162
2163impl FileDetailSource {
2164 pub fn read_file(
2168 index: &CoverageIndex<'_>,
2169 view: CoverageViewId,
2170 file: &str,
2171 ) -> Result<Self, QueryError> {
2172 let test_summaries = index.test_summaries(view)?;
2173 let tests_by_id = test_summaries
2174 .iter()
2175 .map(|test| (test.id.clone(), test.clone()))
2176 .collect();
2177 let lines = index.lines_in_file(view, file)?;
2178 let points = index.hit_metadata_in_file(view, file)?;
2179 let decisions = index.decision_details_in_file(view, file)?;
2180 let limitations = index.limitations_in_file(view, file)?;
2181 let mut source = Self {
2182 view,
2183 tests: Vec::new(),
2184 test_summaries,
2185 tests_by_id,
2186 tests_by_file: HashMap::new(),
2187 lines_by_file: BTreeMap::new(),
2188 points_by_file: HashMap::new(),
2189 decisions_by_file: HashMap::new(),
2190 limitations: BTreeMap::new(),
2191 lines,
2192 points,
2193 decisions,
2194 };
2195 if !source.lines.is_empty() {
2196 source
2197 .lines_by_file
2198 .insert(file.to_owned(), (0..source.lines.len()).collect());
2199 let mut tests = source
2202 .lines
2203 .iter()
2204 .flat_map(|line| line.tests.iter().cloned())
2205 .collect::<Vec<_>>();
2206 tests.sort_unstable();
2207 tests.dedup();
2208 source.tests_by_file.insert(file.to_owned(), tests);
2209 }
2210 if !source.points.is_empty() {
2211 source
2212 .points_by_file
2213 .insert(file.to_owned(), (0..source.points.len()).collect());
2214 }
2215 if !source.decisions.is_empty() {
2216 source
2217 .decisions_by_file
2218 .insert(file.to_owned(), (0..source.decisions.len()).collect());
2219 }
2220 if !limitations.is_empty() {
2221 source.limitations.insert(file.to_owned(), limitations);
2222 }
2223 Ok(source)
2224 }
2225
2226 pub fn read(index: &CoverageIndex<'_>, view: CoverageViewId) -> Result<Self, QueryError> {
2227 let tests = index.test_details(view)?;
2228 let mut tests_by_file = HashMap::<String, Vec<String>>::new();
2229 for test in &tests {
2230 let mut files = test
2231 .lines
2232 .iter()
2233 .map(|line| line.file.as_str())
2234 .collect::<Vec<_>>();
2235 files.sort_unstable();
2236 files.dedup();
2237 for file in files {
2238 tests_by_file
2239 .entry(file.to_owned())
2240 .or_default()
2241 .push(test.summary.id.clone());
2242 }
2243 }
2244 let test_summaries = tests
2245 .iter()
2246 .map(|test| test.summary.clone())
2247 .collect::<Vec<_>>();
2248 let tests_by_id = test_summaries
2249 .iter()
2250 .map(|test| (test.id.clone(), test.clone()))
2251 .collect();
2252 let lines = index.lines(view)?;
2253 let mut lines_by_file = BTreeMap::<String, Vec<usize>>::new();
2254 for (at, line) in lines.iter().enumerate() {
2255 lines_by_file.entry(line.file.clone()).or_default().push(at);
2256 }
2257 let points = index.hit_metadata(view)?;
2258 let mut points_by_file = HashMap::<String, Vec<usize>>::new();
2259 for (at, point) in points.iter().enumerate() {
2260 points_by_file
2261 .entry(point.file.clone())
2262 .or_default()
2263 .push(at);
2264 }
2265 let decisions = index.decision_details(view)?;
2266 let mut decisions_by_file = HashMap::<String, Vec<usize>>::new();
2267 for (at, decision) in decisions.iter().enumerate() {
2268 decisions_by_file
2269 .entry(decision.meta.file.clone())
2270 .or_default()
2271 .push(at);
2272 }
2273 let mut limitations = BTreeMap::<String, Vec<_>>::new();
2274 for limitation in index.limitations(view)? {
2275 limitations
2276 .entry(limitation.file.clone())
2277 .or_default()
2278 .push(limitation);
2279 }
2280 Ok(Self {
2281 view,
2282 tests,
2283 test_summaries,
2284 tests_by_id,
2285 tests_by_file,
2286 lines,
2287 lines_by_file,
2288 points,
2289 points_by_file,
2290 decisions,
2291 decisions_by_file,
2292 limitations,
2293 })
2294 }
2295
2296 pub fn tests(&self) -> &[crate::coverage_index::IndexedTestDetail] {
2299 &self.tests
2300 }
2301
2302 pub fn lines(&self) -> &[crate::coverage_index::IndexedLine] {
2303 &self.lines
2304 }
2305
2306 pub fn points(&self) -> &[crate::coverage_index::IndexedHitMetadata] {
2307 &self.points
2308 }
2309
2310 pub fn decisions(&self) -> &[crate::coverage_report::DecisionResult] {
2311 &self.decisions
2312 }
2313}
2314
2315pub fn coverage_file_detail_query(
2316 index: &CoverageIndex<'_>,
2317 options: CoverageFileDetailOptions<'_>,
2318) -> Result<(CoverageFileDetailData, AgentPagination), QueryError> {
2319 if options.limit == 0 {
2320 return Err(QueryError::InvalidPagination);
2321 }
2322 let scoped = FileDetailSource::read_file(index, options.view, options.selector)?;
2325 if !scoped.lines.is_empty() || !scoped.limitations.is_empty() {
2326 return coverage_file_detail_from(&scoped, options);
2327 }
2328 coverage_file_detail_from(&FileDetailSource::read(index, options.view)?, options)
2329}
2330
2331pub fn coverage_file_detail_from(
2334 read: &FileDetailSource,
2335 options: CoverageFileDetailOptions<'_>,
2336) -> Result<(CoverageFileDetailData, AgentPagination), QueryError> {
2337 if options.limit == 0 {
2338 return Err(QueryError::InvalidPagination);
2339 }
2340 debug_assert!(read.view == options.view);
2341 let selected = selected_test_ids(&read.test_summaries, options.kind, options.runner)?;
2342 let tests_by_id = &read.tests_by_id;
2343 let files = read
2344 .lines_by_file
2345 .keys()
2346 .chain(read.limitations.keys())
2347 .map(String::as_str)
2348 .collect::<BTreeSet<_>>();
2349 let file = if files.contains(options.selector) {
2350 options.selector.to_owned()
2351 } else {
2352 let matches = files
2353 .into_iter()
2354 .filter(|file| file.contains(options.selector))
2355 .collect::<Vec<_>>();
2356 if matches.is_empty() {
2357 return Err(QueryError::SourceNotFound(options.selector.into()));
2358 }
2359 if matches.len() != 1 {
2360 return Err(QueryError::AmbiguousSelector {
2361 selector: options.selector.into(),
2362 matches: matches.into_iter().map(str::to_owned).collect(),
2363 });
2364 }
2365 matches[0].to_owned()
2366 };
2367 let lines = read
2368 .lines_by_file
2369 .get(&file)
2370 .into_iter()
2371 .flatten()
2372 .map(|at| &read.lines[*at])
2373 .collect::<Vec<_>>();
2374 let metadata = read
2375 .points_by_file
2376 .get(&file)
2377 .into_iter()
2378 .flatten()
2379 .map(|at| &read.points[*at])
2380 .collect::<Vec<_>>();
2381 let selected_includes = |tests: &[String]| {
2382 selected.as_ref().map_or(!tests.is_empty(), |selected| {
2383 tests.iter().any(|test| selected.contains(test))
2384 })
2385 };
2386 let total_lines = lines.iter().filter(|line| line.measured).count();
2387 let uncovered_lines = lines
2388 .iter()
2389 .filter(|line| line.measured && !selected_includes(&line.tests))
2390 .map(|line| {
2391 CoverageFileObligation::Line(CoverageLineObligation {
2392 kind: "line".into(),
2393 id: format!("line:{}:{}", line.file, line.line),
2394 line: line.line,
2395 other_coverage: other_coverage(&line.tests, selected.as_ref(), tests_by_id),
2396 })
2397 })
2398 .collect::<Vec<_>>();
2399 let statements = metadata
2400 .iter()
2401 .filter(|point| point.obligation == "statement" && !selected_includes(&point.tests))
2402 .map(|point| {
2403 CoverageFileObligation::Point(CoveragePointObligation {
2404 kind: "statement".into(),
2405 id: point.id.clone(),
2406 line: point.line,
2407 column: point.column,
2408 source: point.label.clone().unwrap_or_else(|| point.source.clone()),
2409 other_coverage: other_coverage(&point.tests, selected.as_ref(), tests_by_id),
2410 })
2411 })
2412 .collect::<Vec<_>>();
2413 let functions = metadata
2414 .iter()
2415 .filter(|point| point.obligation == "function" && !selected_includes(&point.tests))
2416 .map(|point| {
2417 CoverageFileObligation::Point(CoveragePointObligation {
2418 kind: "function".into(),
2419 id: point.id.clone(),
2420 line: point.line,
2421 column: point.column,
2422 source: point.label.clone().unwrap_or_else(|| point.source.clone()),
2423 other_coverage: other_coverage(&point.tests, selected.as_ref(), tests_by_id),
2424 })
2425 })
2426 .collect::<Vec<_>>();
2427 let branches = metadata
2428 .iter()
2429 .filter(|branch| branch.obligation == "branch" && !selected_includes(&branch.tests))
2430 .map(|branch| {
2431 CoverageFileObligation::Branch(CoverageBranchObligation {
2432 kind: "branch".into(),
2433 id: branch.id.clone(),
2434 line: branch.line,
2435 column: branch.column,
2436 source: branch.source.clone(),
2437 missing: branch.alternative.clone().unwrap_or_default(),
2438 other_coverage: other_coverage(&branch.tests, selected.as_ref(), tests_by_id),
2439 })
2440 })
2441 .collect::<Vec<_>>();
2442 let observed_lines = metadata
2446 .iter()
2447 .filter(|point| selected_includes(&point.tests))
2448 .map(|point| point.line)
2449 .collect::<BTreeSet<_>>();
2450 let mut mcdc = Vec::new();
2451 for original in read
2452 .decisions_by_file
2453 .get(&file)
2454 .into_iter()
2455 .flatten()
2456 .map(|at| &read.decisions[*at])
2457 {
2458 let filtered = selected_decision(original.clone(), selected.as_ref());
2459 for condition in filtered
2460 .conditions
2461 .iter()
2462 .filter(|condition| !condition.covered)
2463 {
2464 let original_tests = original.conditions[condition.index]
2465 .witness_tests
2466 .clone()
2467 .unwrap_or_default()
2468 .into_iter()
2469 .flatten()
2470 .collect::<Vec<_>>();
2471 let observed = filtered
2472 .vector_observations
2473 .iter()
2474 .map(|observation| &observation.vector)
2475 .collect::<Vec<_>>();
2476 let sources = filtered
2477 .conditions
2478 .iter()
2479 .map(|condition| condition.source.as_str())
2480 .collect::<Vec<_>>();
2481 let (needed_vector, needs) =
2482 needed(condition.index, &observed, &sources, &original.meta.source);
2483 mcdc.push(CoverageFileObligation::Mcdc(CoverageMcdcObligation {
2484 kind: "mcdc".into(),
2485 id: original.meta.id.clone(),
2486 line: original.meta.line,
2487 column: original.meta.column,
2488 decision: original.meta.source.clone(),
2489 missing_condition: condition.source.clone(),
2490 condition_index: condition.index,
2491 observed_vectors: observed.iter().map(|vector| vector_text(vector)).collect(),
2492 missing_outcomes: missing_outcomes(condition.index, &observed),
2493 needed_vector,
2494 needs,
2495 other_coverage: other_coverage(&original_tests, selected.as_ref(), tests_by_id),
2496 }));
2497 }
2498 }
2499 let mut obligations = uncovered_lines
2500 .iter()
2501 .chain(statements.iter())
2502 .chain(functions.iter())
2503 .chain(branches.iter())
2504 .chain(mcdc.iter())
2505 .filter(|obligation| obligation_matches_metric(obligation, options.metric))
2506 .cloned()
2507 .collect::<Vec<_>>();
2508 obligations.sort_by(|left, right| {
2509 left.line()
2510 .cmp(&right.line())
2511 .then_with(|| left.kind().cmp(right.kind()))
2512 });
2513 let mut limitations = read
2514 .limitations
2515 .get(&file)
2516 .into_iter()
2517 .flatten()
2518 .cloned()
2519 .map(|limitation| CoverageFileLimitation {
2520 id: limitation.id,
2521 kind: limitation.kind,
2522 file: limitation.file,
2523 line: limitation.line,
2524 column: limitation.column,
2525 source: limitation.source,
2526 reason: limitation.reason,
2527 blocking: limitation.blocking,
2528 effect: "outside-measured-denominator".into(),
2529 })
2530 .collect::<Vec<_>>();
2531 limitations.sort_by(|left, right| {
2532 left.line
2533 .cmp(&right.line)
2534 .then_with(|| left.column.cmp(&right.column))
2535 .then_with(|| left.id.cmp(&right.id))
2536 });
2537 let total_tests = read
2538 .tests_by_file
2539 .get(&file)
2540 .into_iter()
2541 .flatten()
2542 .filter(|test| {
2543 selected
2544 .as_ref()
2545 .is_none_or(|selected| selected.contains(*test))
2546 })
2547 .count();
2548 let total_obligations = obligations.len();
2549 let total_limitations = limitations.len();
2550 let mut grouped =
2551 BTreeMap::<usize, (Vec<CoverageFileObligation>, Vec<CoverageFileLimitation>)>::new();
2552 for obligation in obligations {
2553 grouped
2554 .entry(obligation.line())
2555 .or_default()
2556 .0
2557 .push(obligation);
2558 }
2559 for limitation in limitations {
2560 grouped
2561 .entry(limitation.line)
2562 .or_default()
2563 .1
2564 .push(limitation);
2565 }
2566 let gap_lines = grouped
2567 .into_iter()
2568 .map(|(line, (obligations, limitations))| {
2569 let source = obligations.iter().find_map(obligation_source).or_else(|| {
2570 limitations
2571 .iter()
2572 .find_map(|value| compact_source(&value.source))
2573 });
2574 let never_ran = !observed_lines.contains(&line)
2578 && !obligations.is_empty()
2579 && obligations
2580 .iter()
2581 .all(|value| !matches!(value, CoverageFileObligation::Mcdc(_)));
2582 let state = if never_ran
2583 || obligations
2584 .iter()
2585 .any(|value| matches!(value, CoverageFileObligation::Line(_)))
2586 {
2587 "missing"
2588 } else if !obligations.is_empty() || observed_lines.contains(&line) {
2589 "part"
2592 } else {
2593 "limited"
2594 };
2595 CoverageFileGapLine {
2596 line,
2597 state: state.into(),
2598 source,
2599 obligations,
2600 limitations,
2601 }
2602 })
2603 .collect::<Vec<_>>();
2604 let total_gap_lines = gap_lines.len();
2605 let selected_gap_lines = gap_lines
2606 .into_iter()
2607 .skip(options.offset)
2608 .take(options.limit)
2609 .collect::<Vec<_>>();
2610 let returned = selected_gap_lines.len();
2611 Ok((
2612 CoverageFileDetailData {
2613 run: options.run.into(),
2614 filters: query_filters(options.view, options.kind, options.runner),
2615 file,
2616 metric: options.metric,
2617 counts: CoverageFileCounts {
2618 total_lines,
2619 covered_lines: total_lines - uncovered_lines.len(),
2620 uncovered_lines: uncovered_lines.len(),
2621 uncovered_statements: statements.len(),
2622 uncovered_functions: functions.len(),
2623 missing_branches: branches.len(),
2624 missing_mcdc_conditions: mcdc.len(),
2625 measurement_limitations: total_limitations,
2626 },
2627 total_tests,
2628 total_obligations,
2629 total_gap_lines,
2630 gap_lines: selected_gap_lines,
2631 total_limitations,
2632 assertions: None,
2633 page_cut: None,
2634 },
2635 pagination(options.offset, options.limit, returned, total_gap_lines),
2636 ))
2637}
2638
2639#[derive(Debug, Clone, PartialEq, Serialize)]
2640pub struct CoverageDiffDelta {
2641 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2642 pub lines: f64,
2643 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2644 pub branches: f64,
2645 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
2646 pub mcdc: f64,
2647}
2648
2649#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2650#[serde(rename_all = "camelCase")]
2651pub struct CoverageDiffSide {
2652 pub line_count: usize,
2653 pub branch_count: usize,
2654 pub mcdc_count: usize,
2655 pub lines: Vec<String>,
2656 pub branches: Vec<String>,
2657 pub mcdc: Vec<String>,
2658}
2659
2660#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2662#[serde(rename_all = "camelCase")]
2663pub struct CoverageDiffFile {
2664 pub file: String,
2665 pub lost: CoverageDiffCounts,
2666 pub gained: CoverageDiffCounts,
2667}
2668
2669#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
2670pub struct CoverageDiffCounts {
2671 pub lines: usize,
2672 pub branches: usize,
2673 pub mcdc: usize,
2674}
2675
2676impl CoverageDiffCounts {
2677 fn total(&self) -> usize {
2678 self.lines + self.branches + self.mcdc
2679 }
2680}
2681
2682const DIFF_FILES: usize = 20;
2684
2685#[derive(Debug, Clone, PartialEq, Serialize)]
2686#[serde(rename_all = "camelCase")]
2687pub struct CoverageDiffData {
2688 pub filters: CoverageQueryFilters,
2689 pub older: String,
2690 pub newer: String,
2691 pub delta: CoverageDiffDelta,
2692 pub gained: CoverageDiffSide,
2693 pub lost: CoverageDiffSide,
2694 pub files: usize,
2698 pub by_file: Vec<CoverageDiffFile>,
2699 #[serde(skip_serializing_if = "Option::is_none")]
2702 pub inputs: Option<CoverageDiffInputs>,
2703 #[serde(skip_serializing_if = "std::ops::Not::not")]
2705 pub gained_only: bool,
2706}
2707
2708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2712#[serde(rename_all = "camelCase")]
2713pub struct CoverageDiffInputs {
2714 pub source_changed: bool,
2716 pub tests_changed: bool,
2717}
2718
2719#[derive(Debug, Clone, Copy)]
2720pub struct CoverageDiffQueryOptions<'a> {
2721 pub older_run: &'a str,
2722 pub newer_run: &'a str,
2723 pub view: CoverageViewId,
2724 pub kind: Option<&'a str>,
2725 pub runner: Option<&'a str>,
2726 pub offset: usize,
2727 pub limit: usize,
2728 pub gained_only: bool,
2732}
2733
2734struct DiffSnapshot {
2735 summary: CoverageSummary,
2736 lines: BTreeSet<String>,
2737 branches: HashMap<String, String>,
2738 mcdc: HashMap<String, String>,
2739}
2740
2741fn diff_snapshot(
2742 index: &CoverageIndex<'_>,
2743 view: CoverageViewId,
2744) -> Result<DiffSnapshot, QueryError> {
2745 let summary = index.summary(view)?;
2746 let lines = index
2747 .lines(view)?
2748 .into_iter()
2749 .filter(|line| line.covered)
2750 .map(|line| format!("{}:{}", line.file, line.line))
2751 .collect();
2752 let branches = index
2753 .hit_metadata(view)?
2754 .into_iter()
2755 .filter(|metadata| metadata.obligation == "branch" && !metadata.tests.is_empty())
2756 .map(|metadata| {
2757 let parent = metadata
2758 .parent_id
2759 .ok_or(QueryError::InvalidRecordSelection)?;
2760 Ok((
2761 format!("{parent}:{}", metadata.id),
2762 format!(
2763 "{}:{} {}",
2764 metadata.file,
2765 metadata.line,
2766 metadata.alternative.unwrap_or_default()
2767 ),
2768 ))
2769 })
2770 .collect::<Result<HashMap<_, _>, QueryError>>()?;
2771 let mcdc = index
2772 .decision_details(view)?
2773 .into_iter()
2774 .flat_map(|decision| {
2775 decision
2776 .conditions
2777 .into_iter()
2778 .filter(|condition| condition.covered)
2779 .map(move |condition| {
2780 (
2781 format!("{}:c{}", decision.meta.id, condition.index),
2782 format!(
2783 "{}:{} C{} {}",
2784 decision.meta.file,
2785 decision.meta.line,
2786 condition.index + 1,
2787 condition.source
2788 ),
2789 )
2790 })
2791 })
2792 .collect();
2793 Ok(DiffSnapshot {
2794 summary,
2795 lines,
2796 branches,
2797 mcdc,
2798 })
2799}
2800
2801fn js_string_cmp(left: &str, right: &str) -> std::cmp::Ordering {
2802 left.encode_utf16().cmp(right.encode_utf16())
2803}
2804
2805fn rounded_delta(newer: f64, older: f64) -> f64 {
2806 ((newer - older) * 100.0).round() / 100.0
2807}
2808
2809pub fn coverage_diff_query(
2810 older: &CoverageIndex<'_>,
2811 newer: &CoverageIndex<'_>,
2812 options: CoverageDiffQueryOptions<'_>,
2813) -> Result<(CoverageDiffData, AgentPagination), QueryError> {
2814 if options.limit == 0 {
2815 return Err(QueryError::InvalidPagination);
2816 }
2817 let older = diff_snapshot(older, options.view)?;
2818 let newer = diff_snapshot(newer, options.view)?;
2819 let mut gained_lines = newer
2820 .lines
2821 .difference(&older.lines)
2822 .cloned()
2823 .collect::<Vec<_>>();
2824 let mut lost_lines = older
2825 .lines
2826 .difference(&newer.lines)
2827 .cloned()
2828 .collect::<Vec<_>>();
2829 let mut gained_branches = newer
2830 .branches
2831 .iter()
2832 .filter(|(id, _)| !older.branches.contains_key(*id))
2833 .map(|(_, label)| label.clone())
2834 .collect::<Vec<_>>();
2835 let mut lost_branches = older
2836 .branches
2837 .iter()
2838 .filter(|(id, _)| !newer.branches.contains_key(*id))
2839 .map(|(_, label)| label.clone())
2840 .collect::<Vec<_>>();
2841 let mut gained_mcdc = newer
2842 .mcdc
2843 .iter()
2844 .filter(|(id, _)| !older.mcdc.contains_key(*id))
2845 .map(|(_, label)| label.clone())
2846 .collect::<Vec<_>>();
2847 let mut lost_mcdc = older
2848 .mcdc
2849 .iter()
2850 .filter(|(id, _)| !newer.mcdc.contains_key(*id))
2851 .map(|(_, label)| label.clone())
2852 .collect::<Vec<_>>();
2853 if options.gained_only {
2854 lost_lines.clear();
2855 lost_branches.clear();
2856 lost_mcdc.clear();
2857 }
2858 for values in [
2859 &mut gained_lines,
2860 &mut lost_lines,
2861 &mut gained_branches,
2862 &mut lost_branches,
2863 &mut gained_mcdc,
2864 &mut lost_mcdc,
2865 ] {
2866 values.sort_by(|left, right| js_string_cmp(left, right));
2867 }
2868 let mut by_file = BTreeMap::<&str, (CoverageDiffCounts, CoverageDiffCounts)>::new();
2870 for (values, count) in [
2871 (&lost_lines, 0),
2872 (&lost_branches, 1),
2873 (&lost_mcdc, 2),
2874 (&gained_lines, 3),
2875 (&gained_branches, 4),
2876 (&gained_mcdc, 5),
2877 ] {
2878 for label in values {
2879 let place = label.split(' ').next().unwrap_or(label);
2880 let file = place.rsplit_once(':').map_or(place, |(file, _)| file);
2881 let (lost, gained) = by_file.entry(file).or_default();
2882 let side = if count < 3 { lost } else { gained };
2883 match count % 3 {
2884 0 => side.lines += 1,
2885 1 => side.branches += 1,
2886 _ => side.mcdc += 1,
2887 }
2888 }
2889 }
2890 let files = by_file.len();
2891 let mut by_file = by_file
2892 .into_iter()
2893 .map(|(file, (lost, gained))| CoverageDiffFile {
2894 file: file.to_owned(),
2895 lost,
2896 gained,
2897 })
2898 .collect::<Vec<_>>();
2899 by_file.sort_by(|left, right| {
2900 (right.lost.total(), right.gained.total())
2901 .cmp(&(left.lost.total(), left.gained.total()))
2902 .then_with(|| js_string_cmp(&left.file, &right.file))
2903 });
2904 by_file.truncate(DIFF_FILES);
2905 let total = [
2906 gained_lines.len(),
2907 gained_branches.len(),
2908 gained_mcdc.len(),
2909 lost_lines.len(),
2910 lost_branches.len(),
2911 lost_mcdc.len(),
2912 ]
2913 .into_iter()
2914 .max()
2915 .unwrap_or(0);
2916 let page = |values: &[String]| {
2917 values
2918 .iter()
2919 .skip(options.offset)
2920 .take(options.limit)
2921 .cloned()
2922 .collect::<Vec<_>>()
2923 };
2924 let gained = CoverageDiffSide {
2925 line_count: gained_lines.len(),
2926 branch_count: gained_branches.len(),
2927 mcdc_count: gained_mcdc.len(),
2928 lines: page(&gained_lines),
2929 branches: page(&gained_branches),
2930 mcdc: page(&gained_mcdc),
2931 };
2932 let lost = CoverageDiffSide {
2933 line_count: lost_lines.len(),
2934 branch_count: lost_branches.len(),
2935 mcdc_count: lost_mcdc.len(),
2936 lines: page(&lost_lines),
2937 branches: page(&lost_branches),
2938 mcdc: page(&lost_mcdc),
2939 };
2940 let returned = [
2941 gained.lines.len(),
2942 gained.branches.len(),
2943 gained.mcdc.len(),
2944 lost.lines.len(),
2945 lost.branches.len(),
2946 lost.mcdc.len(),
2947 ]
2948 .into_iter()
2949 .max()
2950 .unwrap_or(0);
2951 Ok((
2952 CoverageDiffData {
2953 filters: query_filters(options.view, options.kind, options.runner),
2954 older: options.older_run.into(),
2955 newer: options.newer_run.into(),
2956 delta: CoverageDiffDelta {
2957 lines: rounded_delta(
2958 newer.summary.lines.percentage,
2959 older.summary.lines.percentage,
2960 ),
2961 branches: rounded_delta(
2962 newer.summary.branches.percentage,
2963 older.summary.branches.percentage,
2964 ),
2965 mcdc: rounded_delta(
2966 newer.summary.condition_coverage_pct,
2967 older.summary.condition_coverage_pct,
2968 ),
2969 },
2970 gained,
2971 lost,
2972 files,
2973 by_file,
2974 inputs: None,
2975 gained_only: options.gained_only,
2976 },
2977 pagination(options.offset, options.limit, returned, total),
2978 ))
2979}
2980
2981pub fn coverage_scope_query(
2982 index: &CoverageIndex<'_>,
2983 options: CoverageScopeQueryOptions<'_>,
2984) -> Result<(CoverageScopeData, AgentPagination), QueryError> {
2985 if options.limit == 0 {
2986 return Err(QueryError::InvalidPagination);
2987 }
2988 let projection = index.projection(options.view, options.kind, options.runner)?;
2989 let scope = projection
2990 .source_scope
2991 .ok_or(QueryError::ScopeUnavailable)?;
2992 let mut entries = index.scope_entries(options.view)?;
2993 entries.sort_by(|left, right| {
2994 scope_status_rank(&left.status)
2995 .cmp(&scope_status_rank(&right.status))
2996 .then_with(|| left.file.cmp(&right.file))
2997 });
2998 let groups = scope_groups(&entries);
2999 let unclassified = unclassified_source(&scope.roots, &entries);
3000 let total = entries.len();
3001 let selected = entries
3002 .into_iter()
3003 .skip(options.offset)
3004 .take(options.limit)
3005 .collect::<Vec<_>>();
3006 let returned = selected.len();
3007 Ok((
3008 CoverageScopeData {
3009 run: options.run.into(),
3010 filters: CoverageQueryFilters {
3011 outcome: match options.view {
3012 CoverageViewId::All => "all",
3013 CoverageViewId::Passed => "passed",
3014 CoverageViewId::Failed => "failed",
3015 }
3016 .into(),
3017 kind: options.kind.map(str::to_owned),
3018 runner: options.runner.map(str::to_owned),
3019 },
3020 kind: scope.kind,
3021 language: scope.language,
3022 model: scope.model,
3023 mode: scope.mode,
3024 roots: scope.roots,
3025 unit: scope.unit,
3026 measurement_complete: scope.measurement_complete,
3027 counts: ScopeCounts {
3028 included: scope.included,
3029 excluded: scope.excluded,
3030 ambiguous: scope.ambiguous,
3031 },
3032 measurement: projection.measurement,
3033 groups,
3034 unclassified_source: unclassified,
3035 entries: selected,
3036 },
3037 pagination(options.offset, options.limit, returned, total),
3038 ))
3039}
3040
3041pub fn coverage_summary_query(
3042 index: &CoverageIndex<'_>,
3043 options: CoverageSummaryQueryOptions<'_>,
3044) -> Result<CoverageSummaryData, QueryError> {
3045 let projection = index.projection(options.view, options.kind, options.runner)?;
3046 let mut test_kind_sources = BTreeMap::new();
3047 for test in index.test_summaries(options.view)?.iter().filter(|t| {
3048 t.role == "test"
3049 && options.kind.is_none_or(|k| t.provenance.kind == k)
3050 && options.runner.is_none_or(|r| t.provenance.runner == r)
3051 }) {
3052 *test_kind_sources
3053 .entry(test.provenance.source.clone())
3054 .or_insert(0) += 1;
3055 }
3056 let mut diagnostics = Vec::new();
3057 let mut transport_blockers = 0usize;
3058 if projection.empty_evidence_tests > 0 {
3059 diagnostics.push(CoverageDiagnostic {
3060 code: "TEST_EVIDENCE_MISSING".into(),
3061 severity: "warning".into(),
3062 message: format!(
3063 "{} test(s) recorded assertion phases but attributed zero coverage evidence; possible causes include checks of uninstrumented data, shared setup, lost async context or missing probe transport. First: {}",
3064 projection.empty_evidence_tests,
3065 projection.first_empty_evidence_test.as_deref().unwrap_or("unknown")
3066 ),
3067 });
3068 }
3069 if let Some(transport) = &projection.transport {
3070 if transport.corrupt_records > 0 {
3071 transport_blockers += 1;
3072 diagnostics.push(CoverageDiagnostic {
3073 code: "CORRUPT_EVIDENCE_RECORDS".into(),
3074 severity: "error".into(),
3075 message: format!(
3076 "{} malformed evidence record(s) in {} file(s) were excluded; coverage is incomplete.",
3077 transport.corrupt_records, transport.corrupt_files
3078 ),
3079 });
3080 }
3081 if transport.remote_launches > 0
3082 && transport.scoped_server_records == 0
3083 && transport.background_server_records == 0
3084 && projection.attribution.server_explicit == 0
3085 && projection.attribution.server_fallback == 0
3086 {
3087 transport_blockers += 1;
3088 diagnostics.push(CoverageDiagnostic {
3089 code: "REMOTE_SERVER_EVIDENCE_MISSING".into(),
3090 severity: "error".into(),
3091 message: "Remote launches were supervised, but neither scoped nor background server evidence returned. Supercov refuses to describe this measurement as complete.".into(),
3092 });
3093 }
3094 }
3095 let coverage_by_kind = index.dimensions(options.view, CoverageDimension::Kind)?;
3096 let coverage_by_runner = index.dimensions(options.view, CoverageDimension::Runner)?;
3097 let coverage_by_tests = if options.kind.is_none() && options.runner.is_none() {
3098 index
3099 .dimensions(options.view, CoverageDimension::Tests)?
3100 .into_iter()
3101 .next()
3102 .filter(|tests| tests.tests + tests.setups > 0)
3103 .map(|tests| tests.summary)
3104 } else {
3105 None
3106 };
3107 let other_e2e_kinds = coverage_by_kind
3108 .iter()
3109 .filter(|dimension| dimension.tests > 0)
3110 .filter_map(|dimension| dimension.kind.clone())
3111 .filter(|kind| kind != "e2e")
3112 .collect::<Vec<_>>();
3113 let e2e_observed = coverage_by_kind
3114 .iter()
3115 .any(|dimension| dimension.tests > 0 && dimension.kind.as_deref() == Some("e2e"));
3116 let e2e_gap_context = if options.kind.is_none()
3117 && options.runner.is_none()
3118 && e2e_observed
3119 && !other_e2e_kinds.is_empty()
3120 {
3121 let mut covered_elsewhere = IndexedGapDimensions {
3122 lines: 0,
3123 statements: 0,
3124 functions: 0,
3125 branches: 0,
3126 mcdc_conditions: 0,
3127 };
3128 let mut uncovered_everywhere = covered_elsewhere.clone();
3129 for gap in index.file_gaps(options.view, Some("e2e"), None)? {
3130 covered_elsewhere.lines += gap.covered_by_other_tests.lines;
3131 covered_elsewhere.statements += gap.covered_by_other_tests.statements;
3132 covered_elsewhere.functions += gap.covered_by_other_tests.functions;
3133 covered_elsewhere.branches += gap.covered_by_other_tests.branches;
3134 covered_elsewhere.mcdc_conditions += gap.covered_by_other_tests.mcdc_conditions;
3135 uncovered_everywhere.lines += gap.uncovered_everywhere.lines;
3136 uncovered_everywhere.statements += gap.uncovered_everywhere.statements;
3137 uncovered_everywhere.functions += gap.uncovered_everywhere.functions;
3138 uncovered_everywhere.branches += gap.uncovered_everywhere.branches;
3139 uncovered_everywhere.mcdc_conditions += gap.uncovered_everywhere.mcdc_conditions;
3140 }
3141 Some(CoverageKindGapContext {
3142 kind: "e2e".into(),
3143 other_kinds: other_e2e_kinds,
3144 covered_elsewhere,
3145 uncovered_everywhere,
3146 })
3147 } else {
3148 None
3149 };
3150 let filters = CoverageQueryFilters {
3151 outcome: match options.view {
3152 CoverageViewId::All => "all",
3153 CoverageViewId::Passed => "passed",
3154 CoverageViewId::Failed => "failed",
3155 }
3156 .into(),
3157 kind: options.kind.map(str::to_owned),
3158 runner: options.runner.map(str::to_owned),
3159 };
3160 let mut measurement = projection.measurement.clone();
3161 if transport_blockers > 0 {
3162 measurement.complete = false;
3163 measurement.limitations = measurement.limitations.saturating_add(transport_blockers);
3164 measurement.blocking = measurement.blocking.saturating_add(transport_blockers);
3165 }
3166 let structurally_complete = projection.summary.coverage_complete && measurement.complete;
3167 let complete = options.view == CoverageViewId::Passed
3168 && options.valid
3169 && !options.stale
3170 && structurally_complete;
3171 Ok(CoverageSummaryData {
3172 test_kind_sources,
3173 assertion_coverage: None,
3174 run: options.run.into(),
3175 command: Vec::new(),
3176 merged_across: Vec::new(),
3177 companions: Vec::new(),
3178 hints: Vec::new(),
3179 workspace: None,
3180 filters,
3181 model: index.model()?,
3182 generated_at: projection.generated_at,
3183 valid: options.valid,
3184 test_exit_code: options.test_exit_code,
3185 stale: options.stale,
3186 stale_reasons: options.stale_reasons,
3187 structurally_complete,
3188 complete,
3189 coverage: projection.summary,
3190 measurement,
3191 coverage_by_kind,
3192 coverage_by_tests,
3193 e2e_gap_context,
3194 coverage_by_runner,
3195 attribution: projection.attribution,
3196 test_attribution: {
3197 let mut counts = TestAttributionCounts::default();
3198 for test in index
3199 .test_summaries(options.view)?
3200 .iter()
3201 .filter(|test| test.role == "test")
3202 {
3203 match test.attribution.as_str() {
3204 crate::coverage_report::ATTRIBUTION_RUN_WIDE => counts.run_wide += 1,
3205 crate::coverage_report::ATTRIBUTION_PARTIAL => counts.partial += 1,
3206 crate::coverage_report::ATTRIBUTION_BY_TIME => counts.by_time += 1,
3207 _ => counts.exact += 1,
3208 }
3209 }
3210 counts
3211 },
3212 transport: projection.transport,
3213 diagnostics,
3214 confidence: (options.kind.is_none() && options.runner.is_none())
3215 .then_some(projection.confidence),
3216 files_with_gaps: projection.files_with_gaps,
3217 files_with_coverage_gaps: projection.files_with_coverage_gaps,
3218 files_with_measurement_limitations: projection.measurement.files,
3219 tests: projection.tests,
3220 setups: projection.setups,
3221 test_outcomes: projection.test_outcomes,
3222 unclassified_source: match &projection.source_scope {
3223 Some(scope) if scope.ambiguous > 0 => {
3224 unclassified_source(&scope.roots, &index.scope_entries(options.view)?)
3225 }
3226 _ => None,
3227 },
3228 source_scope: projection.source_scope,
3229 })
3230}
3231
3232#[derive(Debug, Clone, PartialEq)]
3233pub enum CoverageDimensionQueryData {
3234 Kinds(CoverageKindsData),
3235 Runners(CoverageRunnersData),
3236}
3237
3238#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3241#[serde(rename_all = "camelCase")]
3242pub struct CoverageArea {
3243 pub directory: String,
3244 pub files: usize,
3245 pub totals: IndexedGapDimensions,
3246 pub covered: IndexedGapDimensions,
3247 pub by_kind: Vec<CoverageAreaKind>,
3248 #[serde(skip_serializing_if = "Option::is_none")]
3252 pub covered_by_tests: Option<IndexedGapDimensions>,
3253}
3254
3255#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3256#[serde(rename_all = "camelCase")]
3257pub struct CoverageAreaKind {
3258 pub kind: String,
3259 pub covered: IndexedGapDimensions,
3260}
3261
3262#[derive(Debug, Clone, PartialEq, Serialize)]
3263#[serde(rename_all = "camelCase")]
3264pub struct CoverageAreasData {
3265 pub run: String,
3266 pub filters: CoverageQueryFilters,
3267 pub metric: MinimizeMetric,
3268 pub depth: usize,
3269 pub kinds: Vec<String>,
3272 pub areas: Vec<CoverageArea>,
3273}
3274
3275#[derive(Debug, Clone, Copy)]
3276pub struct CoverageAreasQueryOptions<'a> {
3277 pub run: &'a str,
3278 pub view: CoverageViewId,
3279 pub metric: MinimizeMetric,
3280 pub kind: Option<&'a str>,
3281 pub runner: Option<&'a str>,
3282 pub depth: usize,
3283 pub offset: usize,
3284 pub limit: usize,
3285}
3286
3287fn area_directory(file: &str, depth: usize) -> String {
3290 let mut folders = file.split('/').collect::<Vec<_>>();
3291 folders.pop();
3292 if folders.is_empty() {
3293 ".".into()
3294 } else {
3295 folders[..folders.len().min(depth)].join("/")
3296 }
3297}
3298
3299fn add_dimensions(sum: &mut IndexedGapDimensions, value: &IndexedGapDimensions) {
3300 sum.lines += value.lines;
3301 sum.statements += value.statements;
3302 sum.functions += value.functions;
3303 sum.branches += value.branches;
3304 sum.mcdc_conditions += value.mcdc_conditions;
3305}
3306
3307const NO_DIMENSIONS: IndexedGapDimensions = IndexedGapDimensions {
3308 lines: 0,
3309 statements: 0,
3310 functions: 0,
3311 branches: 0,
3312 mcdc_conditions: 0,
3313};
3314
3315pub fn dimension_for_metric(dimensions: &IndexedGapDimensions, metric: MinimizeMetric) -> usize {
3317 match metric {
3318 MinimizeMetric::All | MinimizeMetric::Lines => dimensions.lines,
3319 MinimizeMetric::Statements => dimensions.statements,
3320 MinimizeMetric::Functions => dimensions.functions,
3321 MinimizeMetric::Branches => dimensions.branches,
3322 MinimizeMetric::Mcdc => dimensions.mcdc_conditions,
3323 }
3324}
3325
3326pub fn coverage_areas_query(
3330 index: &CoverageIndex<'_>,
3331 options: CoverageAreasQueryOptions<'_>,
3332) -> Result<(CoverageAreasData, AgentPagination), QueryError> {
3333 if options.limit == 0 || options.depth == 0 {
3334 return Err(QueryError::InvalidPagination);
3335 }
3336 let files = index.file_gaps(options.view, options.kind, options.runner)?;
3337 if (options.kind.is_some() || options.runner.is_some()) && files.is_empty() {
3338 return Err(QueryError::TestFilterEmpty {
3339 kind: options.kind.map(str::to_owned),
3340 runner: options.runner.map(str::to_owned),
3341 });
3342 }
3343 let kinds = if options.kind.is_none() && options.runner.is_none() {
3344 index
3345 .dimensions(options.view, CoverageDimension::Kind)?
3346 .into_iter()
3347 .filter(|kind| kind.tests > 0 && kind.attributed > 0)
3348 .filter_map(|kind| kind.kind)
3349 .collect::<Vec<_>>()
3350 } else {
3351 Vec::new()
3352 };
3353 let mut areas = BTreeMap::<String, CoverageArea>::new();
3354 for file in &files {
3355 let directory = area_directory(&file.file, options.depth);
3356 let area = areas
3357 .entry(directory.clone())
3358 .or_insert_with(|| CoverageArea {
3359 directory,
3360 files: 0,
3361 totals: NO_DIMENSIONS,
3362 covered: NO_DIMENSIONS,
3363 by_kind: kinds
3364 .iter()
3365 .map(|kind| CoverageAreaKind {
3366 kind: kind.clone(),
3367 covered: NO_DIMENSIONS,
3368 })
3369 .collect(),
3370 covered_by_tests: None,
3371 });
3372 area.files += 1;
3373 add_dimensions(&mut area.totals, &file.totals);
3374 add_dimensions(&mut area.covered, &file.covered);
3375 }
3376 for (position, kind) in kinds.iter().enumerate() {
3377 for file in index.file_gaps(options.view, Some(kind), None)? {
3378 if let Some(area) = areas.get_mut(&area_directory(&file.file, options.depth)) {
3379 add_dimensions(&mut area.by_kind[position].covered, &file.covered);
3380 }
3381 }
3382 }
3383 if !kinds.is_empty() {
3384 let tested = index.file_gaps(options.view, Some(crate::coverage_index::ALL_TESTS), None)?;
3385 if !tested.is_empty() {
3386 for area in areas.values_mut() {
3387 area.covered_by_tests = Some(NO_DIMENSIONS);
3388 }
3389 for file in tested {
3390 if let Some(sum) = areas
3391 .get_mut(&area_directory(&file.file, options.depth))
3392 .and_then(|area| area.covered_by_tests.as_mut())
3393 {
3394 add_dimensions(sum, &file.covered);
3395 }
3396 }
3397 }
3398 }
3399 let mut areas = areas.into_values().collect::<Vec<_>>();
3400 areas.sort_by(|left, right| {
3401 dimension_for_metric(&right.totals, options.metric)
3402 .cmp(&dimension_for_metric(&left.totals, options.metric))
3403 .then_with(|| left.directory.cmp(&right.directory))
3404 });
3405 let total = areas.len();
3406 let page = areas
3407 .into_iter()
3408 .skip(options.offset)
3409 .take(options.limit)
3410 .collect::<Vec<_>>();
3411 let returned = page.len();
3412 Ok((
3413 CoverageAreasData {
3414 run: options.run.into(),
3415 filters: CoverageQueryFilters {
3416 outcome: match options.view {
3417 CoverageViewId::All => "all",
3418 CoverageViewId::Passed => "passed",
3419 CoverageViewId::Failed => "failed",
3420 }
3421 .into(),
3422 kind: options.kind.map(str::to_owned),
3423 runner: options.runner.map(str::to_owned),
3424 },
3425 metric: options.metric,
3426 depth: options.depth,
3427 kinds,
3428 areas: page,
3429 },
3430 pagination(options.offset, options.limit, returned, total),
3431 ))
3432}
3433
3434#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3436#[serde(rename_all = "camelCase")]
3437pub struct TestWithoutEvidence {
3438 pub id: String,
3439 pub name: String,
3440 #[serde(skip_serializing_if = "Option::is_none")]
3441 pub file: Option<String>,
3442 pub kind: String,
3443 pub runner: String,
3444 pub outcome: String,
3445 pub assertions: usize,
3446 #[serde(skip_serializing_if = "Option::is_none")]
3450 pub imports_measured: Option<bool>,
3451}
3452
3453#[derive(Debug, Clone, PartialEq, Serialize)]
3454#[serde(rename_all = "camelCase")]
3455pub struct TestsWithoutEvidenceData {
3456 pub run: String,
3457 pub filters: CoverageQueryFilters,
3458 pub tests: Vec<TestWithoutEvidence>,
3459}
3460
3461#[derive(Debug, Clone, Copy)]
3462pub struct TestsWithoutEvidenceOptions<'a> {
3463 pub run: &'a str,
3464 pub view: CoverageViewId,
3465 pub kind: Option<&'a str>,
3466 pub runner: Option<&'a str>,
3467 pub offset: usize,
3468 pub limit: usize,
3469}
3470
3471pub fn tests_without_evidence_query(
3475 index: &CoverageIndex<'_>,
3476 options: TestsWithoutEvidenceOptions<'_>,
3477) -> Result<(TestsWithoutEvidenceData, AgentPagination), QueryError> {
3478 if options.limit == 0 {
3479 return Err(QueryError::InvalidPagination);
3480 }
3481 let mut assertions = BTreeMap::<String, usize>::new();
3482 for phase in index.phase_summaries(options.view)? {
3483 *assertions.entry(phase.test).or_insert(0) += 1;
3484 }
3485 let mut tests = index
3486 .test_details(options.view)?
3487 .into_iter()
3488 .filter(|test| {
3489 test.summary.role == "test"
3490 && test.lines.is_empty()
3491 && test.hits.is_empty()
3492 && test.decisions.is_empty()
3493 && options
3494 .kind
3495 .is_none_or(|kind| test.summary.provenance.kind == kind)
3496 && options
3497 .runner
3498 .is_none_or(|runner| test.summary.provenance.runner == runner)
3499 })
3500 .filter_map(|test| {
3501 let assertions = *assertions.get(&test.summary.id)?;
3502 Some(TestWithoutEvidence {
3503 id: test.summary.id,
3504 name: test.summary.name,
3505 file: test.summary.file,
3506 kind: test.summary.provenance.kind,
3507 runner: test.summary.provenance.runner,
3508 outcome: test.summary.outcome,
3509 assertions,
3510 imports_measured: None,
3511 })
3512 })
3513 .collect::<Vec<_>>();
3514 tests.sort_by(|left, right| {
3515 (&left.file, &left.name, &left.id).cmp(&(&right.file, &right.name, &right.id))
3516 });
3517 let total = tests.len();
3518 let page = tests
3519 .into_iter()
3520 .skip(options.offset)
3521 .take(options.limit)
3522 .collect::<Vec<_>>();
3523 let returned = page.len();
3524 Ok((
3525 TestsWithoutEvidenceData {
3526 run: options.run.into(),
3527 filters: CoverageQueryFilters {
3528 outcome: match options.view {
3529 CoverageViewId::All => "all",
3530 CoverageViewId::Passed => "passed",
3531 CoverageViewId::Failed => "failed",
3532 }
3533 .into(),
3534 kind: options.kind.map(str::to_owned),
3535 runner: options.runner.map(str::to_owned),
3536 },
3537 tests: page,
3538 },
3539 pagination(options.offset, options.limit, returned, total),
3540 ))
3541}
3542
3543#[derive(Debug, Clone, PartialEq)]
3544pub enum CoverageFileQueryData {
3545 Files(CoverageFilesData),
3546 Gaps(CoverageGapsData),
3547}
3548
3549#[derive(Debug, Clone, PartialEq)]
3550pub struct CoverageFileQueryResult {
3551 pub data: CoverageFileQueryData,
3552 pub pagination: AgentPagination,
3553}
3554
3555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3556#[serde(rename_all = "lowercase")]
3557pub enum DecisionSort {
3558 Location,
3559 Missing,
3560}
3561
3562#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3563#[serde(rename_all = "camelCase")]
3564pub struct DecisionGapTotals {
3565 pub decisions: usize,
3566 pub decisions_with_missing_conditions: usize,
3567 pub conditions: usize,
3568 pub missing_conditions: usize,
3569}
3570
3571#[derive(Debug, Clone, PartialEq, Serialize)]
3572#[serde(rename_all = "camelCase")]
3573pub struct CoverageFileDecisionsData {
3574 pub run: String,
3575 pub filters: CoverageQueryFilters,
3576 pub file: String,
3577 pub group: String,
3578 pub sort: DecisionSort,
3579 pub totals: DecisionGapTotals,
3580 pub decisions: Vec<CoverageFileDecisionRow>,
3581}
3582
3583#[derive(Debug, Clone, PartialEq, Serialize)]
3585#[serde(rename_all = "camelCase")]
3586pub struct CoverageFileDecisionRow {
3587 #[serde(flatten)]
3588 pub gap: IndexedDecisionGap,
3589 pub missing: Vec<CoverageMissingCondition>,
3590}
3591
3592#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3593#[serde(rename_all = "camelCase")]
3594pub struct CoverageMissingCondition {
3595 pub index: usize,
3596 pub condition: String,
3597 pub missing_outcomes: Vec<String>,
3600 pub needed_vector: Option<String>,
3601 pub needs: String,
3602}
3603
3604#[derive(Debug, Clone, Copy)]
3605pub struct CoverageFileDecisionsOptions<'a> {
3606 pub run: &'a str,
3607 pub view: CoverageViewId,
3608 pub kind: Option<&'a str>,
3609 pub runner: Option<&'a str>,
3610 pub file: &'a str,
3611 pub sort: DecisionSort,
3612 pub offset: usize,
3613 pub limit: usize,
3614}
3615
3616#[derive(Debug, Clone)]
3617pub struct CoverageDimensionQueryOptions<'a> {
3618 pub run: &'a str,
3619 pub view: CoverageViewId,
3620 pub dimension: CoverageDimension,
3621 pub filters: CoverageQueryFilters,
3622 pub offset: usize,
3623 pub limit: usize,
3624}
3625
3626pub fn coverage_dimension_query(
3627 index: &CoverageIndex<'_>,
3628 options: CoverageDimensionQueryOptions<'_>,
3629) -> Result<(CoverageDimensionQueryData, AgentPagination), QueryError> {
3630 let CoverageDimensionQueryOptions {
3631 run,
3632 view,
3633 dimension,
3634 filters,
3635 offset,
3636 limit,
3637 } = options;
3638 if limit == 0 {
3639 return Err(QueryError::InvalidPagination);
3640 }
3641 let values = index.dimensions(view, dimension)?;
3642 let total = values.len();
3643 let selected = values
3644 .into_iter()
3645 .skip(offset)
3646 .take(limit)
3647 .collect::<Vec<_>>();
3648 let returned = selected.len();
3649 let data = match dimension {
3650 CoverageDimension::Kind | CoverageDimension::Tests => {
3652 CoverageDimensionQueryData::Kinds(CoverageKindsData {
3653 run: run.into(),
3654 filters,
3655 kinds: selected,
3656 })
3657 }
3658 CoverageDimension::Runner => CoverageDimensionQueryData::Runners(CoverageRunnersData {
3659 run: run.into(),
3660 filters,
3661 runners: selected,
3662 }),
3663 };
3664 Ok((data, pagination(offset, limit, returned, total)))
3665}
3666
3667#[derive(Debug, Clone, Copy)]
3668pub struct CoverageFileQueryOptions<'a> {
3669 pub run: &'a str,
3670 pub view: CoverageViewId,
3671 pub metric: MinimizeMetric,
3672 pub gaps_only: bool,
3673 pub kind: Option<&'a str>,
3674 pub runner: Option<&'a str>,
3675 pub offset: usize,
3676 pub limit: usize,
3677}
3678
3679fn gap_metric_value(gap: &IndexedFileGap, metric: MinimizeMetric) -> usize {
3680 match metric {
3681 MinimizeMetric::All => gap.score,
3682 MinimizeMetric::Lines => gap.uncovered_lines,
3683 MinimizeMetric::Statements => gap.uncovered_statements,
3684 MinimizeMetric::Functions => gap.uncovered_functions,
3685 MinimizeMetric::Branches => gap.missing_branches,
3686 MinimizeMetric::Mcdc => gap.missing_mcdc_conditions,
3687 }
3688}
3689
3690fn has_gap_for_metric(gap: &IndexedFileGap, metric: MinimizeMetric) -> bool {
3691 match metric {
3692 MinimizeMetric::All => {
3693 gap.uncovered_lines > 0
3694 || gap.uncovered_statements > 0
3695 || gap.uncovered_functions > 0
3696 || gap.missing_branches > 0
3697 || gap.missing_mcdc_conditions > 0
3698 }
3699 _ => gap_metric_value(gap, metric) > 0,
3700 }
3701}
3702
3703pub fn coverage_file_decisions_query(
3704 index: &CoverageIndex<'_>,
3705 options: CoverageFileDecisionsOptions<'_>,
3706) -> Result<(CoverageFileDecisionsData, AgentPagination), QueryError> {
3707 if options.limit == 0 {
3708 return Err(QueryError::InvalidPagination);
3709 }
3710 let all = index.decision_gaps(options.view, options.kind, options.runner, options.file)?;
3711 if (options.kind.is_some() || options.runner.is_some()) && all.is_empty() {
3712 if index
3715 .file_gaps(options.view, options.kind, options.runner)?
3716 .is_empty()
3717 {
3718 return Err(QueryError::TestFilterEmpty {
3719 kind: options.kind.map(str::to_owned),
3720 runner: options.runner.map(str::to_owned),
3721 });
3722 }
3723 }
3724 let totals = DecisionGapTotals {
3725 decisions: all.len(),
3726 decisions_with_missing_conditions: all
3727 .iter()
3728 .filter(|decision| decision.missing_conditions > 0)
3729 .count(),
3730 conditions: all.iter().map(|decision| decision.conditions).sum(),
3731 missing_conditions: all.iter().map(|decision| decision.missing_conditions).sum(),
3732 };
3733 let mut missing = all
3734 .into_iter()
3735 .filter(|decision| decision.missing_conditions > 0)
3736 .collect::<Vec<_>>();
3737 missing.sort_by(|left, right| match options.sort {
3738 DecisionSort::Missing => right
3739 .missing_conditions
3740 .cmp(&left.missing_conditions)
3741 .then_with(|| left.line.cmp(&right.line))
3742 .then_with(|| left.column.cmp(&right.column)),
3743 DecisionSort::Location => left
3744 .line
3745 .cmp(&right.line)
3746 .then_with(|| left.column.cmp(&right.column))
3747 .then_with(|| left.id.cmp(&right.id)),
3748 });
3749 let total = missing.len();
3750 let rows = missing
3751 .into_iter()
3752 .skip(options.offset)
3753 .take(options.limit)
3754 .collect::<Vec<_>>();
3755 let selected = if options.kind.is_some() || options.runner.is_some() {
3759 selected_test_ids(
3760 &index.test_summaries(options.view)?,
3761 options.kind,
3762 options.runner,
3763 )?
3764 } else {
3765 None
3766 };
3767 let details = index
3768 .decision_details_in_file(options.view, options.file)?
3769 .into_iter()
3770 .map(|decision| (decision.meta.id.clone(), decision))
3771 .collect::<HashMap<_, _>>();
3772 let rows = rows
3773 .into_iter()
3774 .map(|gap| {
3775 let missing = details
3776 .get(&gap.id)
3777 .map(|decision| {
3778 let filtered = selected_decision(decision.clone(), selected.as_ref());
3779 let observed = filtered
3780 .vector_observations
3781 .iter()
3782 .map(|observation| &observation.vector)
3783 .collect::<Vec<_>>();
3784 let sources = filtered
3785 .conditions
3786 .iter()
3787 .map(|condition| condition.source.as_str())
3788 .collect::<Vec<_>>();
3789 filtered
3790 .conditions
3791 .iter()
3792 .filter(|condition| !condition.covered)
3793 .map(|condition| {
3794 let (needed_vector, needs) =
3795 needed(condition.index, &observed, &sources, &decision.meta.source);
3796 CoverageMissingCondition {
3797 index: condition.index,
3798 condition: condition.source.clone(),
3799 missing_outcomes: missing_outcomes(condition.index, &observed),
3800 needed_vector,
3801 needs,
3802 }
3803 })
3804 .collect()
3805 })
3806 .unwrap_or_default();
3807 CoverageFileDecisionRow { gap, missing }
3808 })
3809 .collect::<Vec<_>>();
3810 let returned = rows.len();
3811 let filters = CoverageQueryFilters {
3812 outcome: match options.view {
3813 CoverageViewId::All => "all",
3814 CoverageViewId::Passed => "passed",
3815 CoverageViewId::Failed => "failed",
3816 }
3817 .into(),
3818 kind: options.kind.map(str::to_owned),
3819 runner: options.runner.map(str::to_owned),
3820 };
3821 Ok((
3822 CoverageFileDecisionsData {
3823 run: options.run.into(),
3824 filters,
3825 file: options.file.into(),
3826 group: "decision".into(),
3827 sort: options.sort,
3828 totals,
3829 decisions: rows,
3830 },
3831 pagination(options.offset, options.limit, returned, total),
3832 ))
3833}
3834
3835pub fn coverage_file_query(
3836 index: &CoverageIndex<'_>,
3837 options: CoverageFileQueryOptions<'_>,
3838) -> Result<CoverageFileQueryResult, QueryError> {
3839 let CoverageFileQueryOptions {
3840 run,
3841 view,
3842 metric,
3843 gaps_only,
3844 kind,
3845 runner,
3846 offset,
3847 limit,
3848 } = options;
3849 if limit == 0 {
3850 return Err(QueryError::InvalidPagination);
3851 }
3852 let mut files = index.file_gaps(view, kind, runner)?;
3853 if (kind.is_some() || runner.is_some()) && files.is_empty() {
3854 return Err(QueryError::TestFilterEmpty {
3855 kind: kind.map(str::to_owned),
3856 runner: runner.map(str::to_owned),
3857 });
3858 }
3859 if gaps_only {
3860 files.retain(|gap| has_gap_for_metric(gap, metric) || gap.measurement_limitations > 0);
3861 }
3862 files.sort_by(|left, right| {
3863 gap_metric_value(right, metric)
3864 .cmp(&gap_metric_value(left, metric))
3865 .then_with(|| {
3866 right
3867 .measurement_limitations
3868 .cmp(&left.measurement_limitations)
3869 })
3870 .then_with(|| left.file.cmp(&right.file))
3871 });
3872 let total = files.len();
3873 let page = files
3874 .into_iter()
3875 .skip(offset)
3876 .take(limit)
3877 .collect::<Vec<_>>();
3878 let page_info = pagination(offset, limit, page.len(), total);
3879 let filters = CoverageQueryFilters {
3880 outcome: match view {
3881 CoverageViewId::All => "all",
3882 CoverageViewId::Passed => "passed",
3883 CoverageViewId::Failed => "failed",
3884 }
3885 .into(),
3886 kind: kind.map(str::to_owned),
3887 runner: runner.map(str::to_owned),
3888 };
3889 let data = if gaps_only {
3890 CoverageFileQueryData::Gaps(CoverageGapsData {
3891 run: run.into(),
3892 filters,
3893 metric,
3894 gaps: page,
3895 })
3896 } else {
3897 CoverageFileQueryData::Files(CoverageFilesData {
3898 run: run.into(),
3899 filters,
3900 metric,
3901 files: page,
3902 })
3903 };
3904 Ok(CoverageFileQueryResult {
3905 data,
3906 pagination: page_info,
3907 })
3908}
3909
3910#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
3911enum ObligationMetric {
3912 Lines,
3913 Statements,
3914 Functions,
3915 Branches,
3916 Mcdc,
3917}
3918
3919impl ObligationMetric {
3920 fn selected(self, metric: MinimizeMetric) -> bool {
3921 metric == MinimizeMetric::All
3922 || matches!(
3923 (self, metric),
3924 (Self::Lines, MinimizeMetric::Lines)
3925 | (Self::Statements, MinimizeMetric::Statements)
3926 | (Self::Functions, MinimizeMetric::Functions)
3927 | (Self::Branches, MinimizeMetric::Branches)
3928 | (Self::Mcdc, MinimizeMetric::Mcdc)
3929 )
3930 }
3931
3932 fn public(self) -> MinimizeMetric {
3933 match self {
3934 Self::Lines => MinimizeMetric::Lines,
3935 Self::Statements => MinimizeMetric::Statements,
3936 Self::Functions => MinimizeMetric::Functions,
3937 Self::Branches => MinimizeMetric::Branches,
3938 Self::Mcdc => MinimizeMetric::Mcdc,
3939 }
3940 }
3941}
3942
3943#[derive(Clone)]
3944struct Obligation {
3945 id: String,
3946 metric: ObligationMetric,
3947 options: Vec<Vec<String>>,
3949}
3950
3951struct ObligationModel {
3952 obligations: Vec<Obligation>,
3953 setup_by_file: BTreeMap<String, Vec<String>>,
3954 tests_by_file: BTreeMap<String, Vec<String>>,
3955 background: Vec<String>,
3956}
3957
3958impl ObligationModel {
3959 fn expand(&self, selected: &BTreeSet<String>) -> BTreeSet<String> {
3960 let mut expanded = selected.clone();
3961 expanded.extend(self.background.iter().cloned());
3962 for (file, setup_ids) in &self.setup_by_file {
3963 if self
3964 .tests_by_file
3965 .get(file)
3966 .is_some_and(|tests| tests.iter().any(|id| selected.contains(id)))
3967 {
3968 expanded.extend(setup_ids.iter().cloned());
3969 }
3970 }
3971 expanded
3972 }
3973}
3974
3975fn deduplicate_options(options: impl IntoIterator<Item = Vec<String>>) -> Vec<Vec<String>> {
3976 let mut unique = BTreeMap::<String, Vec<String>>::new();
3977 for mut option in options {
3978 option.sort();
3979 option.dedup();
3980 let key = option.join("\0");
3981 unique.entry(key).or_insert(option);
3982 }
3983 unique.into_values().collect()
3984}
3985
3986fn evidence_choices(
3987 ids: &[Id],
3988 tests: &HashMap<&str, &crate::coverage_report::TestCoverageResult>,
3989 candidates: &BTreeSet<String>,
3990 tests_by_file: &BTreeMap<String, Vec<String>>,
3991) -> Vec<Vec<String>> {
3992 let mut choices = Vec::new();
3993 for id in ids {
3994 let Some(test) = tests.get(id.as_str()) else {
3995 continue;
3996 };
3997 if test.role == "background" {
3998 choices.push(Vec::new());
3999 } else if test.role == "setup" {
4000 if let Some(file) = &test.file {
4001 choices.extend(
4002 tests_by_file
4003 .get(file)
4004 .into_iter()
4005 .flatten()
4006 .map(|candidate| vec![candidate.clone()]),
4007 );
4008 }
4009 } else if candidates.contains(id.as_str()) {
4010 choices.push(vec![id.to_string()]);
4011 }
4012 }
4013 deduplicate_options(choices)
4014}
4015
4016fn build_obligations(view: &CoverageView, candidates: &BTreeSet<String>) -> ObligationModel {
4017 let tests = view
4018 .tests
4019 .iter()
4020 .map(|test| (test.id.as_str(), test))
4021 .collect::<HashMap<_, _>>();
4022 let mut tests_by_file = BTreeMap::<String, Vec<String>>::new();
4023 let mut setup_by_file = BTreeMap::<String, Vec<String>>::new();
4024 let mut background = Vec::new();
4025 for test in &view.tests {
4026 match test.role.as_str() {
4027 "test" if candidates.contains(&test.id) => {
4028 if let Some(file) = &test.file {
4029 tests_by_file
4030 .entry(file.clone())
4031 .or_default()
4032 .push(test.id.clone());
4033 }
4034 }
4035 "setup" => {
4036 if let Some(file) = &test.file {
4037 setup_by_file
4038 .entry(file.clone())
4039 .or_default()
4040 .push(test.id.clone());
4041 }
4042 }
4043 "background" => background.push(test.id.clone()),
4044 _ => {}
4045 }
4046 }
4047 let choices = |ids: &[Id]| evidence_choices(ids, &tests, candidates, &tests_by_file);
4048 let mut obligations = Vec::new();
4049 let mut unique_lines = BTreeMap::new();
4050 for line in &view.lines {
4051 unique_lines.insert((line.file.as_str(), line.line), line);
4052 }
4053 for ((file, line), result) in unique_lines {
4054 obligations.push(Obligation {
4055 id: format!("line:{file}:{line}"),
4056 metric: ObligationMetric::Lines,
4057 options: choices(&result.tests),
4058 });
4059 }
4060 for point in &view.points {
4061 let (kind, metric) = match point.meta.kind {
4062 crate::coverage_analysis::PointKind::Statement => {
4063 ("statement", ObligationMetric::Statements)
4064 }
4065 crate::coverage_analysis::PointKind::Function => {
4066 ("function", ObligationMetric::Functions)
4067 }
4068 };
4069 obligations.push(Obligation {
4070 id: format!("{kind}:{}", point.meta.id),
4071 metric,
4072 options: choices(&point.tests),
4073 });
4074 }
4075 for branch in &view.branches {
4076 for alternative in &branch.alternatives {
4077 obligations.push(Obligation {
4078 id: format!("branch:{}:{}", branch.meta.id, alternative.id),
4079 metric: ObligationMetric::Branches,
4080 options: choices(&alternative.tests),
4081 });
4082 }
4083 }
4084 for decision in &view.decisions {
4085 for condition in 0..decision.meta.conditions.len() {
4086 let mut options = Vec::new();
4087 for left in 0..decision.vector_observations.len() {
4088 for right in (left + 1)..decision.vector_observations.len() {
4089 let first = &decision.vector_observations[left];
4090 let second = &decision.vector_observations[right];
4091 if !is_independence_pair(&first.vector, &second.vector, condition) {
4092 continue;
4093 }
4094 for first_choice in choices(&first.tests) {
4095 for second_choice in choices(&second.tests) {
4096 let mut combined = first_choice.clone();
4097 combined.extend(second_choice);
4098 options.push(combined);
4099 }
4100 }
4101 }
4102 }
4103 obligations.push(Obligation {
4104 id: format!("mcdc:{}:{condition}", decision.meta.id),
4105 metric: ObligationMetric::Mcdc,
4106 options: deduplicate_options(options),
4107 });
4108 }
4109 }
4110 ObligationModel {
4111 obligations,
4112 setup_by_file,
4113 tests_by_file,
4114 background,
4115 }
4116}
4117
4118fn percentage(metric: ObligationMetric, summary: &CoverageSummary) -> f64 {
4119 match metric {
4120 ObligationMetric::Lines => summary.lines.percentage,
4121 ObligationMetric::Statements => summary.statements.percentage,
4122 ObligationMetric::Functions => summary.functions.percentage,
4123 ObligationMetric::Branches => summary.branches.percentage,
4124 ObligationMetric::Mcdc => summary.condition_coverage_pct,
4125 }
4126}
4127
4128fn obligation_satisfied(obligation: &Obligation, selected: &BTreeSet<String>) -> bool {
4129 obligation
4130 .options
4131 .iter()
4132 .any(|option| option.iter().all(|test| selected.contains(test)))
4133}
4134
4135struct Search<'a> {
4136 obligations: &'a [Obligation],
4137 skip_limits: BTreeMap<ObligationMetric, usize>,
4138 best: BTreeSet<String>,
4139 explored_states: usize,
4140 max_states: usize,
4141 seen: BTreeSet<String>,
4142 candidate_tests: usize,
4143 target: f64,
4144 metric: MinimizeMetric,
4145}
4146
4147impl Search<'_> {
4148 fn visit(
4149 &mut self,
4150 selected: BTreeSet<String>,
4151 skipped: BTreeSet<String>,
4152 skipped_by_metric: BTreeMap<ObligationMetric, usize>,
4153 ) -> Result<(), QueryError> {
4154 self.explored_states += 1;
4155 if self.explored_states > self.max_states {
4156 return Err(QueryError::ComplexityLimit {
4157 candidate_tests: self.candidate_tests,
4158 obligations: self.obligations.len(),
4159 explored_states: self.explored_states,
4160 max_states: self.max_states,
4161 target: self.target,
4162 metric: self.metric,
4163 });
4164 }
4165 if selected.len() >= self.best.len() {
4166 return Ok(());
4167 }
4168 let state_key = format!(
4169 "{}|{}",
4170 selected.iter().cloned().collect::<Vec<_>>().join(","),
4171 skipped.iter().cloned().collect::<Vec<_>>().join(",")
4172 );
4173 if !self.seen.insert(state_key) {
4174 return Ok(());
4175 }
4176 let mut unmet = self
4177 .obligations
4178 .iter()
4179 .filter(|obligation| {
4180 !skipped.contains(&obligation.id) && !obligation_satisfied(obligation, &selected)
4181 })
4182 .collect::<Vec<_>>();
4183 if unmet.is_empty() {
4184 self.best = selected;
4185 return Ok(());
4186 }
4187 unmet.sort_by(|left, right| {
4188 let feasible = |obligation: &Obligation| {
4189 obligation
4190 .options
4191 .iter()
4192 .filter(|option| option.iter().any(|test| !selected.contains(test)))
4193 .count()
4194 };
4195 feasible(left)
4196 .cmp(&feasible(right))
4197 .then_with(|| left.id.cmp(&right.id))
4198 });
4199 let obligation = unmet[0];
4200 let mut additions = deduplicate_options(obligation.options.iter().filter_map(|option| {
4201 let addition = option
4202 .iter()
4203 .filter(|test| !selected.contains(*test))
4204 .cloned()
4205 .collect::<Vec<_>>();
4206 (!addition.is_empty()).then_some(addition)
4207 }));
4208 additions.sort_by(|left, right| {
4209 left.len()
4210 .cmp(&right.len())
4211 .then_with(|| left.join("\0").cmp(&right.join("\0")))
4212 });
4213 for addition in additions {
4214 if selected.len() + addition.len() >= self.best.len() {
4215 continue;
4216 }
4217 let mut next = selected.clone();
4218 next.extend(addition);
4219 self.visit(next, skipped.clone(), skipped_by_metric.clone())?;
4220 }
4221 let skipped_count = skipped_by_metric
4222 .get(&obligation.metric)
4223 .copied()
4224 .unwrap_or(0);
4225 if skipped_count
4226 < self
4227 .skip_limits
4228 .get(&obligation.metric)
4229 .copied()
4230 .unwrap_or(0)
4231 {
4232 let mut next_skipped = skipped;
4233 next_skipped.insert(obligation.id.clone());
4234 let mut next_counts = skipped_by_metric;
4235 next_counts.insert(obligation.metric, skipped_count + 1);
4236 self.visit(selected, next_skipped, next_counts)?;
4237 }
4238 Ok(())
4239 }
4240}
4241
4242pub fn minimum_test_set(
4243 view: &CoverageView,
4244 target: f64,
4245 metric: MinimizeMetric,
4246 max_states: usize,
4247) -> Result<MinimumTestSetResult, QueryError> {
4248 use crate::coverage_report::coverage_is_complete;
4249 if !target.is_finite() || !(0.0..=100.0).contains(&target) {
4250 return Err(QueryError::InvalidTarget(target));
4251 }
4252 if view.tests.iter().any(|test| {
4253 test.role == "background" && (!test.hits.is_empty() || !test.decisions.is_empty())
4254 }) {
4255 return Err(QueryError::UnattributedEvidence);
4256 }
4257 if view
4264 .tests
4265 .iter()
4266 .any(|test| test.role == "test" && !coverage_is_complete(&test.attribution))
4267 {
4268 return Err(QueryError::UnattributedEvidence);
4269 }
4270 let candidate_tests = view
4271 .tests
4272 .iter()
4273 .filter(|test| test.role == "test")
4274 .map(|test| test.id.clone())
4275 .collect::<BTreeSet<_>>();
4276 let model = build_obligations(view, &candidate_tests);
4277 let obligations = model
4278 .obligations
4279 .iter()
4280 .filter(|obligation| obligation.metric.selected(metric))
4281 .cloned()
4282 .collect::<Vec<_>>();
4283 let mut totals = BTreeMap::<ObligationMetric, usize>::new();
4284 for obligation in &obligations {
4285 *totals.entry(obligation.metric).or_default() += 1;
4286 }
4287 let metrics = [
4288 ObligationMetric::Lines,
4289 ObligationMetric::Statements,
4290 ObligationMetric::Functions,
4291 ObligationMetric::Branches,
4292 ObligationMetric::Mcdc,
4293 ]
4294 .into_iter()
4295 .filter(|candidate| candidate.selected(metric))
4296 .collect::<Vec<_>>();
4297 let skip_limits = metrics
4298 .iter()
4299 .map(|selected_metric| {
4300 let total = totals.get(selected_metric).copied().unwrap_or(0);
4301 let required = ((total as f64 * target) / 100.0).ceil() as usize;
4302 (*selected_metric, total.saturating_sub(required))
4303 })
4304 .collect::<BTreeMap<_, _>>();
4305 let full_expanded = model.expand(&candidate_tests);
4306 let full_summary = coverage_summary_for_tests(view, &full_expanded)?;
4307 for selected_metric in &metrics {
4308 let reachable = percentage(*selected_metric, &full_summary);
4309 if reachable + 1e-9 < target {
4310 return Err(QueryError::TargetUnreachable {
4311 metric: selected_metric.public(),
4312 target,
4313 reachable,
4314 });
4315 }
4316 }
4317 let mut search = Search {
4318 obligations: &obligations,
4319 skip_limits,
4320 best: candidate_tests.clone(),
4321 explored_states: 0,
4322 max_states,
4323 seen: BTreeSet::new(),
4324 candidate_tests: candidate_tests.len(),
4325 target,
4326 metric,
4327 };
4328 search.visit(BTreeSet::new(), BTreeSet::new(), BTreeMap::new())?;
4329 let expanded = model.expand(&search.best);
4330 let summary = coverage_summary_for_tests(view, &expanded)?;
4331 Ok(MinimumTestSetResult {
4332 optimal: true,
4333 target,
4334 metric,
4335 selected: search.best.into_iter().collect(),
4336 expanded: expanded.into_iter().collect(),
4337 summary,
4338 explored_states: search.explored_states,
4339 })
4340}
4341
4342#[cfg(test)]
4343mod tests {
4344 use crate::{
4345 coverage_analysis::McdcVector,
4346 coverage_report::{
4347 CoverageManifest, CoverageReport, CoverageReportRequest, DecisionMeta, ExitCodeInput,
4348 RawTestResult, RuntimeSnapshot, TestProvenance, analyze_coverage_results,
4349 },
4350 };
4351
4352 use super::*;
4353
4354 fn scope_entry(
4355 file: &str,
4356 status: &str,
4357 reason: &str,
4358 package: Option<&str>,
4359 ) -> IndexedScopeEntry {
4360 IndexedScopeEntry {
4361 file: file.into(),
4362 status: status.into(),
4363 reason: reason.into(),
4364 package_root: package.map(str::to_owned),
4365 measurement_limitations: 0,
4366 limitation_kinds: Vec::new(),
4367 }
4368 }
4369
4370 #[test]
4371 fn a_needed_run_is_one_the_decision_can_produce() {
4372 use crate::coverage_analysis::McdcVector;
4373 let vector = |values: &str, outcome: bool| McdcVector {
4374 values: values
4375 .chars()
4376 .map(|value| match value {
4377 'T' => Some(true),
4378 'F' => Some(false),
4379 _ => None,
4380 })
4381 .collect(),
4382 outcome,
4383 };
4384 let seen = [vector("FFFFT", true)];
4387 let observed = seen.iter().collect::<Vec<_>>();
4388 let sources = ["a", "b", "c", "d", "e"];
4389 let (values, words) = needed(0, &observed, &sources, "a || b || c || d || e");
4390 assert_eq!(
4391 values.as_deref(),
4392 Some("FFFFF -> F and T---- -> T"),
4393 "{words}"
4394 );
4395 let (values, _) = needed(4, &observed, &sources, "a || b || c || d || e");
4396 assert_eq!(values.as_deref(), Some("FFFFF -> F"));
4397 let seen = [vector("T-", true), vector("FT", true)];
4399 let observed = seen.iter().collect::<Vec<_>>();
4400 let (values, words) = needed(0, &observed, &["a", "b"], "a || b");
4401 assert_eq!(values.as_deref(), Some("FF -> F"));
4402 assert_eq!(words, "`a` false, `b` false, decision false");
4403 let (values, words) = needed(0, &observed, &["a", "b"], "pick(a, b)");
4405 assert_eq!(values, None);
4406 assert!(words.contains("alone changes the decision"), "{words}");
4407 }
4408
4409 #[test]
4410 fn a_scope_is_grouped_by_place_and_names_the_roots_that_settle_it() {
4411 let unclassified = "unclassified first-party source";
4412 let entries = [
4413 scope_entry("components/a.tsx", "ambiguous", unclassified, None),
4414 scope_entry("components/ui/b.tsx", "ambiguous", unclassified, None),
4415 scope_entry("proxy.ts", "ambiguous", unclassified, None),
4416 scope_entry(
4417 "app/page.tsx",
4418 "included",
4419 "discovered package source root",
4420 None,
4421 ),
4422 scope_entry(
4423 "tests/a.test.ts",
4424 "excluded",
4425 "test or fixture source",
4426 None,
4427 ),
4428 scope_entry(
4429 "next.config.ts",
4430 "excluded",
4431 "build/test/tool configuration",
4432 None,
4433 ),
4434 scope_entry(
4435 "packages/ui/extra/c.ts",
4436 "ambiguous",
4437 unclassified,
4438 Some("packages/ui"),
4439 ),
4440 scope_entry(
4441 "packages/ui/src/d.ts",
4442 "included",
4443 "discovered package source root",
4444 Some("packages/ui"),
4445 ),
4446 ];
4447 let groups = scope_groups(&entries)
4448 .into_iter()
4449 .map(|group| (group.files, group.status, group.directory))
4450 .collect::<Vec<_>>();
4451 assert_eq!(
4452 groups,
4453 [
4454 (2, "ambiguous".to_owned(), "components".to_owned()),
4455 (1, "ambiguous".into(), ".".into()),
4456 (1, "ambiguous".into(), "packages/ui/extra".into()),
4457 (1, "included".into(), "app".into()),
4458 (1, "included".into(), "packages/ui/src".into()),
4459 (1, "excluded".into(), ".".into()),
4460 (1, "excluded".into(), "tests".into()),
4461 ]
4462 );
4463 let hint =
4466 unclassified_source(&["app".into(), "packages/ui/src".into()], &entries).unwrap();
4467 assert_eq!(
4468 hint.roots,
4469 [
4470 "app",
4471 "packages/ui/src",
4472 "components",
4473 "packages/ui/extra",
4474 "proxy.ts"
4475 ]
4476 );
4477 assert_eq!(hint.directories[0].directory, "components");
4478 assert_eq!(hint.directories[0].files, 2);
4479 assert_eq!(unclassified_source(&[], &entries[3..6]), None);
4480 }
4481
4482 #[test]
4483 fn a_file_is_counted_under_its_first_folders() {
4484 assert_eq!(area_directory("app/api/cron/route.ts", 1), "app");
4485 assert_eq!(area_directory("app/api/cron/route.ts", 2), "app/api");
4486 assert_eq!(area_directory("app/page.tsx", 2), "app");
4487 assert_eq!(area_directory("proxy.ts", 2), ".");
4488 }
4489
4490 #[test]
4491 fn all_metric_keeps_statement_only_files_in_the_gap_set() {
4492 let gap = IndexedFileGap {
4493 view: CoverageViewId::All,
4494 file: "src/statement.js".into(),
4495 uncovered_lines: 0,
4496 uncovered_statements: 1,
4497 uncovered_functions: 0,
4498 missing_branches: 0,
4499 missing_mcdc_conditions: 0,
4500 measurement_limitations: 0,
4501 limitation_kinds: Vec::new(),
4502 covered_by_other_tests: crate::coverage_index::IndexedGapDimensions {
4503 lines: 0,
4504 statements: 0,
4505 functions: 0,
4506 branches: 0,
4507 mcdc_conditions: 0,
4508 },
4509 uncovered_everywhere: crate::coverage_index::IndexedGapDimensions {
4510 lines: 0,
4511 statements: 1,
4512 functions: 0,
4513 branches: 0,
4514 mcdc_conditions: 0,
4515 },
4516 totals: crate::coverage_index::IndexedGapDimensions {
4517 lines: 1,
4518 statements: 2,
4519 functions: 0,
4520 branches: 0,
4521 mcdc_conditions: 0,
4522 },
4523 covered: crate::coverage_index::IndexedGapDimensions {
4524 lines: 1,
4525 statements: 1,
4526 functions: 0,
4527 branches: 0,
4528 mcdc_conditions: 0,
4529 },
4530 score: 0,
4531 };
4532 assert!(has_gap_for_metric(&gap, MinimizeMetric::All));
4533 assert!(has_gap_for_metric(&gap, MinimizeMetric::Statements));
4534 assert!(!has_gap_for_metric(&gap, MinimizeMetric::Lines));
4535 }
4536
4537 fn result(id: &str, vector: McdcVector) -> RawTestResult {
4538 RawTestResult {
4539 span: None,
4540 test_id: Some(id.into()),
4541 scope: None,
4542 test: id.into(),
4543 test_file: Some("tests/permission.test.js".into()),
4544 title: None,
4545 retry: Some(0),
4546 status: Some("passed".into()),
4547 expected_status: None,
4548 flaky: false,
4549 provenance: TestProvenance {
4550 runner: "node:test".into(),
4551 kind: "unit".into(),
4552 project: None,
4553 source: "runner-default".into(),
4554 tags: Vec::new(),
4555 },
4556 role: "test".into(),
4557 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
4558 phases: Vec::new(),
4559 runtime: vec![RuntimeSnapshot {
4560 decisions: vec![crate::coverage_report::DecisionSnapshot {
4561 meta: decision(),
4562 vectors: vec![vector],
4563 }],
4564 hits: Vec::new(),
4565 events: Vec::new(),
4566 logicals: Vec::new(),
4567 phase_id: None,
4568 }],
4569 browser: Vec::new(),
4570 server: Vec::new(),
4571 }
4572 }
4573
4574 fn decision() -> DecisionMeta {
4575 DecisionMeta {
4576 id: "decision".into(),
4577 file: "src/permission.js".into(),
4578 line: 1,
4579 column: 1,
4580 source: "admin || owner".into(),
4581 conditions: vec!["admin".into(), "owner".into()],
4582 kind: "if".into(),
4583 }
4584 }
4585
4586 fn report(mut results: Vec<RawTestResult>) -> CoverageReport {
4587 analyze_coverage_results(&CoverageReportRequest {
4588 run_id: "run".into(),
4589 manifest: CoverageManifest {
4590 unmeasured: Vec::new(),
4591 decisions: vec![decision()],
4592 points: Vec::new(),
4593 branches: Vec::new(),
4594 limitations: Vec::new(),
4595 scope: None,
4596 assertion_sites: Vec::new(),
4597 },
4598 raw_results: std::mem::take(&mut results),
4599 generated_at: "time".into(),
4600 coverage_model: None,
4601 integrity: None,
4602 test_exit_code: ExitCodeInput::Present(Some(0)),
4603 })
4604 .unwrap()
4605 }
4606
4607 #[test]
4608 fn recomputes_mcdc_witnesses_and_removes_a_redundant_vector() {
4609 let report = report(vec![
4610 result(
4611 "admin",
4612 McdcVector {
4613 values: vec![Some(true), None],
4614 outcome: true,
4615 },
4616 ),
4617 result(
4618 "owner",
4619 McdcVector {
4620 values: vec![Some(false), Some(true)],
4621 outcome: true,
4622 },
4623 ),
4624 result(
4625 "both",
4626 McdcVector {
4627 values: vec![Some(true), None],
4628 outcome: true,
4629 },
4630 ),
4631 result(
4632 "neither",
4633 McdcVector {
4634 values: vec![Some(false), Some(false)],
4635 outcome: false,
4636 },
4637 ),
4638 ]);
4639 let minimized = minimum_test_set(&report.view, 100.0, MinimizeMetric::Mcdc, 5_000).unwrap();
4640 assert_eq!(minimized.selected.len(), 3);
4641 assert!(minimized.selected.contains(&"owner".into()));
4642 assert!(minimized.selected.contains(&"neither".into()));
4643 assert_eq!(minimized.summary.condition_coverage_pct, 100.0);
4644 }
4645
4646 #[test]
4647 fn a_test_that_belongs_to_no_test_is_not_minimized_away() {
4648 let mut parallel = result(
4655 "parallel",
4656 McdcVector {
4657 values: vec![Some(false), Some(false)],
4658 outcome: false,
4659 },
4660 );
4661 parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4662 assert!(matches!(
4663 minimum_test_set(
4664 &report(vec![parallel]).view,
4665 100.0,
4666 MinimizeMetric::Mcdc,
4667 5_000,
4668 ),
4669 Err(QueryError::UnattributedEvidence)
4670 ));
4671 }
4672
4673 #[test]
4674 fn the_summary_counts_each_test_under_the_attribution_it_carries() {
4675 let vector = || McdcVector {
4679 values: vec![Some(false), Some(false)],
4680 outcome: false,
4681 };
4682 let mut serial = result("serial", vector());
4683 serial.attribution = crate::coverage_report::ATTRIBUTION_EXACT.into();
4684 let mut parallel = result("parallel", vector());
4685 parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4686 let mut ruby = result("ruby", vector());
4687 ruby.attribution = crate::coverage_report::ATTRIBUTION_PARTIAL.into();
4688 let mut also_parallel = result("also-parallel", vector());
4689 also_parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
4690 let report = report(vec![serial, parallel, ruby, also_parallel]);
4691
4692 let nonce = std::time::SystemTime::now()
4693 .duration_since(std::time::UNIX_EPOCH)
4694 .unwrap()
4695 .as_nanos();
4696 let root = std::env::temp_dir().join(format!(
4697 "supercov-attribution-counts-{}-{nonce}",
4698 std::process::id()
4699 ));
4700 std::fs::create_dir(&root).unwrap();
4701 let path = root.join("query-index.v1.bin");
4702 let identity = crate::query_index::QueryIndexIdentity {
4703 evidence_sha256: [1; 32],
4704 evidence_bytes: 100,
4705 analysis_sha256: [2; 32],
4706 producer_sha256: [3; 32],
4707 archive_schema_version: 2,
4708 };
4709 crate::query_index::write_query_index(
4710 &crate::coverage_index::coverage_index_sections(&report).unwrap(),
4711 &identity,
4712 &path,
4713 )
4714 .unwrap();
4715 let container = crate::query_index::QueryIndex::open(&path, &identity).unwrap();
4716 let index = crate::coverage_index::CoverageIndex::new(&container).unwrap();
4717
4718 let summary = coverage_summary_query(
4719 &index,
4720 CoverageSummaryQueryOptions {
4721 run: "run",
4722 view: CoverageViewId::All,
4723 kind: None,
4724 runner: None,
4725 valid: true,
4726 test_exit_code: Some(0),
4727 stale: false,
4728 stale_reasons: Vec::new(),
4729 },
4730 )
4731 .unwrap();
4732 assert_eq!(summary.test_attribution.exact, 1);
4733 assert_eq!(summary.test_attribution.run_wide, 2);
4734 assert_eq!(summary.test_attribution.partial, 1);
4735 assert_eq!(summary.test_attribution.total(), 4);
4736 std::fs::remove_dir_all(&root).ok();
4737 }
4738
4739 #[test]
4740 fn refuses_background_evidence() {
4741 let mut aggregate = result(
4742 "aggregate",
4743 McdcVector {
4744 values: vec![Some(false), Some(false)],
4745 outcome: false,
4746 },
4747 );
4748 aggregate.role = "background".into();
4749 assert!(matches!(
4750 minimum_test_set(
4751 &report(vec![aggregate]).view,
4752 100.0,
4753 MinimizeMetric::Mcdc,
4754 5_000,
4755 ),
4756 Err(QueryError::UnattributedEvidence)
4757 ));
4758 }
4759
4760 #[test]
4761 fn bounds_the_exact_search() {
4762 let report = report(vec![
4763 result(
4764 "admin",
4765 McdcVector {
4766 values: vec![Some(true), None],
4767 outcome: true,
4768 },
4769 ),
4770 result(
4771 "owner",
4772 McdcVector {
4773 values: vec![Some(false), Some(true)],
4774 outcome: true,
4775 },
4776 ),
4777 result(
4778 "neither",
4779 McdcVector {
4780 values: vec![Some(false), Some(false)],
4781 outcome: false,
4782 },
4783 ),
4784 ]);
4785 assert!(matches!(
4786 minimum_test_set(&report.view, 100.0, MinimizeMetric::Mcdc, 1),
4787 Err(QueryError::ComplexityLimit { .. })
4788 ));
4789 }
4790}