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/// Whether a several-line query's error belongs to one location -- its file
386/// is not in the run, or names more than one -- rather than to the run.
387fn location_failure(error: &QueryError) -> bool {
388    matches!(
389        error,
390        QueryError::SourceNotFound(_) | QueryError::AmbiguousSelector { .. }
391    )
392}
393
394/// A location of a several-line query that could not be answered.
395#[derive(Debug, Clone, PartialEq, Serialize)]
396#[serde(rename_all = "camelCase")]
397pub struct CoverageLineFailure {
398    /// As asked: `file:line`.
399    pub location: String,
400    pub code: agent_json::ErrorCode,
401    pub message: String,
402}
403
404/// Several lines asked in one call, each answered as `line` answers it, but
405/// compactly: see [`compact_line`].
406#[derive(Debug, Clone, PartialEq, Serialize)]
407#[serde(rename_all = "camelCase")]
408pub struct CoverageLinesData {
409    pub run: String,
410    pub lines: Vec<CoverageCoversData>,
411    /// Locations asked that did not fit in one reply, as `file:line`: ask
412    /// them in another call.
413    #[serde(skip_serializing_if = "Vec::is_empty")]
414    pub remaining: Vec<String>,
415    /// Locations that could not be answered -- a file the run has no record
416    /// of, a line past its end -- each with why. The others are answered:
417    /// one mistyped path no longer costs every other location its reply.
418    #[serde(skip_serializing_if = "Vec::is_empty")]
419    pub failed: Vec<CoverageLineFailure>,
420    /// Each line's own page of tests and obligations, for a reader that
421    /// renders them one by one.
422    #[serde(skip)]
423    pub pages: Vec<supercov_contracts::AgentPagination>,
424}
425
426pub struct NewerQuery<'a> {
427    pub run_id: &'a str,
428    pub index: &'a CoverageIndex<'a>,
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize)]
432#[serde(untagged)]
433pub enum IndexedQueryData {
434    Summary(Box<CoverageSummaryData>),
435    Scope(Box<CoverageScopeData>),
436    Line(Box<CoverageCoversData>),
437    Lines(Box<CoverageLinesData>),
438    Test(Box<CoverageTestData>),
439    Decision(Box<CoverageDecisionData>),
440    FileDetail(Box<CoverageFileDetailData>),
441    FileDecisions(Box<CoverageFileDecisionsData>),
442    Kinds(Box<CoverageKindsData>),
443    Runners(Box<CoverageRunnersData>),
444    Files(Box<CoverageFilesData>),
445    Areas(Box<crate::coverage_query::CoverageAreasData>),
446    Gaps(Box<CoverageGapsData>),
447    Minimize(Box<CoverageMinimizeData>),
448    Diff(Box<CoverageDiffData>),
449    TestsWithoutEvidence(Box<crate::coverage_query::TestsWithoutEvidenceData>),
450}
451
452#[derive(Debug, Clone, PartialEq)]
453pub struct IndexedQueryOutput {
454    pub command: &'static str,
455    pub data: IndexedQueryData,
456    pub pagination: Option<supercov_contracts::AgentPagination>,
457}
458
459/// How many of the entries of a page that was too large fit in a reply:
460/// nine tenths of what the sizes say, since entries differ.
461fn entries_that_fit(returned: usize, size: &ResponseTooLarge) -> usize {
462    (returned * size.max_bytes * 9 / (size.actual_bytes * 10)).max(1)
463}
464
465impl IndexedQueryOutput {
466    pub fn agent_json(&self) -> Result<String, IndexedQueryError> {
467        agent_json::success(self.command, &self.data, self.pagination.as_ref()).map_err(|size| {
468            match &self.pagination {
469                Some(page) if page.returned > 1 => IndexedQueryError::PageTooLarge {
470                    asked: page.returned,
471                    fits: entries_that_fit(page.returned, &size),
472                    size,
473                },
474                _ => IndexedQueryError::ResponseTooLarge(size),
475            }
476        })
477    }
478}
479
480/// Execute a frozen agent query against an already authenticated index.
481///
482/// Only minimization needs the reconstructed per-test report today. All other
483/// commands are served directly from the mmap-backed index.
484pub fn execute_indexed_query(
485    index: &CoverageIndex<'_>,
486    report: Option<&CoverageReport>,
487    request: &IndexedQueryRequest,
488    newer: Option<NewerQuery<'_>>,
489) -> Result<String, IndexedQueryError> {
490    query_indexed(index, report, request, newer)?.agent_json()
491}
492
493pub fn query_indexed(
494    index: &CoverageIndex<'_>,
495    report: Option<&CoverageReport>,
496    request: &IndexedQueryRequest,
497    newer: Option<NewerQuery<'_>>,
498) -> Result<IndexedQueryOutput, IndexedQueryError> {
499    let view = request.view()?;
500    let gaps_only = match request.command.as_str() {
501        "files" => Some(false),
502        "gaps" => Some(true),
503        "file-decisions"
504        | "kinds"
505        | "runners"
506        | "summary"
507        | "scope"
508        | "line"
509        | "test"
510        | "decision"
511        | "file-detail"
512        | "minimize"
513        | "diff"
514        | "tests-without-evidence" => None,
515        _ => {
516            return Err(IndexedQueryError::UnsupportedCommand(
517                request.command.clone(),
518            ));
519        }
520    };
521
522    if request.command == "diff" {
523        let newer = newer.ok_or(IndexedQueryError::MissingNewerRun)?;
524        let (data, page) = coverage_diff_query(
525            index,
526            newer.index,
527            CoverageDiffQueryOptions {
528                older_run: &request.run_id,
529                newer_run: newer.run_id,
530                view,
531                kind: request.kind.as_deref(),
532                runner: request.runner.as_deref(),
533                offset: request.offset,
534                limit: request.limit,
535                gained_only: request.gained_only,
536            },
537        )?;
538        return Ok(IndexedQueryOutput {
539            command: "diff",
540            data: IndexedQueryData::Diff(Box::new(data)),
541            pagination: Some(page),
542        });
543    }
544
545    if request.command == "minimize" {
546        let report = report.ok_or(IndexedQueryError::MissingReport)?;
547        let coverage_view = match view {
548            CoverageViewId::All => &report.view,
549            CoverageViewId::Passed => &report.filters.passed,
550            CoverageViewId::Failed => &report.filters.failed,
551        };
552        let (data, page) = coverage_minimize_query(
553            coverage_view,
554            CoverageMinimizeQueryOptions {
555                run: &request.run_id,
556                view_id: view,
557                kind: request.kind.as_deref(),
558                runner: request.runner.as_deref(),
559                target: request.target.unwrap_or(100.0),
560                metric: request.metric,
561                max_states: request.max_states.unwrap_or(5_000),
562                offset: request.offset,
563                limit: request.limit,
564            },
565        )?;
566        return Ok(IndexedQueryOutput {
567            command: "coverage.minimize",
568            data: IndexedQueryData::Minimize(Box::new(data)),
569            pagination: Some(page),
570        });
571    }
572
573    if request.command == "summary" {
574        let data = coverage_summary_query(
575            index,
576            CoverageSummaryQueryOptions {
577                run: &request.run_id,
578                view,
579                kind: request.kind.as_deref(),
580                runner: request.runner.as_deref(),
581                valid: request.valid.unwrap_or(false),
582                test_exit_code: request.test_exit_code,
583                stale: request.stale.unwrap_or(false),
584                stale_reasons: request.stale_reasons.clone().unwrap_or_default(),
585            },
586        )?;
587        return Ok(IndexedQueryOutput {
588            command: "coverage.summary",
589            data: IndexedQueryData::Summary(Box::new(data)),
590            pagination: None,
591        });
592    }
593
594    if request.command == "scope" {
595        let (data, page) = coverage_scope_query(
596            index,
597            CoverageScopeQueryOptions {
598                run: &request.run_id,
599                view,
600                kind: request.kind.as_deref(),
601                runner: request.runner.as_deref(),
602                offset: request.offset,
603                limit: request.limit,
604            },
605        )?;
606        return Ok(IndexedQueryOutput {
607            command: "coverage.scope",
608            data: IndexedQueryData::Scope(Box::new(data)),
609            pagination: Some(page),
610        });
611    }
612
613    if request.command == "line" && !request.more_lines.is_empty() {
614        let first = (
615            request
616                .file
617                .clone()
618                .ok_or(IndexedQueryError::MissingArgument("a file"))?,
619            request
620                .line
621                .ok_or(IndexedQueryError::MissingArgument("a line"))?,
622        );
623        // Each location once, in the order asked; `--offset` skips the
624        // locations a reply before this one answered.
625        let mut locations = Vec::new();
626        for location in std::iter::once(first).chain(request.more_lines.iter().cloned()) {
627            if !locations.contains(&location) {
628                locations.push(location);
629            }
630        }
631        let total = locations.len();
632        let mut lines = Vec::new();
633        let mut pages = Vec::new();
634        let mut cache = crate::coverage_query::CoversCache::default();
635        let mut failed = Vec::new();
636        let mut first_failure = None;
637        for (file, line) in locations.into_iter().skip(request.offset) {
638            let answer = crate::coverage_query::coverage_covers_query_cached(
639                index,
640                CoverageCoversQueryOptions {
641                    run: &request.run_id,
642                    view,
643                    kind: request.kind.as_deref(),
644                    runner: request.runner.as_deref(),
645                    file: &file,
646                    line,
647                    offset: 0,
648                    limit: request.limit.min(BATCH_LINE_TESTS),
649                },
650                &mut cache,
651            );
652            match answer {
653                Ok((data, page)) => {
654                    lines.push(compact_line(data));
655                    pages.push(page);
656                }
657                // A failure of the location itself: the others still count.
658                Err(error) if location_failure(&error) => {
659                    let wrapped = IndexedQueryError::Query(error);
660                    let agent = wrapped.agent_error();
661                    failed.push(CoverageLineFailure {
662                        location: format!("{file}:{line}"),
663                        code: agent.code,
664                        message: agent.message,
665                    });
666                    first_failure.get_or_insert(wrapped);
667                }
668                Err(error) => return Err(error.into()),
669            }
670        }
671        // Every location failed: the call failed, as one location's would.
672        if lines.is_empty()
673            && let Some(error) = first_failure
674        {
675            return Err(error);
676        }
677        let returned = lines.len();
678        return Ok(IndexedQueryOutput {
679            command: "coverage.lines",
680            data: IndexedQueryData::Lines(Box::new(CoverageLinesData {
681                run: request.run_id.clone(),
682                lines,
683                remaining: Vec::new(),
684                failed,
685                pages,
686            })),
687            pagination: Some(supercov_contracts::AgentPagination {
688                offset: request.offset,
689                limit: returned,
690                returned,
691                total,
692                has_more: false,
693                next_offset: None,
694            }),
695        });
696    }
697
698    if request.command == "line" {
699        let file = request
700            .file
701            .as_deref()
702            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
703        let line = request
704            .line
705            .ok_or(IndexedQueryError::MissingArgument("a line"))?;
706        let (data, page) = coverage_covers_query(
707            index,
708            CoverageCoversQueryOptions {
709                run: &request.run_id,
710                view,
711                kind: request.kind.as_deref(),
712                runner: request.runner.as_deref(),
713                file,
714                line,
715                offset: request.offset,
716                limit: request.limit,
717            },
718        )?;
719        return Ok(IndexedQueryOutput {
720            command: "coverage.line",
721            data: IndexedQueryData::Line(Box::new(data)),
722            pagination: Some(page),
723        });
724    }
725
726    if request.command == "test" {
727        let selector = request
728            .selector
729            .as_deref()
730            .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
731        let (data, page) = coverage_test_query(
732            index,
733            CoverageTestQueryOptions {
734                run: &request.run_id,
735                view,
736                kind: request.kind.as_deref(),
737                runner: request.runner.as_deref(),
738                selector,
739                offset: request.offset,
740                limit: request.limit,
741            },
742        )?;
743        return Ok(IndexedQueryOutput {
744            command: "coverage.test",
745            data: IndexedQueryData::Test(Box::new(data)),
746            pagination: Some(page),
747        });
748    }
749
750    if request.command == "decision" {
751        let selector = request
752            .selector
753            .as_deref()
754            .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
755        let (data, page) = coverage_decision_query(
756            index,
757            CoverageDecisionQueryOptions {
758                run: &request.run_id,
759                view,
760                kind: request.kind.as_deref(),
761                runner: request.runner.as_deref(),
762                selector,
763                offset: request.offset,
764                limit: request.limit,
765            },
766        )?;
767        return Ok(IndexedQueryOutput {
768            command: "coverage.decision",
769            data: IndexedQueryData::Decision(Box::new(data)),
770            pagination: Some(page),
771        });
772    }
773
774    if request.command == "file-detail" {
775        let selector = request
776            .file
777            .as_deref()
778            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
779        let (data, page) = coverage_file_detail_query(
780            index,
781            CoverageFileDetailOptions {
782                run: &request.run_id,
783                view,
784                kind: request.kind.as_deref(),
785                runner: request.runner.as_deref(),
786                selector,
787                metric: request.metric,
788                offset: request.offset,
789                limit: request.limit,
790            },
791        )?;
792        return Ok(IndexedQueryOutput {
793            command: "coverage.file",
794            data: IndexedQueryData::FileDetail(Box::new(data)),
795            pagination: Some(page),
796        });
797    }
798
799    if request.command == "tests-without-evidence" {
800        let (data, page) = crate::coverage_query::tests_without_evidence_query(
801            index,
802            crate::coverage_query::TestsWithoutEvidenceOptions {
803                run: &request.run_id,
804                view,
805                kind: request.kind.as_deref(),
806                runner: request.runner.as_deref(),
807                offset: request.offset,
808                limit: request.limit,
809            },
810        )?;
811        return Ok(IndexedQueryOutput {
812            command: "coverage.tests-without-evidence",
813            data: IndexedQueryData::TestsWithoutEvidence(Box::new(data)),
814            pagination: Some(page),
815        });
816    }
817
818    if request.command == "kinds" || request.command == "runners" {
819        let dimension = if request.command == "kinds" {
820            CoverageDimension::Kind
821        } else {
822            CoverageDimension::Runner
823        };
824        let filters = CoverageQueryFilters {
825            outcome: request.filter.clone(),
826            kind: request.kind.clone(),
827            runner: request.runner.clone(),
828        };
829        let (data, page) = coverage_dimension_query(
830            index,
831            CoverageDimensionQueryOptions {
832                run: &request.run_id,
833                view,
834                dimension,
835                filters,
836                offset: request.offset,
837                limit: request.limit,
838            },
839        )?;
840        let command = if request.command == "kinds" {
841            "coverage.kinds"
842        } else {
843            "coverage.runners"
844        };
845        return Ok(match data {
846            CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
847                command,
848                data: IndexedQueryData::Kinds(Box::new(data)),
849                pagination: Some(page),
850            },
851            CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
852                command,
853                data: IndexedQueryData::Runners(Box::new(data)),
854                pagination: Some(page),
855            },
856        });
857    }
858
859    if request.command == "file-decisions" {
860        let file = request
861            .file
862            .as_deref()
863            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
864        let (data, page) = coverage_file_decisions_query(
865            index,
866            CoverageFileDecisionsOptions {
867                run: &request.run_id,
868                view,
869                kind: request.kind.as_deref(),
870                runner: request.runner.as_deref(),
871                file,
872                sort: request.sort.unwrap_or(DecisionSort::Location),
873                offset: request.offset,
874                limit: request.limit,
875            },
876        )?;
877        return Ok(IndexedQueryOutput {
878            command: "coverage.file",
879            data: IndexedQueryData::FileDecisions(Box::new(data)),
880            pagination: Some(page),
881        });
882    }
883
884    if request.command == "files" && request.group.as_deref() == Some("dir") {
885        let (data, page) = crate::coverage_query::coverage_areas_query(
886            index,
887            crate::coverage_query::CoverageAreasQueryOptions {
888                run: &request.run_id,
889                view,
890                metric: request.metric,
891                kind: request.kind.as_deref(),
892                runner: request.runner.as_deref(),
893                depth: request.depth.unwrap_or(1),
894                offset: request.offset,
895                limit: request.limit,
896            },
897        )?;
898        return Ok(IndexedQueryOutput {
899            command: "coverage.files",
900            data: IndexedQueryData::Areas(Box::new(data)),
901            pagination: Some(page),
902        });
903    }
904
905    let query = coverage_file_query(
906        index,
907        CoverageFileQueryOptions {
908            run: &request.run_id,
909            view,
910            metric: request.metric,
911            gaps_only: gaps_only.expect("files/gaps command"),
912            kind: request.kind.as_deref(),
913            runner: request.runner.as_deref(),
914            offset: request.offset,
915            limit: request.limit,
916        },
917    )?;
918    let command = if gaps_only == Some(true) {
919        "coverage.gaps"
920    } else {
921        "coverage.files"
922    };
923    Ok(match query.data {
924        CoverageFileQueryData::Files(data) => IndexedQueryOutput {
925            command,
926            data: IndexedQueryData::Files(Box::new(data)),
927            pagination: Some(query.pagination),
928        },
929        CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
930            command,
931            data: IndexedQueryData::Gaps(Box::new(data)),
932            pagination: Some(query.pagination),
933        },
934    })
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn a_page_too_large_says_how_many_entries_fit() {
943        // `runs <id> files --limit 5000 --json` on a project of 300 files:
944        // the reply said it was too large and not what limit would not be.
945        let size = ResponseTooLarge {
946            actual_bytes: 170_131,
947            max_bytes: 65_536,
948        };
949        let fits = entries_that_fit(300, &size);
950        assert_eq!(fits, 104);
951        // One entry larger than a reply is still asked for one at a time.
952        assert_eq!(
953            entries_that_fit(
954                2,
955                &ResponseTooLarge {
956                    actual_bytes: 9_000_000,
957                    max_bytes: 65_536
958                }
959            ),
960            1
961        );
962        let error = IndexedQueryError::PageTooLarge {
963            size,
964            asked: 300,
965            fits,
966        }
967        .agent_error();
968        assert_eq!(error.code, agent_json::ErrorCode::ResponseTooLarge);
969        assert_eq!(error.details.as_ref().unwrap()["suggestedLimit"], 104);
970        assert!(
971            error
972                .message
973                .contains("About 104 of the 300 entries asked for fit in one reply: use --limit 104 and follow pagination.nextOffset"),
974            "{}",
975            error.message
976        );
977    }
978
979    #[test]
980    fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
981        let source =
982            IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
983        assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
984        assert_eq!(source.message, "Source file not found: missing.ts");
985        assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
986
987        let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
988            kind: Some("e2e".into()),
989            runner: Some("playwright".into()),
990        })
991        .agent_error();
992        assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
993        assert_eq!(
994            filtered.message,
995            "No tests match kind=e2e, runner=playwright"
996        );
997        assert_eq!(
998            filtered.details,
999            Some(json!({ "kind": "e2e", "runner": "playwright" }))
1000        );
1001    }
1002
1003    #[test]
1004    fn maps_solver_limits_without_losing_machine_readable_details() {
1005        let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
1006            candidate_tests: 200,
1007            obligations: 900,
1008            explored_states: 5_001,
1009            max_states: 5_000,
1010            target: 100.0,
1011            metric: MinimizeMetric::All,
1012        })
1013        .agent_error();
1014        assert_eq!(
1015            error.code,
1016            agent_json::ErrorCode::MinimizationComplexityLimit
1017        );
1018        assert!(error.message.contains("5,000-state safety budget"));
1019        assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
1020    }
1021}