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