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