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