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