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 coverage_waivers::CoverageWaiverEvaluation,
29};
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct IndexedQueryRequest {
34 pub run_id: String,
35 pub filter: String,
36 pub command: String,
37 #[serde(default = "default_metric")]
38 pub metric: MinimizeMetric,
39 pub kind: Option<String>,
40 pub runner: Option<String>,
41 pub file: Option<String>,
42 pub line: Option<usize>,
43 pub selector: Option<String>,
44 pub sort: Option<DecisionSort>,
45 pub valid: Option<bool>,
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 Covers(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
353pub 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 execute_indexed_query_with_waivers(
367 index: &CoverageIndex<'_>,
368 report: Option<&CoverageReport>,
369 request: &IndexedQueryRequest,
370 newer: Option<NewerQuery<'_>>,
371 waivers: Option<&CoverageWaiverEvaluation>,
372) -> Result<String, IndexedQueryError> {
373 query_indexed_with_waivers(index, report, request, newer, waivers)?.agent_json()
374}
375
376pub fn query_indexed(
377 index: &CoverageIndex<'_>,
378 report: Option<&CoverageReport>,
379 request: &IndexedQueryRequest,
380 newer: Option<NewerQuery<'_>>,
381) -> Result<IndexedQueryOutput, IndexedQueryError> {
382 query_indexed_with_waivers(index, report, request, newer, None)
383}
384
385pub fn query_indexed_with_waivers(
386 index: &CoverageIndex<'_>,
387 report: Option<&CoverageReport>,
388 request: &IndexedQueryRequest,
389 newer: Option<NewerQuery<'_>>,
390 waivers: Option<&CoverageWaiverEvaluation>,
391) -> Result<IndexedQueryOutput, IndexedQueryError> {
392 let view = request.view()?;
393 let gaps_only = match request.command.as_str() {
394 "files" => Some(false),
395 "gaps" => Some(true),
396 "file-decisions" | "kinds" | "runners" | "summary" | "scope" | "covers" | "test"
397 | "decision" | "file-detail" | "minimize" | "diff" => None,
398 _ => {
399 return Err(IndexedQueryError::UnsupportedCommand(
400 request.command.clone(),
401 ));
402 }
403 };
404
405 if request.command == "diff" {
406 let newer = newer.ok_or(IndexedQueryError::MissingNewerRun)?;
407 let (data, page) = coverage_diff_query(
408 index,
409 newer.index,
410 CoverageDiffQueryOptions {
411 older_run: &request.run_id,
412 newer_run: newer.run_id,
413 view,
414 kind: request.kind.as_deref(),
415 runner: request.runner.as_deref(),
416 offset: request.offset,
417 limit: request.limit,
418 },
419 )?;
420 return Ok(IndexedQueryOutput {
421 command: "diff",
422 data: IndexedQueryData::Diff(Box::new(data)),
423 pagination: Some(page),
424 });
425 }
426
427 if request.command == "minimize" {
428 let report = report.ok_or(IndexedQueryError::MissingReport)?;
429 let coverage_view = match view {
430 CoverageViewId::All => &report.view,
431 CoverageViewId::Passed => &report.filters.passed,
432 CoverageViewId::Failed => &report.filters.failed,
433 };
434 let (data, page) = coverage_minimize_query(
435 coverage_view,
436 CoverageMinimizeQueryOptions {
437 run: &request.run_id,
438 view_id: view,
439 kind: request.kind.as_deref(),
440 runner: request.runner.as_deref(),
441 target: request.target.unwrap_or(100.0),
442 metric: request.metric,
443 max_states: request.max_states.unwrap_or(5_000),
444 offset: request.offset,
445 limit: request.limit,
446 },
447 )?;
448 return Ok(IndexedQueryOutput {
449 command: "coverage.minimize",
450 data: IndexedQueryData::Minimize(Box::new(data)),
451 pagination: Some(page),
452 });
453 }
454
455 if request.command == "summary" {
456 let mut data = coverage_summary_query(
457 index,
458 CoverageSummaryQueryOptions {
459 run: &request.run_id,
460 view,
461 kind: request.kind.as_deref(),
462 runner: request.runner.as_deref(),
463 valid: request.valid.unwrap_or(false),
464 stale: request.stale.unwrap_or(false),
465 stale_reasons: request.stale_reasons.clone().unwrap_or_default(),
466 },
467 )?;
468 if let Some(waivers) = waivers {
469 data.waivers =
470 Some(waivers.summary(data.coverage.covered_conditions, data.coverage.conditions));
471 }
472 return Ok(IndexedQueryOutput {
473 command: "coverage.summary",
474 data: IndexedQueryData::Summary(Box::new(data)),
475 pagination: None,
476 });
477 }
478
479 if request.command == "scope" {
480 let (data, page) = coverage_scope_query(
481 index,
482 CoverageScopeQueryOptions {
483 run: &request.run_id,
484 view,
485 kind: request.kind.as_deref(),
486 runner: request.runner.as_deref(),
487 offset: request.offset,
488 limit: request.limit,
489 },
490 )?;
491 return Ok(IndexedQueryOutput {
492 command: "coverage.scope",
493 data: IndexedQueryData::Scope(Box::new(data)),
494 pagination: Some(page),
495 });
496 }
497
498 if request.command == "covers" {
499 let file = request
500 .file
501 .as_deref()
502 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
503 let line = request
504 .line
505 .ok_or(IndexedQueryError::MissingArgument("a line"))?;
506 let (data, page) = coverage_covers_query(
507 index,
508 CoverageCoversQueryOptions {
509 run: &request.run_id,
510 view,
511 kind: request.kind.as_deref(),
512 runner: request.runner.as_deref(),
513 file,
514 line,
515 offset: request.offset,
516 limit: request.limit,
517 },
518 )?;
519 return Ok(IndexedQueryOutput {
520 command: "coverage.covers",
521 data: IndexedQueryData::Covers(Box::new(data)),
522 pagination: Some(page),
523 });
524 }
525
526 if request.command == "test" {
527 let selector = request
528 .selector
529 .as_deref()
530 .ok_or(IndexedQueryError::MissingArgument("a test selector"))?;
531 let (data, page) = coverage_test_query(
532 index,
533 CoverageTestQueryOptions {
534 run: &request.run_id,
535 view,
536 kind: request.kind.as_deref(),
537 runner: request.runner.as_deref(),
538 selector,
539 offset: request.offset,
540 limit: request.limit,
541 },
542 )?;
543 return Ok(IndexedQueryOutput {
544 command: "coverage.test",
545 data: IndexedQueryData::Test(Box::new(data)),
546 pagination: Some(page),
547 });
548 }
549
550 if request.command == "decision" {
551 let selector = request
552 .selector
553 .as_deref()
554 .ok_or(IndexedQueryError::MissingArgument("a decision selector"))?;
555 let (mut data, page) = coverage_decision_query(
556 index,
557 CoverageDecisionQueryOptions {
558 run: &request.run_id,
559 view,
560 kind: request.kind.as_deref(),
561 runner: request.runner.as_deref(),
562 selector,
563 offset: request.offset,
564 limit: request.limit,
565 },
566 )?;
567 if let Some(waivers) = waivers
568 && let crate::coverage_query::CoverageDecisionData::Detail(detail) = &mut data
569 {
570 for decision in &mut detail.decisions {
571 if let Some(conditions) = waivers.waived_by_decision.get(&decision.meta.id) {
572 for condition in &mut decision.conditions {
573 if let Some(waiver) = conditions.get(&condition.index) {
574 condition.waived = Some(true);
575 condition.waiver_reason = Some(waiver.reason.clone());
576 }
577 }
578 }
579 }
580 }
581 return Ok(IndexedQueryOutput {
582 command: "coverage.decision",
583 data: IndexedQueryData::Decision(Box::new(data)),
584 pagination: Some(page),
585 });
586 }
587
588 if request.command == "file-detail" {
589 let selector = request
590 .file
591 .as_deref()
592 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
593 let (mut data, page) = coverage_file_detail_query(
594 index,
595 CoverageFileDetailOptions {
596 run: &request.run_id,
597 view,
598 kind: request.kind.as_deref(),
599 runner: request.runner.as_deref(),
600 selector,
601 metric: request.metric,
602 offset: request.offset,
603 limit: request.limit,
604 },
605 )?;
606 if let Some(waivers) = waivers {
607 data.counts.waived_mcdc_conditions = waivers
608 .applied_by_file
609 .get(&data.file)
610 .copied()
611 .unwrap_or(0);
612 for obligation in &mut data.obligations {
613 if let crate::coverage_query::CoverageFileObligation::Mcdc(obligation) = obligation
614 && let Some(waiver) = waivers
615 .waived_by_decision
616 .get(&obligation.id)
617 .and_then(|conditions| conditions.get(&obligation.condition_index))
618 {
619 obligation.waived = Some(true);
620 obligation.waiver_reason = Some(waiver.reason.clone());
621 }
622 }
623 }
624 return Ok(IndexedQueryOutput {
625 command: "coverage.file",
626 data: IndexedQueryData::FileDetail(Box::new(data)),
627 pagination: Some(page),
628 });
629 }
630
631 if request.command == "kinds" || request.command == "runners" {
632 let dimension = if request.command == "kinds" {
633 CoverageDimension::Kind
634 } else {
635 CoverageDimension::Runner
636 };
637 let filters = CoverageQueryFilters {
638 outcome: request.filter.clone(),
639 kind: request.kind.clone(),
640 runner: request.runner.clone(),
641 };
642 let (data, page) = coverage_dimension_query(
643 index,
644 CoverageDimensionQueryOptions {
645 run: &request.run_id,
646 view,
647 dimension,
648 filters,
649 offset: request.offset,
650 limit: request.limit,
651 },
652 )?;
653 let command = if request.command == "kinds" {
654 "coverage.kinds"
655 } else {
656 "coverage.runners"
657 };
658 return Ok(match data {
659 CoverageDimensionQueryData::Kinds(data) => IndexedQueryOutput {
660 command,
661 data: IndexedQueryData::Kinds(Box::new(data)),
662 pagination: Some(page),
663 },
664 CoverageDimensionQueryData::Runners(data) => IndexedQueryOutput {
665 command,
666 data: IndexedQueryData::Runners(Box::new(data)),
667 pagination: Some(page),
668 },
669 });
670 }
671
672 if request.command == "file-decisions" {
673 let file = request
674 .file
675 .as_deref()
676 .ok_or(IndexedQueryError::MissingArgument("a file"))?;
677 let (data, page) = coverage_file_decisions_query(
678 index,
679 CoverageFileDecisionsOptions {
680 run: &request.run_id,
681 view,
682 kind: request.kind.as_deref(),
683 runner: request.runner.as_deref(),
684 file,
685 waived_by_decision: waivers.map(|waivers| &waivers.waived_by_decision),
686 sort: request.sort.unwrap_or(DecisionSort::Location),
687 offset: request.offset,
688 limit: request.limit,
689 },
690 )?;
691 return Ok(IndexedQueryOutput {
692 command: "coverage.file",
693 data: IndexedQueryData::FileDecisions(Box::new(data)),
694 pagination: Some(page),
695 });
696 }
697
698 let mut query = coverage_file_query(
699 index,
700 CoverageFileQueryOptions {
701 run: &request.run_id,
702 view,
703 metric: request.metric,
704 gaps_only: gaps_only.expect("files/gaps command"),
705 kind: request.kind.as_deref(),
706 runner: request.runner.as_deref(),
707 offset: request.offset,
708 limit: request.limit,
709 },
710 )?;
711 if let Some(waivers) = waivers {
712 let rows = match &mut query.data {
713 CoverageFileQueryData::Files(data) => &mut data.files,
714 CoverageFileQueryData::Gaps(data) => &mut data.gaps,
715 };
716 for row in rows {
717 row.waived_mcdc_conditions =
718 Some(waivers.applied_by_file.get(&row.file).copied().unwrap_or(0));
719 }
720 }
721 let command = if gaps_only == Some(true) {
722 "coverage.gaps"
723 } else {
724 "coverage.files"
725 };
726 Ok(match query.data {
727 CoverageFileQueryData::Files(data) => IndexedQueryOutput {
728 command,
729 data: IndexedQueryData::Files(Box::new(data)),
730 pagination: Some(query.pagination),
731 },
732 CoverageFileQueryData::Gaps(data) => IndexedQueryOutput {
733 command,
734 data: IndexedQueryData::Gaps(Box::new(data)),
735 pagination: Some(query.pagination),
736 },
737 })
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn maps_typed_selection_failures_to_the_frozen_agent_contract() {
746 let source =
747 IndexedQueryError::Query(QueryError::SourceNotFound("missing.ts".into())).agent_error();
748 assert_eq!(source.code, agent_json::ErrorCode::SourceNotFound);
749 assert_eq!(source.message, "Source file not found: missing.ts");
750 assert_eq!(source.details, Some(json!({ "selector": "missing.ts" })));
751
752 let filtered = IndexedQueryError::Query(QueryError::TestFilterEmpty {
753 kind: Some("e2e".into()),
754 runner: Some("playwright".into()),
755 })
756 .agent_error();
757 assert_eq!(filtered.code, agent_json::ErrorCode::TestFilterEmpty);
758 assert_eq!(
759 filtered.message,
760 "No tests match kind=e2e, runner=playwright"
761 );
762 assert_eq!(
763 filtered.details,
764 Some(json!({ "kind": "e2e", "runner": "playwright" }))
765 );
766 }
767
768 #[test]
769 fn maps_solver_limits_without_losing_machine_readable_details() {
770 let error = IndexedQueryError::Query(QueryError::ComplexityLimit {
771 candidate_tests: 200,
772 obligations: 900,
773 explored_states: 5_001,
774 max_states: 5_000,
775 target: 100.0,
776 metric: MinimizeMetric::All,
777 })
778 .agent_error();
779 assert_eq!(
780 error.code,
781 agent_json::ErrorCode::MinimizationComplexityLimit
782 );
783 assert!(error.message.contains("5,000-state safety budget"));
784 assert_eq!(error.details.as_ref().unwrap()["exploredStates"], 5_001);
785 }
786}