Skip to main content

supercov_engine/
indexed_query.rs

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