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}
343
344#[derive(Debug, Clone, PartialEq)]
345pub struct IndexedQueryOutput {
346    pub command: &'static str,
347    pub data: IndexedQueryData,
348    pub pagination: Option<supercov_contracts::AgentPagination>,
349}
350
351impl IndexedQueryOutput {
352    pub fn agent_json(&self) -> Result<String, IndexedQueryError> {
353        Ok(agent_json::success(
354            self.command,
355            &self.data,
356            self.pagination.as_ref(),
357        )?)
358    }
359}
360
361/// Execute a frozen agent query against an already authenticated index.
362///
363/// Only minimization needs the reconstructed per-test report today. All other
364/// commands are served directly from the mmap-backed index.
365pub fn execute_indexed_query(
366    index: &CoverageIndex<'_>,
367    report: Option<&CoverageReport>,
368    request: &IndexedQueryRequest,
369    newer: Option<NewerQuery<'_>>,
370) -> Result<String, IndexedQueryError> {
371    query_indexed(index, report, request, newer)?.agent_json()
372}
373
374pub fn query_indexed(
375    index: &CoverageIndex<'_>,
376    report: Option<&CoverageReport>,
377    request: &IndexedQueryRequest,
378    newer: Option<NewerQuery<'_>>,
379) -> Result<IndexedQueryOutput, IndexedQueryError> {
380    let view = request.view()?;
381    let gaps_only = match request.command.as_str() {
382        "files" => Some(false),
383        "gaps" => Some(true),
384        "file-decisions" | "kinds" | "runners" | "summary" | "scope" | "line" | "test"
385        | "decision" | "file-detail" | "minimize" | "diff" => None,
386        _ => {
387            return Err(IndexedQueryError::UnsupportedCommand(
388                request.command.clone(),
389            ));
390        }
391    };
392
393    if request.command == "diff" {
394        let newer = newer.ok_or(IndexedQueryError::MissingNewerRun)?;
395        let (data, page) = coverage_diff_query(
396            index,
397            newer.index,
398            CoverageDiffQueryOptions {
399                older_run: &request.run_id,
400                newer_run: newer.run_id,
401                view,
402                kind: request.kind.as_deref(),
403                runner: request.runner.as_deref(),
404                offset: request.offset,
405                limit: request.limit,
406            },
407        )?;
408        return Ok(IndexedQueryOutput {
409            command: "diff",
410            data: IndexedQueryData::Diff(Box::new(data)),
411            pagination: Some(page),
412        });
413    }
414
415    if request.command == "minimize" {
416        let report = report.ok_or(IndexedQueryError::MissingReport)?;
417        let coverage_view = match view {
418            CoverageViewId::All => &report.view,
419            CoverageViewId::Passed => &report.filters.passed,
420            CoverageViewId::Failed => &report.filters.failed,
421        };
422        let (data, page) = coverage_minimize_query(
423            coverage_view,
424            CoverageMinimizeQueryOptions {
425                run: &request.run_id,
426                view_id: view,
427                kind: request.kind.as_deref(),
428                runner: request.runner.as_deref(),
429                target: request.target.unwrap_or(100.0),
430                metric: request.metric,
431                max_states: request.max_states.unwrap_or(5_000),
432                offset: request.offset,
433                limit: request.limit,
434            },
435        )?;
436        return Ok(IndexedQueryOutput {
437            command: "coverage.minimize",
438            data: IndexedQueryData::Minimize(Box::new(data)),
439            pagination: Some(page),
440        });
441    }
442
443    if request.command == "summary" {
444        let data = coverage_summary_query(
445            index,
446            CoverageSummaryQueryOptions {
447                run: &request.run_id,
448                view,
449                kind: request.kind.as_deref(),
450                runner: request.runner.as_deref(),
451                valid: request.valid.unwrap_or(false),
452                test_exit_code: request.test_exit_code,
453                stale: request.stale.unwrap_or(false),
454                stale_reasons: request.stale_reasons.clone().unwrap_or_default(),
455            },
456        )?;
457        return Ok(IndexedQueryOutput {
458            command: "coverage.summary",
459            data: IndexedQueryData::Summary(Box::new(data)),
460            pagination: None,
461        });
462    }
463
464    if request.command == "scope" {
465        let (data, page) = coverage_scope_query(
466            index,
467            CoverageScopeQueryOptions {
468                run: &request.run_id,
469                view,
470                kind: request.kind.as_deref(),
471                runner: request.runner.as_deref(),
472                offset: request.offset,
473                limit: request.limit,
474            },
475        )?;
476        return Ok(IndexedQueryOutput {
477            command: "coverage.scope",
478            data: IndexedQueryData::Scope(Box::new(data)),
479            pagination: Some(page),
480        });
481    }
482
483    if request.command == "line" {
484        let file = request
485            .file
486            .as_deref()
487            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
488        let line = request
489            .line
490            .ok_or(IndexedQueryError::MissingArgument("a line"))?;
491        let (data, page) = coverage_covers_query(
492            index,
493            CoverageCoversQueryOptions {
494                run: &request.run_id,
495                view,
496                kind: request.kind.as_deref(),
497                runner: request.runner.as_deref(),
498                file,
499                line,
500                offset: request.offset,
501                limit: request.limit,
502            },
503        )?;
504        return Ok(IndexedQueryOutput {
505            command: "coverage.line",
506            data: IndexedQueryData::Line(Box::new(data)),
507            pagination: Some(page),
508        });
509    }
510
511    if request.command == "test" {
512        let selector = request
513            .selector
514            .as_deref()
515            .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
516        let (data, page) = coverage_test_query(
517            index,
518            CoverageTestQueryOptions {
519                run: &request.run_id,
520                view,
521                kind: request.kind.as_deref(),
522                runner: request.runner.as_deref(),
523                selector,
524                offset: request.offset,
525                limit: request.limit,
526            },
527        )?;
528        return Ok(IndexedQueryOutput {
529            command: "coverage.test",
530            data: IndexedQueryData::Test(Box::new(data)),
531            pagination: Some(page),
532        });
533    }
534
535    if request.command == "decision" {
536        let selector = request
537            .selector
538            .as_deref()
539            .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
540        let (data, page) = coverage_decision_query(
541            index,
542            CoverageDecisionQueryOptions {
543                run: &request.run_id,
544                view,
545                kind: request.kind.as_deref(),
546                runner: request.runner.as_deref(),
547                selector,
548                offset: request.offset,
549                limit: request.limit,
550            },
551        )?;
552        return Ok(IndexedQueryOutput {
553            command: "coverage.decision",
554            data: IndexedQueryData::Decision(Box::new(data)),
555            pagination: Some(page),
556        });
557    }
558
559    if request.command == "file-detail" {
560        let selector = request
561            .file
562            .as_deref()
563            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
564        let (data, page) = coverage_file_detail_query(
565            index,
566            CoverageFileDetailOptions {
567                run: &request.run_id,
568                view,
569                kind: request.kind.as_deref(),
570                runner: request.runner.as_deref(),
571                selector,
572                metric: request.metric,
573                offset: request.offset,
574                limit: request.limit,
575            },
576        )?;
577        return Ok(IndexedQueryOutput {
578            command: "coverage.file",
579            data: IndexedQueryData::FileDetail(Box::new(data)),
580            pagination: Some(page),
581        });
582    }
583
584    if request.command == "kinds" || request.command == "runners" {
585        let dimension = if request.command == "kinds" {
586            CoverageDimension::Kind
587        } else {
588            CoverageDimension::Runner
589        };
590        let filters = CoverageQueryFilters {
591            outcome: request.filter.clone(),
592            kind: request.kind.clone(),
593            runner: request.runner.clone(),
594        };
595        let (data, page) = coverage_dimension_query(
596            index,
597            CoverageDimensionQueryOptions {
598                run: &request.run_id,
599                view,
600                dimension,
601                filters,
602                offset: request.offset,
603                limit: request.limit,
604            },
605        )?;
606        let command = if request.command == "kinds" {
607            "coverage.kinds"
608        } else {
609            "coverage.runners"
610        };
611        return Ok(match data {
612            CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
613                command,
614                data: IndexedQueryData::Kinds(Box::new(data)),
615                pagination: Some(page),
616            },
617            CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
618                command,
619                data: IndexedQueryData::Runners(Box::new(data)),
620                pagination: Some(page),
621            },
622        });
623    }
624
625    if request.command == "file-decisions" {
626        let file = request
627            .file
628            .as_deref()
629            .ok_or(IndexedQueryError::MissingArgument("a file"))?;
630        let (data, page) = coverage_file_decisions_query(
631            index,
632            CoverageFileDecisionsOptions {
633                run: &request.run_id,
634                view,
635                kind: request.kind.as_deref(),
636                runner: request.runner.as_deref(),
637                file,
638                sort: request.sort.unwrap_or(DecisionSort::Location),
639                offset: request.offset,
640                limit: request.limit,
641            },
642        )?;
643        return Ok(IndexedQueryOutput {
644            command: "coverage.file",
645            data: IndexedQueryData::FileDecisions(Box::new(data)),
646            pagination: Some(page),
647        });
648    }
649
650    if request.command == "files" && request.group.as_deref() == Some("dir") {
651        let (data, page) = crate::coverage_query::coverage_areas_query(
652            index,
653            crate::coverage_query::CoverageAreasQueryOptions {
654                run: &request.run_id,
655                view,
656                metric: request.metric,
657                kind: request.kind.as_deref(),
658                runner: request.runner.as_deref(),
659                depth: request.depth.unwrap_or(1),
660                offset: request.offset,
661                limit: request.limit,
662            },
663        )?;
664        return Ok(IndexedQueryOutput {
665            command: "coverage.files",
666            data: IndexedQueryData::Areas(Box::new(data)),
667            pagination: Some(page),
668        });
669    }
670
671    let query = coverage_file_query(
672        index,
673        CoverageFileQueryOptions {
674            run: &request.run_id,
675            view,
676            metric: request.metric,
677            gaps_only: gaps_only.expect("files/gaps command"),
678            kind: request.kind.as_deref(),
679            runner: request.runner.as_deref(),
680            offset: request.offset,
681            limit: request.limit,
682        },
683    )?;
684    let command = if gaps_only == Some(true) {
685        "coverage.gaps"
686    } else {
687        "coverage.files"
688    };
689    Ok(match query.data {
690        CoverageFileQueryData::Files(data) => IndexedQueryOutput {
691            command,
692            data: IndexedQueryData::Files(Box::new(data)),
693            pagination: Some(query.pagination),
694        },
695        CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
696            command,
697            data: IndexedQueryData::Gaps(Box::new(data)),
698            pagination: Some(query.pagination),
699        },
700    })
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
709        let source =
710            IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
711        assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
712        assert_eq!(source.message, "Source file not found: missing.ts");
713        assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
714
715        let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
716            kind: Some("e2e".into()),
717            runner: Some("playwright".into()),
718        })
719        .agent_error();
720        assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
721        assert_eq!(
722            filtered.message,
723            "No tests match kind=e2e, runner=playwright"
724        );
725        assert_eq!(
726            filtered.details,
727            Some(json!({ "kind": "e2e", "runner": "playwright" }))
728        );
729    }
730
731    #[test]
732    fn maps_solver_limits_without_losing_machine_readable_details() {
733        let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
734            candidate_tests: 200,
735            obligations: 900,
736            explored_states: 5_001,
737            max_states: 5_000,
738            target: 100.0,
739            metric: MinimizeMetric::All,
740        })
741        .agent_error();
742        assert_eq!(
743            error.code,
744            agent_json::ErrorCode::MinimizationComplexityLimit
745        );
746        assert!(error.message.contains("5,000-state safety budget"));
747        assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
748    }
749}