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 lines = Vec::new();
600 let mut pages = Vec::new();
601 let mut cache = crate::coverage_query::CoversCache::default();
602 for (file, line) in std::iter::once(first).chain(request.more_lines.iter().cloned()) {
603 let (data, page) = crate::coverage_query::coverage_covers_query_cached(
604 index,
605 CoverageCoversQueryOptions {
606 run: &request.run_id,
607 view,
608 kind: request.kind.as_deref(),
609 runner: request.runner.as_deref(),
610 file: &file,
611 line,
612 offset: 0,
613 limit: request.limit.min(BATCH_LINE_TESTS),
614 },
615 &mut cache,
616 )?;
617 lines.push(compact_line(data));
618 pages.push(page);
619 }
620 return Ok(IndexedQueryOutput {
621 command: "coverage.lines",
622 data: IndexedQueryData::Lines(Box::new(CoverageLinesData {
623 run: request.run_id.clone(),
624 lines,
625 remaining: Vec::new(),
626 pages,
627 })),
628 pagination: None,
629 });
630 }
631
632 if request.command == "line" {
633 let file = request
634 .file
635 .as_deref()
636 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
637 let line = request
638 .line
639 .ok_or(IndexedQueryError::MissingArgument("a line"))?;
640 let (data, page) = coverage_covers_query(
641 index,
642 CoverageCoversQueryOptions {
643 run: &request.run_id,
644 view,
645 kind: request.kind.as_deref(),
646 runner: request.runner.as_deref(),
647 file,
648 line,
649 offset: request.offset,
650 limit: request.limit,
651 },
652 )?;
653 return Ok(IndexedQueryOutput {
654 command: "coverage.line",
655 data: IndexedQueryData::Line(Box::new(data)),
656 pagination: Some(page),
657 });
658 }
659
660 if request.command == "test" {
661 let selector = request
662 .selector
663 .as_deref()
664 .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
665 let (data, page) = coverage_test_query(
666 index,
667 CoverageTestQueryOptions {
668 run: &request.run_id,
669 view,
670 kind: request.kind.as_deref(),
671 runner: request.runner.as_deref(),
672 selector,
673 offset: request.offset,
674 limit: request.limit,
675 },
676 )?;
677 return Ok(IndexedQueryOutput {
678 command: "coverage.test",
679 data: IndexedQueryData::Test(Box::new(data)),
680 pagination: Some(page),
681 });
682 }
683
684 if request.command == "decision" {
685 let selector = request
686 .selector
687 .as_deref()
688 .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
689 let (data, page) = coverage_decision_query(
690 index,
691 CoverageDecisionQueryOptions {
692 run: &request.run_id,
693 view,
694 kind: request.kind.as_deref(),
695 runner: request.runner.as_deref(),
696 selector,
697 offset: request.offset,
698 limit: request.limit,
699 },
700 )?;
701 return Ok(IndexedQueryOutput {
702 command: "coverage.decision",
703 data: IndexedQueryData::Decision(Box::new(data)),
704 pagination: Some(page),
705 });
706 }
707
708 if request.command == "file-detail" {
709 let selector = request
710 .file
711 .as_deref()
712 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
713 let (data, page) = coverage_file_detail_query(
714 index,
715 CoverageFileDetailOptions {
716 run: &request.run_id,
717 view,
718 kind: request.kind.as_deref(),
719 runner: request.runner.as_deref(),
720 selector,
721 metric: request.metric,
722 offset: request.offset,
723 limit: request.limit,
724 },
725 )?;
726 return Ok(IndexedQueryOutput {
727 command: "coverage.file",
728 data: IndexedQueryData::FileDetail(Box::new(data)),
729 pagination: Some(page),
730 });
731 }
732
733 if request.command == "tests-without-evidence" {
734 let (data, page) = crate::coverage_query::tests_without_evidence_query(
735 index,
736 crate::coverage_query::TestsWithoutEvidenceOptions {
737 run: &request.run_id,
738 view,
739 kind: request.kind.as_deref(),
740 runner: request.runner.as_deref(),
741 offset: request.offset,
742 limit: request.limit,
743 },
744 )?;
745 return Ok(IndexedQueryOutput {
746 command: "coverage.tests-without-evidence",
747 data: IndexedQueryData::TestsWithoutEvidence(Box::new(data)),
748 pagination: Some(page),
749 });
750 }
751
752 if request.command == "kinds" || request.command == "runners" {
753 let dimension = if request.command == "kinds" {
754 CoverageDimension::Kind
755 } else {
756 CoverageDimension::Runner
757 };
758 let filters = CoverageQueryFilters {
759 outcome: request.filter.clone(),
760 kind: request.kind.clone(),
761 runner: request.runner.clone(),
762 };
763 let (data, page) = coverage_dimension_query(
764 index,
765 CoverageDimensionQueryOptions {
766 run: &request.run_id,
767 view,
768 dimension,
769 filters,
770 offset: request.offset,
771 limit: request.limit,
772 },
773 )?;
774 let command = if request.command == "kinds" {
775 "coverage.kinds"
776 } else {
777 "coverage.runners"
778 };
779 return Ok(match data {
780 CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
781 command,
782 data: IndexedQueryData::Kinds(Box::new(data)),
783 pagination: Some(page),
784 },
785 CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
786 command,
787 data: IndexedQueryData::Runners(Box::new(data)),
788 pagination: Some(page),
789 },
790 });
791 }
792
793 if request.command == "file-decisions" {
794 let file = request
795 .file
796 .as_deref()
797 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
798 let (data, page) = coverage_file_decisions_query(
799 index,
800 CoverageFileDecisionsOptions {
801 run: &request.run_id,
802 view,
803 kind: request.kind.as_deref(),
804 runner: request.runner.as_deref(),
805 file,
806 sort: request.sort.unwrap_or(DecisionSort::Location),
807 offset: request.offset,
808 limit: request.limit,
809 },
810 )?;
811 return Ok(IndexedQueryOutput {
812 command: "coverage.file",
813 data: IndexedQueryData::FileDecisions(Box::new(data)),
814 pagination: Some(page),
815 });
816 }
817
818 if request.command == "files" && request.group.as_deref() == Some("dir") {
819 let (data, page) = crate::coverage_query::coverage_areas_query(
820 index,
821 crate::coverage_query::CoverageAreasQueryOptions {
822 run: &request.run_id,
823 view,
824 metric: request.metric,
825 kind: request.kind.as_deref(),
826 runner: request.runner.as_deref(),
827 depth: request.depth.unwrap_or(1),
828 offset: request.offset,
829 limit: request.limit,
830 },
831 )?;
832 return Ok(IndexedQueryOutput {
833 command: "coverage.files",
834 data: IndexedQueryData::Areas(Box::new(data)),
835 pagination: Some(page),
836 });
837 }
838
839 let query = coverage_file_query(
840 index,
841 CoverageFileQueryOptions {
842 run: &request.run_id,
843 view,
844 metric: request.metric,
845 gaps_only: gaps_only.expect("files/gaps command"),
846 kind: request.kind.as_deref(),
847 runner: request.runner.as_deref(),
848 offset: request.offset,
849 limit: request.limit,
850 },
851 )?;
852 let command = if gaps_only == Some(true) {
853 "coverage.gaps"
854 } else {
855 "coverage.files"
856 };
857 Ok(match query.data {
858 CoverageFileQueryData::Files(data) => IndexedQueryOutput {
859 command,
860 data: IndexedQueryData::Files(Box::new(data)),
861 pagination: Some(query.pagination),
862 },
863 CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
864 command,
865 data: IndexedQueryData::Gaps(Box::new(data)),
866 pagination: Some(query.pagination),
867 },
868 })
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
876 fn a_page_too_large_says_how_many_entries_fit() {
877 let size = ResponseTooLarge {
880 actual_bytes: 170_131,
881 max_bytes: 65_536,
882 };
883 let fits = entries_that_fit(300, &size);
884 assert_eq!(fits, 104);
885 assert_eq!(
887 entries_that_fit(
888 2,
889 &ResponseTooLarge {
890 actual_bytes: 9_000_000,
891 max_bytes: 65_536
892 }
893 ),
894 1
895 );
896 let error = IndexedQueryError::PageTooLarge {
897 size,
898 asked: 300,
899 fits,
900 }
901 .agent_error();
902 assert_eq!(error.code, agent_json::ErrorCode::ResponseTooLarge);
903 assert_eq!(error.details.as_ref().unwrap()["suggestedLimit"], 104);
904 assert!(
905 error
906 .message
907 .contains("About 104 of the 300 entries asked for fit in one reply: use --limit 104 and follow pagination.nextOffset"),
908 "{}",
909 error.message
910 );
911 }
912
913 #[test]
914 fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
915 let source =
916 IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
917 assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
918 assert_eq!(source.message, "Source file not found: missing.ts");
919 assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
920
921 let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
922 kind: Some("e2e".into()),
923 runner: Some("playwright".into()),
924 })
925 .agent_error();
926 assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
927 assert_eq!(
928 filtered.message,
929 "No tests match kind=e2e, runner=playwright"
930 );
931 assert_eq!(
932 filtered.details,
933 Some(json!({ "kind": "e2e", "runner": "playwright" }))
934 );
935 }
936
937 #[test]
938 fn maps_solver_limits_without_losing_machine_readable_details() {
939 let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
940 candidate_tests: 200,
941 obligations: 900,
942 explored_states: 5_001,
943 max_states: 5_000,
944 target: 100.0,
945 metric: MinimizeMetric::All,
946 })
947 .agent_error();
948 assert_eq!(
949 error.code,
950 agent_json::ErrorCode::MinimizationComplexityLimit
951 );
952 assert!(error.message.contains("5,000-state safety budget"));
953 assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
954 }
955}