Skip to main content

supercov_engine/
indexed_query.rs

1//! One agent-query implementation over an authenticated immutable query index.
2//!
3//! Opening, rebuilding and storing indexes belongs to `run_store`; this module
4//! is deliberately unaware of paths. Both archive differential tests and the
5//! persisted-run CLI therefore exercise exactly the same query operators.
6
7use 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    coverage_waivers::CoverageWaiverEvaluation,
29};
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct IndexedQueryRequest {
34    pub run_id: String,
35    pub filter: String,
36    pub command: String,
37    #[serde(default = "default_metric")]
38    pub metric: MinimizeMetric,
39    pub kind: Option<String>,
40    pub runner: Option<String>,
41    pub file: Option<String>,
42    pub line: Option<usize>,
43    pub selector: Option<String>,
44    pub sort: Option<DecisionSort>,
45    pub valid: Option<bool>,
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    Covers(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
353/// Execute a frozen agent query against an already authenticated index.
354///
355/// Only minimization needs the reconstructed per-test report today. All other
356/// commands are served directly from the mmap-backed index.
357pub 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 execute_indexed_query_with_waivers(
367    index: &CoverageIndex<'_>,
368    report: Option<&CoverageReport>,
369    request: &IndexedQueryRequest,
370    newer: Option<NewerQuery<'_>>,
371    waivers: Option<&CoverageWaiverEvaluation>,
372) -> Result<String, IndexedQueryError> {
373    query_indexed_with_waivers(index, report, request, newer, waivers)?.agent_json()
374}
375
376pub fn query_indexed(
377    index: &CoverageIndex<'_>,
378    report: Option<&CoverageReport>,
379    request: &IndexedQueryRequest,
380    newer: Option<NewerQuery<'_>>,
381) -> Result<IndexedQueryOutput, IndexedQueryError> {
382    query_indexed_with_waivers(index, report, request, newer, None)
383}
384
385pub fn query_indexed_with_waivers(
386    index: &CoverageIndex<'_>,
387    report: Option<&CoverageReport>,
388    request: &IndexedQueryRequest,
389    newer: Option<NewerQuery<'_>>,
390    waivers: Option<&CoverageWaiverEvaluation>,
391) -> Result<IndexedQueryOutput, IndexedQueryError> {
392    let view = request.view()?;
393    let gaps_only = match request.command.as_str() {
394        "files" => Some(false),
395        "gaps" => Some(true),
396        "file-decisions" | "kinds" | "runners" | "summary" | "scope" | "covers" | "test"
397        | "decision" | "file-detail" | "minimize" | "diff" => None,
398        _ => {
399            return Err(IndexedQueryError::UnsupportedCommand(
400                request.command.clone(),
401            ));
402        }
403    };
404
405    if request.command == "diff" {
406        let newer = newer.ok_or(IndexedQueryError::MissingNewerRun)?;
407        let (data, page) = coverage_diff_query(
408            index,
409            newer.index,
410            CoverageDiffQueryOptions {
411                older_run: &request.run_id,
412                newer_run: newer.run_id,
413                view,
414                kind: request.kind.as_deref(),
415                runner: request.runner.as_deref(),
416                offset: request.offset,
417                limit: request.limit,
418            },
419        )?;
420        return Ok(IndexedQueryOutput {
421            command: "diff",
422            data: IndexedQueryData::Diff(Box::new(data)),
423            pagination: Some(page),
424        });
425    }
426
427    if request.command == "minimize" {
428        let report = report.ok_or(IndexedQueryError::MissingReport)?;
429        let coverage_view = match view {
430            CoverageViewId::All => &report.view,
431            CoverageViewId::Passed => &report.filters.passed,
432            CoverageViewId::Failed => &report.filters.failed,
433        };
434        let (data, page) = coverage_minimize_query(
435            coverage_view,
436            CoverageMinimizeQueryOptions {
437                run: &request.run_id,
438                view_id: view,
439                kind: request.kind.as_deref(),
440                runner: request.runner.as_deref(),
441                target: request.target.unwrap_or(100.0),
442                metric: request.metric,
443                max_states: request.max_states.unwrap_or(5_000),
444                offset: request.offset,
445                limit: request.limit,
446            },
447        )?;
448        return Ok(IndexedQueryOutput {
449            command: "coverage.minimize",
450            data: IndexedQueryData::Minimize(Box::new(data)),
451            pagination: Some(page),
452        });
453    }
454
455    if request.command == "summary" {
456        let mut data = coverage_summary_query(
457            index,
458            CoverageSummaryQueryOptions {
459                run: &request.run_id,
460                view,
461                kind: request.kind.as_deref(),
462                runner: request.runner.as_deref(),
463                valid: request.valid.unwrap_or(false),
464                stale: request.stale.unwrap_or(false),
465                stale_reasons: request.stale_reasons.clone().unwrap_or_default(),
466            },
467        )?;
468        if let Some(waivers) = waivers {
469            data.waivers =
470                Some(waivers.summary(data.coverage.covered_conditions, data.coverage.conditions));
471        }
472        return Ok(IndexedQueryOutput {
473            command: "coverage.summary",
474            data: IndexedQueryData::Summary(Box::new(data)),
475            pagination: None,
476        });
477    }
478
479    if request.command == "scope" {
480        let (data, page) = coverage_scope_query(
481            index,
482            CoverageScopeQueryOptions {
483                run: &request.run_id,
484                view,
485                kind: request.kind.as_deref(),
486                runner: request.runner.as_deref(),
487                offset: request.offset,
488                limit: request.limit,
489            },
490        )?;
491        return Ok(IndexedQueryOutput {
492            command: "coverage.scope",
493            data: IndexedQueryData::Scope(Box::new(data)),
494            pagination: Some(page),
495        });
496    }
497
498    if request.command == "covers" {
499        let file = request
500            .file
501            .as_deref()
502            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
503        let line = request
504            .line
505            .ok_or(IndexedQueryError::MissingArgument("a line"))?;
506        let (data, page) = coverage_covers_query(
507            index,
508            CoverageCoversQueryOptions {
509                run: &request.run_id,
510                view,
511                kind: request.kind.as_deref(),
512                runner: request.runner.as_deref(),
513                file,
514                line,
515                offset: request.offset,
516                limit: request.limit,
517            },
518        )?;
519        return Ok(IndexedQueryOutput {
520            command: "coverage.covers",
521            data: IndexedQueryData::Covers(Box::new(data)),
522            pagination: Some(page),
523        });
524    }
525
526    if request.command == "test" {
527        let selector = request
528            .selector
529            .as_deref()
530            .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
531        let (data, page) = coverage_test_query(
532            index,
533            CoverageTestQueryOptions {
534                run: &request.run_id,
535                view,
536                kind: request.kind.as_deref(),
537                runner: request.runner.as_deref(),
538                selector,
539                offset: request.offset,
540                limit: request.limit,
541            },
542        )?;
543        return Ok(IndexedQueryOutput {
544            command: "coverage.test",
545            data: IndexedQueryData::Test(Box::new(data)),
546            pagination: Some(page),
547        });
548    }
549
550    if request.command == "decision" {
551        let selector = request
552            .selector
553            .as_deref()
554            .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
555        let (mut data, page) = coverage_decision_query(
556            index,
557            CoverageDecisionQueryOptions {
558                run: &request.run_id,
559                view,
560                kind: request.kind.as_deref(),
561                runner: request.runner.as_deref(),
562                selector,
563                offset: request.offset,
564                limit: request.limit,
565            },
566        )?;
567        if let Some(waivers) = waivers
568            && let crate::coverage_query::CoverageDecisionData::Detail(detail) = &mut data
569        {
570            for decision in &mut detail.decisions {
571                if let Some(conditions) = waivers.waived_by_decision.get(&decision.meta.id) {
572                    for condition in &mut decision.conditions {
573                        if let Some(waiver) = conditions.get(&condition.index) {
574                            condition.waived = Some(true);
575                            condition.waiver_reason = Some(waiver.reason.clone());
576                        }
577                    }
578                }
579            }
580        }
581        return Ok(IndexedQueryOutput {
582            command: "coverage.decision",
583            data: IndexedQueryData::Decision(Box::new(data)),
584            pagination: Some(page),
585        });
586    }
587
588    if request.command == "file-detail" {
589        let selector = request
590            .file
591            .as_deref()
592            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
593        let (mut data, page) = coverage_file_detail_query(
594            index,
595            CoverageFileDetailOptions {
596                run: &request.run_id,
597                view,
598                kind: request.kind.as_deref(),
599                runner: request.runner.as_deref(),
600                selector,
601                metric: request.metric,
602                offset: request.offset,
603                limit: request.limit,
604            },
605        )?;
606        if let Some(waivers) = waivers {
607            data.counts.waived_mcdc_conditions = waivers
608                .applied_by_file
609                .get(&data.file)
610                .copied()
611                .unwrap_or(0);
612            for obligation in &mut data.obligations {
613                if let crate::coverage_query::CoverageFileObligation::Mcdc(obligation) = obligation
614                    && let Some(waiver) = waivers
615                        .waived_by_decision
616                        .get(&obligation.id)
617                        .and_then(|conditions| conditions.get(&obligation.condition_index))
618                {
619                    obligation.waived = Some(true);
620                    obligation.waiver_reason = Some(waiver.reason.clone());
621                }
622            }
623        }
624        return Ok(IndexedQueryOutput {
625            command: "coverage.file",
626            data: IndexedQueryData::FileDetail(Box::new(data)),
627            pagination: Some(page),
628        });
629    }
630
631    if request.command == "kinds" || request.command == "runners" {
632        let dimension = if request.command == "kinds" {
633            CoverageDimension::Kind
634        } else {
635            CoverageDimension::Runner
636        };
637        let filters = CoverageQueryFilters {
638            outcome: request.filter.clone(),
639            kind: request.kind.clone(),
640            runner: request.runner.clone(),
641        };
642        let (data, page) = coverage_dimension_query(
643            index,
644            CoverageDimensionQueryOptions {
645                run: &request.run_id,
646                view,
647                dimension,
648                filters,
649                offset: request.offset,
650                limit: request.limit,
651            },
652        )?;
653        let command = if request.command == "kinds" {
654            "coverage.kinds"
655        } else {
656            "coverage.runners"
657        };
658        return Ok(match data {
659            CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
660                command,
661                data: IndexedQueryData::Kinds(Box::new(data)),
662                pagination: Some(page),
663            },
664            CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
665                command,
666                data: IndexedQueryData::Runners(Box::new(data)),
667                pagination: Some(page),
668            },
669        });
670    }
671
672    if request.command == "file-decisions" {
673        let file = request
674            .file
675            .as_deref()
676            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
677        let (data, page) = coverage_file_decisions_query(
678            index,
679            CoverageFileDecisionsOptions {
680                run: &request.run_id,
681                view,
682                kind: request.kind.as_deref(),
683                runner: request.runner.as_deref(),
684                file,
685                waived_by_decision: waivers.map(|waivers| &waivers.waived_by_decision),
686                sort: request.sort.unwrap_or(DecisionSort::Location),
687                offset: request.offset,
688                limit: request.limit,
689            },
690        )?;
691        return Ok(IndexedQueryOutput {
692            command: "coverage.file",
693            data: IndexedQueryData::FileDecisions(Box::new(data)),
694            pagination: Some(page),
695        });
696    }
697
698    let mut query = coverage_file_query(
699        index,
700        CoverageFileQueryOptions {
701            run: &request.run_id,
702            view,
703            metric: request.metric,
704            gaps_only: gaps_only.expect("files/gaps command"),
705            kind: request.kind.as_deref(),
706            runner: request.runner.as_deref(),
707            offset: request.offset,
708            limit: request.limit,
709        },
710    )?;
711    if let Some(waivers) = waivers {
712        let rows = match &mut query.data {
713            CoverageFileQueryData::Files(data) => &mut data.files,
714            CoverageFileQueryData::Gaps(data) => &mut data.gaps,
715        };
716        for row in rows {
717            row.waived_mcdc_conditions =
718                Some(waivers.applied_by_file.get(&row.file).copied().unwrap_or(0));
719        }
720    }
721    let command = if gaps_only == Some(true) {
722        "coverage.gaps"
723    } else {
724        "coverage.files"
725    };
726    Ok(match query.data {
727        CoverageFileQueryData::Files(data) => IndexedQueryOutput {
728            command,
729            data: IndexedQueryData::Files(Box::new(data)),
730            pagination: Some(query.pagination),
731        },
732        CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
733            command,
734            data: IndexedQueryData::Gaps(Box::new(data)),
735            pagination: Some(query.pagination),
736        },
737    })
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
746        let source =
747            IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
748        assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
749        assert_eq!(source.message, "Source file not found: missing.ts");
750        assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
751
752        let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
753            kind: Some("e2e".into()),
754            runner: Some("playwright".into()),
755        })
756        .agent_error();
757        assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
758        assert_eq!(
759            filtered.message,
760            "No tests match kind=e2e, runner=playwright"
761        );
762        assert_eq!(
763            filtered.details,
764            Some(json!({ "kind": "e2e", "runner": "playwright" }))
765        );
766    }
767
768    #[test]
769    fn maps_solver_limits_without_losing_machine_readable_details() {
770        let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
771            candidate_tests: 200,
772            obligations: 900,
773            explored_states: 5_001,
774            max_states: 5_000,
775            target: 100.0,
776            metric: MinimizeMetric::All,
777        })
778        .agent_error();
779        assert_eq!(
780            error.code,
781            agent_json::ErrorCode::MinimizationComplexityLimit
782        );
783        assert!(error.message.contains("5,000-state safety budget"));
784        assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
785    }
786}