1use 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 #[serde(default)]
57 pub group: Option<String>,
58 #[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
362pub 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}