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