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