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        // Each location once, in the order asked; `--offset` skips the
600        // locations a reply before this one answered.
601        let mut locations = Vec::new();
602        for location in std::iter::once(first).chain(request.more_lines.iter().cloned()) {
603            if !locations.contains(&location) {
604                locations.push(location);
605            }
606        }
607        let total = locations.len();
608        let mut lines = Vec::new();
609        let mut pages = Vec::new();
610        let mut cache = crate::coverage_query::CoversCache::default();
611        for (file, line) in locations.into_iter().skip(request.offset) {
612            let (data, page) = crate::coverage_query::coverage_covers_query_cached(
613                index,
614                CoverageCoversQueryOptions {
615                    run: &request.run_id,
616                    view,
617                    kind: request.kind.as_deref(),
618                    runner: request.runner.as_deref(),
619                    file: &file,
620                    line,
621                    offset: 0,
622                    limit: request.limit.min(BATCH_LINE_TESTS),
623                },
624                &mut cache,
625            )?;
626            lines.push(compact_line(data));
627            pages.push(page);
628        }
629        let returned = lines.len();
630        return Ok(IndexedQueryOutput {
631            command: "coverage.lines",
632            data: IndexedQueryData::Lines(Box::new(CoverageLinesData {
633                run: request.run_id.clone(),
634                lines,
635                remaining: Vec::new(),
636                pages,
637            })),
638            pagination: Some(supercov_contracts::AgentPagination {
639                offset: request.offset,
640                limit: returned,
641                returned,
642                total,
643                has_more: false,
644                next_offset: None,
645            }),
646        });
647    }
648
649    if request.command == "line" {
650        let file = request
651            .file
652            .as_deref()
653            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
654        let line = request
655            .line
656            .ok_or(IndexedQueryError::MissingArgument("a line"))?;
657        let (data, page) = coverage_covers_query(
658            index,
659            CoverageCoversQueryOptions {
660                run: &request.run_id,
661                view,
662                kind: request.kind.as_deref(),
663                runner: request.runner.as_deref(),
664                file,
665                line,
666                offset: request.offset,
667                limit: request.limit,
668            },
669        )?;
670        return Ok(IndexedQueryOutput {
671            command: "coverage.line",
672            data: IndexedQueryData::Line(Box::new(data)),
673            pagination: Some(page),
674        });
675    }
676
677    if request.command == "test" {
678        let selector = request
679            .selector
680            .as_deref()
681            .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
682        let (data, page) = coverage_test_query(
683            index,
684            CoverageTestQueryOptions {
685                run: &request.run_id,
686                view,
687                kind: request.kind.as_deref(),
688                runner: request.runner.as_deref(),
689                selector,
690                offset: request.offset,
691                limit: request.limit,
692            },
693        )?;
694        return Ok(IndexedQueryOutput {
695            command: "coverage.test",
696            data: IndexedQueryData::Test(Box::new(data)),
697            pagination: Some(page),
698        });
699    }
700
701    if request.command == "decision" {
702        let selector = request
703            .selector
704            .as_deref()
705            .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
706        let (data, page) = coverage_decision_query(
707            index,
708            CoverageDecisionQueryOptions {
709                run: &request.run_id,
710                view,
711                kind: request.kind.as_deref(),
712                runner: request.runner.as_deref(),
713                selector,
714                offset: request.offset,
715                limit: request.limit,
716            },
717        )?;
718        return Ok(IndexedQueryOutput {
719            command: "coverage.decision",
720            data: IndexedQueryData::Decision(Box::new(data)),
721            pagination: Some(page),
722        });
723    }
724
725    if request.command == "file-detail" {
726        let selector = request
727            .file
728            .as_deref()
729            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
730        let (data, page) = coverage_file_detail_query(
731            index,
732            CoverageFileDetailOptions {
733                run: &request.run_id,
734                view,
735                kind: request.kind.as_deref(),
736                runner: request.runner.as_deref(),
737                selector,
738                metric: request.metric,
739                offset: request.offset,
740                limit: request.limit,
741            },
742        )?;
743        return Ok(IndexedQueryOutput {
744            command: "coverage.file",
745            data: IndexedQueryData::FileDetail(Box::new(data)),
746            pagination: Some(page),
747        });
748    }
749
750    if request.command == "tests-without-evidence" {
751        let (data, page) = crate::coverage_query::tests_without_evidence_query(
752            index,
753            crate::coverage_query::TestsWithoutEvidenceOptions {
754                run: &request.run_id,
755                view,
756                kind: request.kind.as_deref(),
757                runner: request.runner.as_deref(),
758                offset: request.offset,
759                limit: request.limit,
760            },
761        )?;
762        return Ok(IndexedQueryOutput {
763            command: "coverage.tests-without-evidence",
764            data: IndexedQueryData::TestsWithoutEvidence(Box::new(data)),
765            pagination: Some(page),
766        });
767    }
768
769    if request.command == "kinds" || request.command == "runners" {
770        let dimension = if request.command == "kinds" {
771            CoverageDimension::Kind
772        } else {
773            CoverageDimension::Runner
774        };
775        let filters = CoverageQueryFilters {
776            outcome: request.filter.clone(),
777            kind: request.kind.clone(),
778            runner: request.runner.clone(),
779        };
780        let (data, page) = coverage_dimension_query(
781            index,
782            CoverageDimensionQueryOptions {
783                run: &request.run_id,
784                view,
785                dimension,
786                filters,
787                offset: request.offset,
788                limit: request.limit,
789            },
790        )?;
791        let command = if request.command == "kinds" {
792            "coverage.kinds"
793        } else {
794            "coverage.runners"
795        };
796        return Ok(match data {
797            CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
798                command,
799                data: IndexedQueryData::Kinds(Box::new(data)),
800                pagination: Some(page),
801            },
802            CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
803                command,
804                data: IndexedQueryData::Runners(Box::new(data)),
805                pagination: Some(page),
806            },
807        });
808    }
809
810    if request.command == "file-decisions" {
811        let file = request
812            .file
813            .as_deref()
814            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
815        let (data, page) = coverage_file_decisions_query(
816            index,
817            CoverageFileDecisionsOptions {
818                run: &request.run_id,
819                view,
820                kind: request.kind.as_deref(),
821                runner: request.runner.as_deref(),
822                file,
823                sort: request.sort.unwrap_or(DecisionSort::Location),
824                offset: request.offset,
825                limit: request.limit,
826            },
827        )?;
828        return Ok(IndexedQueryOutput {
829            command: "coverage.file",
830            data: IndexedQueryData::FileDecisions(Box::new(data)),
831            pagination: Some(page),
832        });
833    }
834
835    if request.command == "files" && request.group.as_deref() == Some("dir") {
836        let (data, page) = crate::coverage_query::coverage_areas_query(
837            index,
838            crate::coverage_query::CoverageAreasQueryOptions {
839                run: &request.run_id,
840                view,
841                metric: request.metric,
842                kind: request.kind.as_deref(),
843                runner: request.runner.as_deref(),
844                depth: request.depth.unwrap_or(1),
845                offset: request.offset,
846                limit: request.limit,
847            },
848        )?;
849        return Ok(IndexedQueryOutput {
850            command: "coverage.files",
851            data: IndexedQueryData::Areas(Box::new(data)),
852            pagination: Some(page),
853        });
854    }
855
856    let query = coverage_file_query(
857        index,
858        CoverageFileQueryOptions {
859            run: &request.run_id,
860            view,
861            metric: request.metric,
862            gaps_only: gaps_only.expect("files/gaps command"),
863            kind: request.kind.as_deref(),
864            runner: request.runner.as_deref(),
865            offset: request.offset,
866            limit: request.limit,
867        },
868    )?;
869    let command = if gaps_only == Some(true) {
870        "coverage.gaps"
871    } else {
872        "coverage.files"
873    };
874    Ok(match query.data {
875        CoverageFileQueryData::Files(data) => IndexedQueryOutput {
876            command,
877            data: IndexedQueryData::Files(Box::new(data)),
878            pagination: Some(query.pagination),
879        },
880        CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
881            command,
882            data: IndexedQueryData::Gaps(Box::new(data)),
883            pagination: Some(query.pagination),
884        },
885    })
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    #[test]
893    fn a_page_too_large_says_how_many_entries_fit() {
894        // `runs <id> files --limit 5000 --json` on a project of 300 files:
895        // the reply said it was too large and not what limit would not be.
896        let size = ResponseTooLarge {
897            actual_bytes: 170_131,
898            max_bytes: 65_536,
899        };
900        let fits = entries_that_fit(300, &size);
901        assert_eq!(fits, 104);
902        // One entry larger than a reply is still asked for one at a time.
903        assert_eq!(
904            entries_that_fit(
905                2,
906                &ResponseTooLarge {
907                    actual_bytes: 9_000_000,
908                    max_bytes: 65_536
909                }
910            ),
911            1
912        );
913        let error = IndexedQueryError::PageTooLarge {
914            size,
915            asked: 300,
916            fits,
917        }
918        .agent_error();
919        assert_eq!(error.code, agent_json::ErrorCode::ResponseTooLarge);
920        assert_eq!(error.details.as_ref().unwrap()["suggestedLimit"], 104);
921        assert!(
922            error
923                .message
924                .contains("About 104 of the 300 entries asked for fit in one reply: use --limit 104 and follow pagination.nextOffset"),
925            "{}",
926            error.message
927        );
928    }
929
930    #[test]
931    fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
932        let source =
933            IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
934        assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
935        assert_eq!(source.message, "Source file not found: missing.ts");
936        assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
937
938        let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
939            kind: Some("e2e".into()),
940            runner: Some("playwright".into()),
941        })
942        .agent_error();
943        assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
944        assert_eq!(
945            filtered.message,
946            "No tests match kind=e2e, runner=playwright"
947        );
948        assert_eq!(
949            filtered.details,
950            Some(json!({ "kind": "e2e", "runner": "playwright" }))
951        );
952    }
953
954    #[test]
955    fn maps_solver_limits_without_losing_machine_readable_details() {
956        let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
957            candidate_tests: 200,
958            obligations: 900,
959            explored_states: 5_001,
960            max_states: 5_000,
961            target: 100.0,
962            metric: MinimizeMetric::All,
963        })
964        .agent_error();
965        assert_eq!(
966            error.code,
967            agent_json::ErrorCode::MinimizationComplexityLimit
968        );
969        assert!(error.message.contains("5,000-state safety budget"));
970        assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
971    }
972}