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