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