1use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value, json};
9
10use crate::{
11 agent_json::{self, ResponseTooLarge},
12 coverage_index::{CoverageDimension, CoverageIndex, CoverageViewId},
13 coverage_query::{
14 CoverageCoversData, CoverageCoversQueryOptions, CoverageDecisionData,
15 CoverageDecisionQueryOptions, CoverageDiffData, CoverageDiffQueryOptions,
16 CoverageDimensionQueryData, CoverageDimensionQueryOptions, CoverageFileDecisionsData,
17 CoverageFileDecisionsOptions, CoverageFileDetailData, CoverageFileDetailOptions,
18 CoverageFileQueryData, CoverageFileQueryOptions, CoverageFilesData, CoverageGapsData,
19 CoverageKindsData, CoverageMinimizeData, CoverageMinimizeQueryOptions,
20 CoverageQueryFilters, CoverageRunnersData, CoverageScopeData, CoverageScopeQueryOptions,
21 CoverageSummaryData, CoverageSummaryQueryOptions, CoverageTestData,
22 CoverageTestQueryOptions, DecisionSort, MinimizeMetric, QueryError, coverage_covers_query,
23 coverage_decision_query, coverage_diff_query, coverage_dimension_query,
24 coverage_file_decisions_query, coverage_file_detail_query, coverage_file_query,
25 coverage_minimize_query, coverage_scope_query, coverage_summary_query, coverage_test_query,
26 },
27 coverage_report::CoverageReport,
28};
29
30#[derive(Debug, Clone, Deserialize)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct IndexedQueryRequest {
33 pub run_id: String,
34 pub filter: String,
35 pub command: String,
36 #[serde(default = "default_metric")]
37 pub metric: MinimizeMetric,
38 pub kind: Option<String>,
39 pub runner: Option<String>,
40 pub file: Option<String>,
41 pub line: Option<usize>,
42 pub selector: Option<String>,
43 pub sort: Option<DecisionSort>,
44 pub valid: Option<bool>,
45 pub test_exit_code: Option<i32>,
46 pub stale: Option<bool>,
47 pub stale_reasons: Option<Vec<String>>,
48 #[serde(default)]
49 pub offset: usize,
50 #[serde(default = "default_limit")]
51 pub limit: usize,
52 pub target: Option<f64>,
53 pub max_states: Option<usize>,
54 #[serde(default)]
57 pub group: Option<String>,
58 #[serde(default)]
60 pub depth: Option<usize>,
61 #[serde(default)]
64 pub gained_only: bool,
65 #[serde(default)]
68 pub more_lines: Vec<(String, usize)>,
69 #[serde(default)]
71 pub tags: Vec<String>,
72 #[serde(default)]
74 pub titles: Vec<String>,
75 #[serde(default)]
77 pub test_files: Vec<String>,
78}
79
80fn default_limit() -> usize {
81 20
82}
83
84fn default_metric() -> MinimizeMetric {
85 MinimizeMetric::All
86}
87
88impl IndexedQueryRequest {
89 pub fn test_filter(&self) -> Option<crate::coverage_report::TestFilter> {
91 let filter = crate::coverage_report::TestFilter {
92 tags: self.tags.clone(),
93 titles: self.titles.clone(),
94 files: self.test_files.clone(),
95 kind: None,
96 runner: None,
97 };
98 (!filter.is_empty()).then_some(filter)
99 }
100
101 pub fn view(&self) -> Result<CoverageViewId, IndexedQueryError> {
102 match self.filter.as_str() {
103 "all" => Ok(CoverageViewId::All),
104 "passed" => Ok(CoverageViewId::Passed),
105 "failed" => Ok(CoverageViewId::Failed),
106 _ => Err(IndexedQueryError::InvalidFilter(self.filter.clone())),
107 }
108 }
109}
110
111#[derive(Debug)]
112pub enum IndexedQueryError {
113 InvalidFilter(String),
114 UnsupportedCommand(String),
115 MissingArgument(&'static str),
116 MissingNewerRun,
117 MissingReport,
118 Query(QueryError),
119 ResponseTooLarge(ResponseTooLarge),
120 PageTooLarge {
122 size: ResponseTooLarge,
123 asked: usize,
124 fits: usize,
125 },
126}
127
128impl From<QueryError> for IndexedQueryError {
129 fn from(value: QueryError) -> Self {
130 Self::Query(value)
131 }
132}
133
134impl From<ResponseTooLarge> for IndexedQueryError {
135 fn from(value: ResponseTooLarge) -> Self {
136 Self::ResponseTooLarge(value)
137 }
138}
139
140impl std::fmt::Display for IndexedQueryError {
141 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 Self::InvalidFilter(filter) => write!(formatter, "invalid coverage filter: {filter}"),
144 Self::UnsupportedCommand(command) => {
145 write!(formatter, "unsupported indexed query: {command}")
146 }
147 Self::MissingArgument(argument) => {
148 write!(formatter, "indexed query requires {argument}")
149 }
150 Self::MissingNewerRun => write!(formatter, "indexed diff requires a newer run"),
151 Self::MissingReport => write!(
152 formatter,
153 "coverage minimization requires reconstructed per-test evidence"
154 ),
155 Self::Query(error) => write!(formatter, "{error:?}"),
156 Self::ResponseTooLarge(error) | Self::PageTooLarge { size: error, .. } => write!(
157 formatter,
158 "response is {} bytes and exceeds the {}-byte limit",
159 error.actual_bytes, error.max_bytes
160 ),
161 }
162 }
163}
164
165impl std::error::Error for IndexedQueryError {}
166
167fn grouped_decimal(value: usize) -> String {
168 let digits = value.to_string();
169 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
170 for (index, digit) in digits.bytes().enumerate() {
171 if index > 0 && (digits.len() - index).is_multiple_of(3) {
172 grouped.push(',');
173 }
174 grouped.push(char::from(digit));
175 }
176 grouped
177}
178
179fn metric_name(metric: MinimizeMetric) -> &'static str {
180 match metric {
181 MinimizeMetric::All => "all",
182 MinimizeMetric::Lines => "lines",
183 MinimizeMetric::Statements => "statements",
184 MinimizeMetric::Functions => "functions",
185 MinimizeMetric::Branches => "branches",
186 MinimizeMetric::Mcdc => "mcdc",
187 }
188}
189
190fn test_filter_details(kind: &Option<String>, runner: &Option<String>) -> (String, Value) {
191 let mut labels = Vec::new();
192 let mut details = Map::new();
193 if let Some(kind) = kind {
194 labels.push(format!("kind={kind}"));
195 details.insert("kind".into(), Value::String(kind.clone()));
196 }
197 if let Some(runner) = runner {
198 labels.push(format!("runner={runner}"));
199 details.insert("runner".into(), Value::String(runner.clone()));
200 }
201 (labels.join(", "), Value::Object(details))
202}
203
204impl IndexedQueryError {
205 pub fn agent_error(&self) -> agent_json::AgentError {
206 use agent_json::ErrorCode;
207
208 let (code, message, details) = match self {
209 Self::InvalidFilter(_) => (
210 ErrorCode::InvalidArgument,
211 "--filter must be all, passed, or failed".into(),
212 None,
213 ),
214 Self::UnsupportedCommand(command) => (
215 ErrorCode::UnknownCommand,
216 format!("Unknown coverage resource: {command}"),
217 Some(json!({ "command": command })),
218 ),
219 Self::MissingArgument(argument) => (
220 ErrorCode::InvalidArgument,
221 format!("Coverage query requires {argument}"),
222 None,
223 ),
224 Self::MissingNewerRun => (
225 ErrorCode::InvalidArgument,
226 "Diff requires an older and newer run ID".into(),
227 None,
228 ),
229 Self::MissingReport => (
230 ErrorCode::InternalError,
231 "Coverage minimization requires reconstructed per-test evidence".into(),
232 None,
233 ),
234 Self::ResponseTooLarge(error) => (
235 ErrorCode::ResponseTooLarge,
236 format!(
237 "JSON response is {} bytes; the maximum is {} bytes",
238 error.actual_bytes, error.max_bytes
239 ),
240 Some(json!({
241 "actualBytes": error.actual_bytes,
242 "maxBytes": error.max_bytes,
243 "hint": "Use --offset/--limit or a narrower coverage query."
244 })),
245 ),
246 Self::PageTooLarge { size, asked, fits } => (
249 ErrorCode::ResponseTooLarge,
250 format!(
251 "JSON response is {} bytes; the maximum is {} bytes. About {fits} of the {asked} entries asked for fit in one reply: use --limit {fits} and follow pagination.nextOffset with --offset until hasMore is false",
252 size.actual_bytes, size.max_bytes
253 ),
254 Some(json!({
255 "actualBytes": size.actual_bytes,
256 "maxBytes": size.max_bytes,
257 "suggestedLimit": fits,
258 "hint": format!("Use --limit {fits} and --offset, following pagination.nextOffset, or a narrower coverage query.")
259 })),
260 ),
261 Self::Query(error) => match error {
262 QueryError::InvalidTarget(_) => (
263 ErrorCode::InvalidArgument,
264 "--target must be between 0 and 100".into(),
265 None,
266 ),
267 QueryError::UnattributedEvidence => (
268 ErrorCode::UnattributedEvidence,
269 "Cannot minimize exactly: this coverage view contains background/unattributed evidence. Use a runner with exact test attribution or select a fully attributed coverage view.".into(),
270 None,
271 ),
272 QueryError::TargetUnreachable {
273 metric,
274 target,
275 reachable,
276 } => (
277 ErrorCode::TargetUnreachable,
278 format!(
279 "The full selected test view reaches only {reachable:.2}% {}; target {target}% is impossible",
280 metric_name(*metric)
281 ),
282 Some(json!({ "metric": metric, "target": target, "reachable": reachable })),
283 ),
284 QueryError::ComplexityLimit {
285 candidate_tests,
286 obligations,
287 explored_states,
288 max_states,
289 target,
290 metric,
291 } => (
292 ErrorCode::MinimizationComplexityLimit,
293 format!(
294 "Exact minimization exceeded its {}-state safety budget. Narrow the test view with --kind or --runner, or request a different target.",
295 grouped_decimal(*max_states)
296 ),
297 Some(json!({
298 "candidateTests": candidate_tests,
299 "obligations": obligations,
300 "exploredStates": explored_states,
301 "maxStates": max_states,
302 "target": target,
303 "metric": metric,
304 })),
305 ),
306 QueryError::InvalidPagination => (
307 ErrorCode::InvalidArgument,
308 "--limit must be a positive integer".into(),
309 None,
310 ),
311 QueryError::TestFilterEmpty { kind, runner } => {
312 let (filter, details) = test_filter_details(kind, runner);
313 (
314 ErrorCode::TestFilterEmpty,
315 format!("No tests match {filter}"),
316 Some(details),
317 )
318 }
319 QueryError::TestNotFound(selector) => (
320 ErrorCode::TestNotFound,
321 format!("Test not found: {selector}"),
322 Some(json!({ "selector": selector })),
323 ),
324 QueryError::DecisionNotFound(selector) => (
325 ErrorCode::DecisionNotFound,
326 format!("Decision not found: {selector}"),
327 Some(json!({ "selector": selector })),
328 ),
329 QueryError::SourceNotFound(selector) => (
330 ErrorCode::SourceNotFound,
331 format!("Source file not found: {selector}"),
332 Some(json!({ "selector": selector })),
333 ),
334 QueryError::AmbiguousSelector { selector, matches } => (
335 ErrorCode::AmbiguousSelector,
336 format!("Ambiguous file selector: {}", matches.join(", ")),
337 Some(json!({ "selector": selector, "matches": matches })),
338 ),
339 QueryError::ScopeUnavailable => (
340 ErrorCode::ScopeUnavailable,
341 "This run does not contain a source-scope inventory.".into(),
342 None,
343 ),
344 QueryError::Analysis(error) => (
345 ErrorCode::InternalError,
346 format!("Coverage analysis failed: {error:?}"),
347 None,
348 ),
349 QueryError::Index(error) => (
350 ErrorCode::InternalError,
351 format!("Coverage index query failed: {error}"),
352 None,
353 ),
354 QueryError::InvalidRecordSelection => (
355 ErrorCode::InternalError,
356 "Coverage index contains inconsistent references".into(),
357 None,
358 ),
359 },
360 };
361 agent_json::AgentError {
362 code,
363 message,
364 retryable: false,
365 details,
366 }
367 }
368}
369
370const BATCH_LINE_TESTS: usize = 3;
373
374fn compact_line(mut data: CoverageCoversData) -> CoverageCoversData {
377 if let CoverageCoversData::Line(line) = &mut data {
378 line.phases.clear();
379 line.confidence.tests.clear();
380 line.confidence.asserted_tests.clear();
381 }
382 data
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize)]
388#[serde(rename_all = "camelCase")]
389pub struct CoverageLinesData {
390 pub run: String,
391 pub lines: Vec<CoverageCoversData>,
392 #[serde(skip_serializing_if = "Vec::is_empty")]
395 pub remaining: Vec<String>,
396 #[serde(skip)]
399 pub pages: Vec<supercov_contracts::AgentPagination>,
400}
401
402pub struct NewerQuery<'a> {
403 pub run_id: &'a str,
404 pub index: &'a CoverageIndex<'a>,
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize)]
408#[serde(untagged)]
409pub enum IndexedQueryData {
410 Summary(Box<CoverageSummaryData>),
411 Scope(Box<CoverageScopeData>),
412 Line(Box<CoverageCoversData>),
413 Lines(Box<CoverageLinesData>),
414 Test(Box<CoverageTestData>),
415 Decision(Box<CoverageDecisionData>),
416 FileDetail(Box<CoverageFileDetailData>),
417 FileDecisions(Box<CoverageFileDecisionsData>),
418 Kinds(Box<CoverageKindsData>),
419 Runners(Box<CoverageRunnersData>),
420 Files(Box<CoverageFilesData>),
421 Areas(Box<crate::coverage_query::CoverageAreasData>),
422 Gaps(Box<CoverageGapsData>),
423 Minimize(Box<CoverageMinimizeData>),
424 Diff(Box<CoverageDiffData>),
425 TestsWithoutEvidence(Box<crate::coverage_query::TestsWithoutEvidenceData>),
426}
427
428#[derive(Debug, Clone, PartialEq)]
429pub struct IndexedQueryOutput {
430 pub command: &'static str,
431 pub data: IndexedQueryData,
432 pub pagination: Option<supercov_contracts::AgentPagination>,
433}
434
435fn entries_that_fit(returned: usize, size: &ResponseTooLarge) -> usize {
438 (returned * size.max_bytes * 9 / (size.actual_bytes * 10)).max(1)
439}
440
441impl IndexedQueryOutput {
442 pub fn agent_json(&self) -> Result<String, IndexedQueryError> {
443 agent_json::success(self.command, &self.data, self.pagination.as_ref()).map_err(|size| {
444 match &self.pagination {
445 Some(page) if page.returned > 1 => IndexedQueryError::PageTooLarge {
446 asked: page.returned,
447 fits: entries_that_fit(page.returned, &size),
448 size,
449 },
450 _ => IndexedQueryError::ResponseTooLarge(size),
451 }
452 })
453 }
454}
455
456pub fn execute_indexed_query(
461 index: &CoverageIndex<'_>,
462 report: Option<&CoverageReport>,
463 request: &IndexedQueryRequest,
464 newer: Option<NewerQuery<'_>>,
465) -> Result<String, IndexedQueryError> {
466 query_indexed(index, report, request, newer)?.agent_json()
467}
468
469pub fn query_indexed(
470 index: &CoverageIndex<'_>,
471 report: Option<&CoverageReport>,
472 request: &IndexedQueryRequest,
473 newer: Option<NewerQuery<'_>>,
474) -> Result<IndexedQueryOutput, IndexedQueryError> {
475 let view = request.view()?;
476 let gaps_only = match request.command.as_str() {
477 "files" => Some(false),
478 "gaps" => Some(true),
479 "file-decisions"
480 | "kinds"
481 | "runners"
482 | "summary"
483 | "scope"
484 | "line"
485 | "test"
486 | "decision"
487 | "file-detail"
488 | "minimize"
489 | "diff"
490 | "tests-without-evidence" => None,
491 _ => {
492 return Err(IndexedQueryError::UnsupportedCommand(
493 request.command.clone(),
494 ));
495 }
496 };
497
498 if request.command == "diff" {
499 let newer = newer.ok_or(IndexedQueryError::MissingNewerRun)?;
500 let (data, page) = coverage_diff_query(
501 index,
502 newer.index,
503 CoverageDiffQueryOptions {
504 older_run: &request.run_id,
505 newer_run: newer.run_id,
506 view,
507 kind: request.kind.as_deref(),
508 runner: request.runner.as_deref(),
509 offset: request.offset,
510 limit: request.limit,
511 gained_only: request.gained_only,
512 },
513 )?;
514 return Ok(IndexedQueryOutput {
515 command: "diff",
516 data: IndexedQueryData::Diff(Box::new(data)),
517 pagination: Some(page),
518 });
519 }
520
521 if request.command == "minimize" {
522 let report = report.ok_or(IndexedQueryError::MissingReport)?;
523 let coverage_view = match view {
524 CoverageViewId::All => &report.view,
525 CoverageViewId::Passed => &report.filters.passed,
526 CoverageViewId::Failed => &report.filters.failed,
527 };
528 let (data, page) = coverage_minimize_query(
529 coverage_view,
530 CoverageMinimizeQueryOptions {
531 run: &request.run_id,
532 view_id: view,
533 kind: request.kind.as_deref(),
534 runner: request.runner.as_deref(),
535 target: request.target.unwrap_or(100.0),
536 metric: request.metric,
537 max_states: request.max_states.unwrap_or(5_000),
538 offset: request.offset,
539 limit: request.limit,
540 },
541 )?;
542 return Ok(IndexedQueryOutput {
543 command: "coverage.minimize",
544 data: IndexedQueryData::Minimize(Box::new(data)),
545 pagination: Some(page),
546 });
547 }
548
549 if request.command == "summary" {
550 let data = coverage_summary_query(
551 index,
552 CoverageSummaryQueryOptions {
553 run: &request.run_id,
554 view,
555 kind: request.kind.as_deref(),
556 runner: request.runner.as_deref(),
557 valid: request.valid.unwrap_or(false),
558 test_exit_code: request.test_exit_code,
559 stale: request.stale.unwrap_or(false),
560 stale_reasons: request.stale_reasons.clone().unwrap_or_default(),
561 },
562 )?;
563 return Ok(IndexedQueryOutput {
564 command: "coverage.summary",
565 data: IndexedQueryData::Summary(Box::new(data)),
566 pagination: None,
567 });
568 }
569
570 if request.command == "scope" {
571 let (data, page) = coverage_scope_query(
572 index,
573 CoverageScopeQueryOptions {
574 run: &request.run_id,
575 view,
576 kind: request.kind.as_deref(),
577 runner: request.runner.as_deref(),
578 offset: request.offset,
579 limit: request.limit,
580 },
581 )?;
582 return Ok(IndexedQueryOutput {
583 command: "coverage.scope",
584 data: IndexedQueryData::Scope(Box::new(data)),
585 pagination: Some(page),
586 });
587 }
588
589 if request.command == "line" && !request.more_lines.is_empty() {
590 let first = (
591 request
592 .file
593 .clone()
594 .ok_or(IndexedQueryError::MissingArgument("a file"))?,
595 request
596 .line
597 .ok_or(IndexedQueryError::MissingArgument("a line"))?,
598 );
599 let mut locations = Vec::new();
602 for location in std::iter::once(first).chain(request.more_lines.iter().cloned()) {
603 if !locations.contains(&location) {
604 locations.push(location);
605 }
606 }
607 let total = locations.len();
608 let mut lines = Vec::new();
609 let mut pages = Vec::new();
610 let mut cache = crate::coverage_query::CoversCache::default();
611 for (file, line) in locations.into_iter().skip(request.offset) {
612 let (data, page) = crate::coverage_query::coverage_covers_query_cached(
613 index,
614 CoverageCoversQueryOptions {
615 run: &request.run_id,
616 view,
617 kind: request.kind.as_deref(),
618 runner: request.runner.as_deref(),
619 file: &file,
620 line,
621 offset: 0,
622 limit: request.limit.min(BATCH_LINE_TESTS),
623 },
624 &mut cache,
625 )?;
626 lines.push(compact_line(data));
627 pages.push(page);
628 }
629 let returned = lines.len();
630 return Ok(IndexedQueryOutput {
631 command: "coverage.lines",
632 data: IndexedQueryData::Lines(Box::new(CoverageLinesData {
633 run: request.run_id.clone(),
634 lines,
635 remaining: Vec::new(),
636 pages,
637 })),
638 pagination: Some(supercov_contracts::AgentPagination {
639 offset: request.offset,
640 limit: returned,
641 returned,
642 total,
643 has_more: false,
644 next_offset: None,
645 }),
646 });
647 }
648
649 if request.command == "line" {
650 let file = request
651 .file
652 .as_deref()
653 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
654 let line = request
655 .line
656 .ok_or(IndexedQueryError::MissingArgument("a line"))?;
657 let (data, page) = coverage_covers_query(
658 index,
659 CoverageCoversQueryOptions {
660 run: &request.run_id,
661 view,
662 kind: request.kind.as_deref(),
663 runner: request.runner.as_deref(),
664 file,
665 line,
666 offset: request.offset,
667 limit: request.limit,
668 },
669 )?;
670 return Ok(IndexedQueryOutput {
671 command: "coverage.line",
672 data: IndexedQueryData::Line(Box::new(data)),
673 pagination: Some(page),
674 });
675 }
676
677 if request.command == "test" {
678 let selector = request
679 .selector
680 .as_deref()
681 .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
682 let (data, page) = coverage_test_query(
683 index,
684 CoverageTestQueryOptions {
685 run: &request.run_id,
686 view,
687 kind: request.kind.as_deref(),
688 runner: request.runner.as_deref(),
689 selector,
690 offset: request.offset,
691 limit: request.limit,
692 },
693 )?;
694 return Ok(IndexedQueryOutput {
695 command: "coverage.test",
696 data: IndexedQueryData::Test(Box::new(data)),
697 pagination: Some(page),
698 });
699 }
700
701 if request.command == "decision" {
702 let selector = request
703 .selector
704 .as_deref()
705 .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
706 let (data, page) = coverage_decision_query(
707 index,
708 CoverageDecisionQueryOptions {
709 run: &request.run_id,
710 view,
711 kind: request.kind.as_deref(),
712 runner: request.runner.as_deref(),
713 selector,
714 offset: request.offset,
715 limit: request.limit,
716 },
717 )?;
718 return Ok(IndexedQueryOutput {
719 command: "coverage.decision",
720 data: IndexedQueryData::Decision(Box::new(data)),
721 pagination: Some(page),
722 });
723 }
724
725 if request.command == "file-detail" {
726 let selector = request
727 .file
728 .as_deref()
729 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
730 let (data, page) = coverage_file_detail_query(
731 index,
732 CoverageFileDetailOptions {
733 run: &request.run_id,
734 view,
735 kind: request.kind.as_deref(),
736 runner: request.runner.as_deref(),
737 selector,
738 metric: request.metric,
739 offset: request.offset,
740 limit: request.limit,
741 },
742 )?;
743 return Ok(IndexedQueryOutput {
744 command: "coverage.file",
745 data: IndexedQueryData::FileDetail(Box::new(data)),
746 pagination: Some(page),
747 });
748 }
749
750 if request.command == "tests-without-evidence" {
751 let (data, page) = crate::coverage_query::tests_without_evidence_query(
752 index,
753 crate::coverage_query::TestsWithoutEvidenceOptions {
754 run: &request.run_id,
755 view,
756 kind: request.kind.as_deref(),
757 runner: request.runner.as_deref(),
758 offset: request.offset,
759 limit: request.limit,
760 },
761 )?;
762 return Ok(IndexedQueryOutput {
763 command: "coverage.tests-without-evidence",
764 data: IndexedQueryData::TestsWithoutEvidence(Box::new(data)),
765 pagination: Some(page),
766 });
767 }
768
769 if request.command == "kinds" || request.command == "runners" {
770 let dimension = if request.command == "kinds" {
771 CoverageDimension::Kind
772 } else {
773 CoverageDimension::Runner
774 };
775 let filters = CoverageQueryFilters {
776 outcome: request.filter.clone(),
777 kind: request.kind.clone(),
778 runner: request.runner.clone(),
779 };
780 let (data, page) = coverage_dimension_query(
781 index,
782 CoverageDimensionQueryOptions {
783 run: &request.run_id,
784 view,
785 dimension,
786 filters,
787 offset: request.offset,
788 limit: request.limit,
789 },
790 )?;
791 let command = if request.command == "kinds" {
792 "coverage.kinds"
793 } else {
794 "coverage.runners"
795 };
796 return Ok(match data {
797 CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
798 command,
799 data: IndexedQueryData::Kinds(Box::new(data)),
800 pagination: Some(page),
801 },
802 CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
803 command,
804 data: IndexedQueryData::Runners(Box::new(data)),
805 pagination: Some(page),
806 },
807 });
808 }
809
810 if request.command == "file-decisions" {
811 let file = request
812 .file
813 .as_deref()
814 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
815 let (data, page) = coverage_file_decisions_query(
816 index,
817 CoverageFileDecisionsOptions {
818 run: &request.run_id,
819 view,
820 kind: request.kind.as_deref(),
821 runner: request.runner.as_deref(),
822 file,
823 sort: request.sort.unwrap_or(DecisionSort::Location),
824 offset: request.offset,
825 limit: request.limit,
826 },
827 )?;
828 return Ok(IndexedQueryOutput {
829 command: "coverage.file",
830 data: IndexedQueryData::FileDecisions(Box::new(data)),
831 pagination: Some(page),
832 });
833 }
834
835 if request.command == "files" && request.group.as_deref() == Some("dir") {
836 let (data, page) = crate::coverage_query::coverage_areas_query(
837 index,
838 crate::coverage_query::CoverageAreasQueryOptions {
839 run: &request.run_id,
840 view,
841 metric: request.metric,
842 kind: request.kind.as_deref(),
843 runner: request.runner.as_deref(),
844 depth: request.depth.unwrap_or(1),
845 offset: request.offset,
846 limit: request.limit,
847 },
848 )?;
849 return Ok(IndexedQueryOutput {
850 command: "coverage.files",
851 data: IndexedQueryData::Areas(Box::new(data)),
852 pagination: Some(page),
853 });
854 }
855
856 let query = coverage_file_query(
857 index,
858 CoverageFileQueryOptions {
859 run: &request.run_id,
860 view,
861 metric: request.metric,
862 gaps_only: gaps_only.expect("files/gaps command"),
863 kind: request.kind.as_deref(),
864 runner: request.runner.as_deref(),
865 offset: request.offset,
866 limit: request.limit,
867 },
868 )?;
869 let command = if gaps_only == Some(true) {
870 "coverage.gaps"
871 } else {
872 "coverage.files"
873 };
874 Ok(match query.data {
875 CoverageFileQueryData::Files(data) => IndexedQueryOutput {
876 command,
877 data: IndexedQueryData::Files(Box::new(data)),
878 pagination: Some(query.pagination),
879 },
880 CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
881 command,
882 data: IndexedQueryData::Gaps(Box::new(data)),
883 pagination: Some(query.pagination),
884 },
885 })
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 #[test]
893 fn a_page_too_large_says_how_many_entries_fit() {
894 let size = ResponseTooLarge {
897 actual_bytes: 170_131,
898 max_bytes: 65_536,
899 };
900 let fits = entries_that_fit(300, &size);
901 assert_eq!(fits, 104);
902 assert_eq!(
904 entries_that_fit(
905 2,
906 &ResponseTooLarge {
907 actual_bytes: 9_000_000,
908 max_bytes: 65_536
909 }
910 ),
911 1
912 );
913 let error = IndexedQueryError::PageTooLarge {
914 size,
915 asked: 300,
916 fits,
917 }
918 .agent_error();
919 assert_eq!(error.code, agent_json::ErrorCode::ResponseTooLarge);
920 assert_eq!(error.details.as_ref().unwrap()["suggestedLimit"], 104);
921 assert!(
922 error
923 .message
924 .contains("About 104 of the 300 entries asked for fit in one reply: use --limit 104 and follow pagination.nextOffset"),
925 "{}",
926 error.message
927 );
928 }
929
930 #[test]
931 fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
932 let source =
933 IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
934 assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
935 assert_eq!(source.message, "Source file not found: missing.ts");
936 assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
937
938 let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
939 kind: Some("e2e".into()),
940 runner: Some("playwright".into()),
941 })
942 .agent_error();
943 assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
944 assert_eq!(
945 filtered.message,
946 "No tests match kind=e2e, runner=playwright"
947 );
948 assert_eq!(
949 filtered.details,
950 Some(json!({ "kind": "e2e", "runner": "playwright" }))
951 );
952 }
953
954 #[test]
955 fn maps_solver_limits_without_losing_machine_readable_details() {
956 let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
957 candidate_tests: 200,
958 obligations: 900,
959 explored_states: 5_001,
960 max_states: 5_000,
961 target: 100.0,
962 metric: MinimizeMetric::All,
963 })
964 .agent_error();
965 assert_eq!(
966 error.code,
967 agent_json::ErrorCode::MinimizationComplexityLimit
968 );
969 assert!(error.message.contains("5,000-state safety budget"));
970 assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
971 }
972}