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
385fn location_failure(error: &QueryError) -> bool {
388 matches!(
389 error,
390 QueryError::SourceNotFound(_) | QueryError::AmbiguousSelector { .. }
391 )
392}
393
394#[derive(Debug, Clone, PartialEq, Serialize)]
396#[serde(rename_all = "camelCase")]
397pub struct CoverageLineFailure {
398 pub location: String,
400 pub code: agent_json::ErrorCode,
401 pub message: String,
402}
403
404#[derive(Debug, Clone, PartialEq, Serialize)]
407#[serde(rename_all = "camelCase")]
408pub struct CoverageLinesData {
409 pub run: String,
410 pub lines: Vec<CoverageCoversData>,
411 #[serde(skip_serializing_if = "Vec::is_empty")]
414 pub remaining: Vec<String>,
415 #[serde(skip_serializing_if = "Vec::is_empty")]
419 pub failed: Vec<CoverageLineFailure>,
420 #[serde(skip)]
423 pub pages: Vec<supercov_contracts::AgentPagination>,
424}
425
426pub struct NewerQuery<'a> {
427 pub run_id: &'a str,
428 pub index: &'a CoverageIndex<'a>,
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize)]
432#[serde(untagged)]
433pub enum IndexedQueryData {
434 Summary(Box<CoverageSummaryData>),
435 Scope(Box<CoverageScopeData>),
436 Line(Box<CoverageCoversData>),
437 Lines(Box<CoverageLinesData>),
438 Test(Box<CoverageTestData>),
439 Decision(Box<CoverageDecisionData>),
440 FileDetail(Box<CoverageFileDetailData>),
441 FileDecisions(Box<CoverageFileDecisionsData>),
442 Kinds(Box<CoverageKindsData>),
443 Runners(Box<CoverageRunnersData>),
444 Files(Box<CoverageFilesData>),
445 Areas(Box<crate::coverage_query::CoverageAreasData>),
446 Gaps(Box<CoverageGapsData>),
447 Minimize(Box<CoverageMinimizeData>),
448 Diff(Box<CoverageDiffData>),
449 TestsWithoutEvidence(Box<crate::coverage_query::TestsWithoutEvidenceData>),
450}
451
452#[derive(Debug, Clone, PartialEq)]
453pub struct IndexedQueryOutput {
454 pub command: &'static str,
455 pub data: IndexedQueryData,
456 pub pagination: Option<supercov_contracts::AgentPagination>,
457}
458
459fn entries_that_fit(returned: usize, size: &ResponseTooLarge) -> usize {
462 (returned * size.max_bytes * 9 / (size.actual_bytes * 10)).max(1)
463}
464
465impl IndexedQueryOutput {
466 pub fn agent_json(&self) -> Result<String, IndexedQueryError> {
467 agent_json::success(self.command, &self.data, self.pagination.as_ref()).map_err(|size| {
468 match &self.pagination {
469 Some(page) if page.returned > 1 => IndexedQueryError::PageTooLarge {
470 asked: page.returned,
471 fits: entries_that_fit(page.returned, &size),
472 size,
473 },
474 _ => IndexedQueryError::ResponseTooLarge(size),
475 }
476 })
477 }
478}
479
480pub fn execute_indexed_query(
485 index: &CoverageIndex<'_>,
486 report: Option<&CoverageReport>,
487 request: &IndexedQueryRequest,
488 newer: Option<NewerQuery<'_>>,
489) -> Result<String, IndexedQueryError> {
490 query_indexed(index, report, request, newer)?.agent_json()
491}
492
493pub fn query_indexed(
494 index: &CoverageIndex<'_>,
495 report: Option<&CoverageReport>,
496 request: &IndexedQueryRequest,
497 newer: Option<NewerQuery<'_>>,
498) -> Result<IndexedQueryOutput, IndexedQueryError> {
499 let view = request.view()?;
500 let gaps_only = match request.command.as_str() {
501 "files" => Some(false),
502 "gaps" => Some(true),
503 "file-decisions"
504 | "kinds"
505 | "runners"
506 | "summary"
507 | "scope"
508 | "line"
509 | "test"
510 | "decision"
511 | "file-detail"
512 | "minimize"
513 | "diff"
514 | "tests-without-evidence" => None,
515 _ => {
516 return Err(IndexedQueryError::UnsupportedCommand(
517 request.command.clone(),
518 ));
519 }
520 };
521
522 if request.command == "diff" {
523 let newer = newer.ok_or(IndexedQueryError::MissingNewerRun)?;
524 let (data, page) = coverage_diff_query(
525 index,
526 newer.index,
527 CoverageDiffQueryOptions {
528 older_run: &request.run_id,
529 newer_run: newer.run_id,
530 view,
531 kind: request.kind.as_deref(),
532 runner: request.runner.as_deref(),
533 offset: request.offset,
534 limit: request.limit,
535 gained_only: request.gained_only,
536 },
537 )?;
538 return Ok(IndexedQueryOutput {
539 command: "diff",
540 data: IndexedQueryData::Diff(Box::new(data)),
541 pagination: Some(page),
542 });
543 }
544
545 if request.command == "minimize" {
546 let report = report.ok_or(IndexedQueryError::MissingReport)?;
547 let coverage_view = match view {
548 CoverageViewId::All => &report.view,
549 CoverageViewId::Passed => &report.filters.passed,
550 CoverageViewId::Failed => &report.filters.failed,
551 };
552 let (data, page) = coverage_minimize_query(
553 coverage_view,
554 CoverageMinimizeQueryOptions {
555 run: &request.run_id,
556 view_id: view,
557 kind: request.kind.as_deref(),
558 runner: request.runner.as_deref(),
559 target: request.target.unwrap_or(100.0),
560 metric: request.metric,
561 max_states: request.max_states.unwrap_or(5_000),
562 offset: request.offset,
563 limit: request.limit,
564 },
565 )?;
566 return Ok(IndexedQueryOutput {
567 command: "coverage.minimize",
568 data: IndexedQueryData::Minimize(Box::new(data)),
569 pagination: Some(page),
570 });
571 }
572
573 if request.command == "summary" {
574 let data = coverage_summary_query(
575 index,
576 CoverageSummaryQueryOptions {
577 run: &request.run_id,
578 view,
579 kind: request.kind.as_deref(),
580 runner: request.runner.as_deref(),
581 valid: request.valid.unwrap_or(false),
582 test_exit_code: request.test_exit_code,
583 stale: request.stale.unwrap_or(false),
584 stale_reasons: request.stale_reasons.clone().unwrap_or_default(),
585 },
586 )?;
587 return Ok(IndexedQueryOutput {
588 command: "coverage.summary",
589 data: IndexedQueryData::Summary(Box::new(data)),
590 pagination: None,
591 });
592 }
593
594 if request.command == "scope" {
595 let (data, page) = coverage_scope_query(
596 index,
597 CoverageScopeQueryOptions {
598 run: &request.run_id,
599 view,
600 kind: request.kind.as_deref(),
601 runner: request.runner.as_deref(),
602 offset: request.offset,
603 limit: request.limit,
604 },
605 )?;
606 return Ok(IndexedQueryOutput {
607 command: "coverage.scope",
608 data: IndexedQueryData::Scope(Box::new(data)),
609 pagination: Some(page),
610 });
611 }
612
613 if request.command == "line" && !request.more_lines.is_empty() {
614 let first = (
615 request
616 .file
617 .clone()
618 .ok_or(IndexedQueryError::MissingArgument("a file"))?,
619 request
620 .line
621 .ok_or(IndexedQueryError::MissingArgument("a line"))?,
622 );
623 let mut locations = Vec::new();
626 for location in std::iter::once(first).chain(request.more_lines.iter().cloned()) {
627 if !locations.contains(&location) {
628 locations.push(location);
629 }
630 }
631 let total = locations.len();
632 let mut lines = Vec::new();
633 let mut pages = Vec::new();
634 let mut cache = crate::coverage_query::CoversCache::default();
635 let mut failed = Vec::new();
636 let mut first_failure = None;
637 for (file, line) in locations.into_iter().skip(request.offset) {
638 let answer = crate::coverage_query::coverage_covers_query_cached(
639 index,
640 CoverageCoversQueryOptions {
641 run: &request.run_id,
642 view,
643 kind: request.kind.as_deref(),
644 runner: request.runner.as_deref(),
645 file: &file,
646 line,
647 offset: 0,
648 limit: request.limit.min(BATCH_LINE_TESTS),
649 },
650 &mut cache,
651 );
652 match answer {
653 Ok((data, page)) => {
654 lines.push(compact_line(data));
655 pages.push(page);
656 }
657 Err(error) if location_failure(&error) => {
659 let wrapped = IndexedQueryError::Query(error);
660 let agent = wrapped.agent_error();
661 failed.push(CoverageLineFailure {
662 location: format!("{file}:{line}"),
663 code: agent.code,
664 message: agent.message,
665 });
666 first_failure.get_or_insert(wrapped);
667 }
668 Err(error) => return Err(error.into()),
669 }
670 }
671 if lines.is_empty()
673 && let Some(error) = first_failure
674 {
675 return Err(error);
676 }
677 let returned = lines.len();
678 return Ok(IndexedQueryOutput {
679 command: "coverage.lines",
680 data: IndexedQueryData::Lines(Box::new(CoverageLinesData {
681 run: request.run_id.clone(),
682 lines,
683 remaining: Vec::new(),
684 failed,
685 pages,
686 })),
687 pagination: Some(supercov_contracts::AgentPagination {
688 offset: request.offset,
689 limit: returned,
690 returned,
691 total,
692 has_more: false,
693 next_offset: None,
694 }),
695 });
696 }
697
698 if request.command == "line" {
699 let file = request
700 .file
701 .as_deref()
702 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
703 let line = request
704 .line
705 .ok_or(IndexedQueryError::MissingArgument("a line"))?;
706 let (data, page) = coverage_covers_query(
707 index,
708 CoverageCoversQueryOptions {
709 run: &request.run_id,
710 view,
711 kind: request.kind.as_deref(),
712 runner: request.runner.as_deref(),
713 file,
714 line,
715 offset: request.offset,
716 limit: request.limit,
717 },
718 )?;
719 return Ok(IndexedQueryOutput {
720 command: "coverage.line",
721 data: IndexedQueryData::Line(Box::new(data)),
722 pagination: Some(page),
723 });
724 }
725
726 if request.command == "test" {
727 let selector = request
728 .selector
729 .as_deref()
730 .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
731 let (data, page) = coverage_test_query(
732 index,
733 CoverageTestQueryOptions {
734 run: &request.run_id,
735 view,
736 kind: request.kind.as_deref(),
737 runner: request.runner.as_deref(),
738 selector,
739 offset: request.offset,
740 limit: request.limit,
741 },
742 )?;
743 return Ok(IndexedQueryOutput {
744 command: "coverage.test",
745 data: IndexedQueryData::Test(Box::new(data)),
746 pagination: Some(page),
747 });
748 }
749
750 if request.command == "decision" {
751 let selector = request
752 .selector
753 .as_deref()
754 .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
755 let (data, page) = coverage_decision_query(
756 index,
757 CoverageDecisionQueryOptions {
758 run: &request.run_id,
759 view,
760 kind: request.kind.as_deref(),
761 runner: request.runner.as_deref(),
762 selector,
763 offset: request.offset,
764 limit: request.limit,
765 },
766 )?;
767 return Ok(IndexedQueryOutput {
768 command: "coverage.decision",
769 data: IndexedQueryData::Decision(Box::new(data)),
770 pagination: Some(page),
771 });
772 }
773
774 if request.command == "file-detail" {
775 let selector = request
776 .file
777 .as_deref()
778 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
779 let (data, page) = coverage_file_detail_query(
780 index,
781 CoverageFileDetailOptions {
782 run: &request.run_id,
783 view,
784 kind: request.kind.as_deref(),
785 runner: request.runner.as_deref(),
786 selector,
787 metric: request.metric,
788 offset: request.offset,
789 limit: request.limit,
790 },
791 )?;
792 return Ok(IndexedQueryOutput {
793 command: "coverage.file",
794 data: IndexedQueryData::FileDetail(Box::new(data)),
795 pagination: Some(page),
796 });
797 }
798
799 if request.command == "tests-without-evidence" {
800 let (data, page) = crate::coverage_query::tests_without_evidence_query(
801 index,
802 crate::coverage_query::TestsWithoutEvidenceOptions {
803 run: &request.run_id,
804 view,
805 kind: request.kind.as_deref(),
806 runner: request.runner.as_deref(),
807 offset: request.offset,
808 limit: request.limit,
809 },
810 )?;
811 return Ok(IndexedQueryOutput {
812 command: "coverage.tests-without-evidence",
813 data: IndexedQueryData::TestsWithoutEvidence(Box::new(data)),
814 pagination: Some(page),
815 });
816 }
817
818 if request.command == "kinds" || request.command == "runners" {
819 let dimension = if request.command == "kinds" {
820 CoverageDimension::Kind
821 } else {
822 CoverageDimension::Runner
823 };
824 let filters = CoverageQueryFilters {
825 outcome: request.filter.clone(),
826 kind: request.kind.clone(),
827 runner: request.runner.clone(),
828 };
829 let (data, page) = coverage_dimension_query(
830 index,
831 CoverageDimensionQueryOptions {
832 run: &request.run_id,
833 view,
834 dimension,
835 filters,
836 offset: request.offset,
837 limit: request.limit,
838 },
839 )?;
840 let command = if request.command == "kinds" {
841 "coverage.kinds"
842 } else {
843 "coverage.runners"
844 };
845 return Ok(match data {
846 CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
847 command,
848 data: IndexedQueryData::Kinds(Box::new(data)),
849 pagination: Some(page),
850 },
851 CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
852 command,
853 data: IndexedQueryData::Runners(Box::new(data)),
854 pagination: Some(page),
855 },
856 });
857 }
858
859 if request.command == "file-decisions" {
860 let file = request
861 .file
862 .as_deref()
863 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
864 let (data, page) = coverage_file_decisions_query(
865 index,
866 CoverageFileDecisionsOptions {
867 run: &request.run_id,
868 view,
869 kind: request.kind.as_deref(),
870 runner: request.runner.as_deref(),
871 file,
872 sort: request.sort.unwrap_or(DecisionSort::Location),
873 offset: request.offset,
874 limit: request.limit,
875 },
876 )?;
877 return Ok(IndexedQueryOutput {
878 command: "coverage.file",
879 data: IndexedQueryData::FileDecisions(Box::new(data)),
880 pagination: Some(page),
881 });
882 }
883
884 if request.command == "files" && request.group.as_deref() == Some("dir") {
885 let (data, page) = crate::coverage_query::coverage_areas_query(
886 index,
887 crate::coverage_query::CoverageAreasQueryOptions {
888 run: &request.run_id,
889 view,
890 metric: request.metric,
891 kind: request.kind.as_deref(),
892 runner: request.runner.as_deref(),
893 depth: request.depth.unwrap_or(1),
894 offset: request.offset,
895 limit: request.limit,
896 },
897 )?;
898 return Ok(IndexedQueryOutput {
899 command: "coverage.files",
900 data: IndexedQueryData::Areas(Box::new(data)),
901 pagination: Some(page),
902 });
903 }
904
905 let query = coverage_file_query(
906 index,
907 CoverageFileQueryOptions {
908 run: &request.run_id,
909 view,
910 metric: request.metric,
911 gaps_only: gaps_only.expect("files/gaps command"),
912 kind: request.kind.as_deref(),
913 runner: request.runner.as_deref(),
914 offset: request.offset,
915 limit: request.limit,
916 },
917 )?;
918 let command = if gaps_only == Some(true) {
919 "coverage.gaps"
920 } else {
921 "coverage.files"
922 };
923 Ok(match query.data {
924 CoverageFileQueryData::Files(data) => IndexedQueryOutput {
925 command,
926 data: IndexedQueryData::Files(Box::new(data)),
927 pagination: Some(query.pagination),
928 },
929 CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
930 command,
931 data: IndexedQueryData::Gaps(Box::new(data)),
932 pagination: Some(query.pagination),
933 },
934 })
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940
941 #[test]
942 fn a_page_too_large_says_how_many_entries_fit() {
943 let size = ResponseTooLarge {
946 actual_bytes: 170_131,
947 max_bytes: 65_536,
948 };
949 let fits = entries_that_fit(300, &size);
950 assert_eq!(fits, 104);
951 assert_eq!(
953 entries_that_fit(
954 2,
955 &ResponseTooLarge {
956 actual_bytes: 9_000_000,
957 max_bytes: 65_536
958 }
959 ),
960 1
961 );
962 let error = IndexedQueryError::PageTooLarge {
963 size,
964 asked: 300,
965 fits,
966 }
967 .agent_error();
968 assert_eq!(error.code, agent_json::ErrorCode::ResponseTooLarge);
969 assert_eq!(error.details.as_ref().unwrap()["suggestedLimit"], 104);
970 assert!(
971 error
972 .message
973 .contains("About 104 of the 300 entries asked for fit in one reply: use --limit 104 and follow pagination.nextOffset"),
974 "{}",
975 error.message
976 );
977 }
978
979 #[test]
980 fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
981 let source =
982 IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
983 assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
984 assert_eq!(source.message, "Source file not found: missing.ts");
985 assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
986
987 let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
988 kind: Some("e2e".into()),
989 runner: Some("playwright".into()),
990 })
991 .agent_error();
992 assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
993 assert_eq!(
994 filtered.message,
995 "No tests match kind=e2e, runner=playwright"
996 );
997 assert_eq!(
998 filtered.details,
999 Some(json!({ "kind": "e2e", "runner": "playwright" }))
1000 );
1001 }
1002
1003 #[test]
1004 fn maps_solver_limits_without_losing_machine_readable_details() {
1005 let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
1006 candidate_tests: 200,
1007 obligations: 900,
1008 explored_states: 5_001,
1009 max_states: 5_000,
1010 target: 100.0,
1011 metric: MinimizeMetric::All,
1012 })
1013 .agent_error();
1014 assert_eq!(
1015 error.code,
1016 agent_json::ErrorCode::MinimizationComplexityLimit
1017 );
1018 assert!(error.message.contains("5,000-state safety budget"));
1019 assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
1020 }
1021}