1use thiserror::Error;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Span {
20 pub start: usize,
22 pub end: usize,
24 pub line: usize,
26 pub col: usize,
28}
29
30impl Span {
31 pub fn new(start: usize, end: usize, line: usize, col: usize) -> Self {
33 Self {
34 start,
35 end,
36 line,
37 col,
38 }
39 }
40
41 pub fn zero() -> Self {
44 Self {
45 start: 0,
46 end: 0,
47 line: 1,
48 col: 1,
49 }
50 }
51}
52
53impl std::fmt::Display for Span {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 write!(f, "{}:{}", self.line, self.col)
56 }
57}
58
59#[derive(Debug, Error)]
68pub enum CalError {
69 #[error("CAL-E001: Query exceeds maximum length ({length} bytes, max {max})")]
72 QueryTooLong {
73 length: usize,
74 max: usize,
75 span: Option<Span>,
76 },
77
78 #[error("CAL-E002: Unexpected token: expected {expected}, found {found}")]
80 UnexpectedToken {
81 expected: String,
82 found: String,
83 span: Option<Span>,
84 suggestion: Option<String>,
85 },
86
87 #[error("CAL-E003: Unknown grain type \"{found}\"")]
90 UnknownGrainType {
91 found: String,
92 span: Option<Span>,
93 suggestion: Option<String>,
94 },
95
96 #[error("CAL-E004: Unknown field \"{found}\"")]
99 UnknownField {
100 found: String,
101 span: Option<Span>,
102 suggestion: Option<String>,
103 },
104
105 #[error("CAL-E005: Unterminated string literal")]
107 UnterminatedString { span: Option<Span> },
108
109 #[error("CAL-E006: Invalid number \"{found}\"")]
111 InvalidNumber { found: String, span: Option<Span> },
112
113 #[error("CAL-E007: Nesting too deep ({depth} levels, max {max})")]
116 NestingTooDeep {
117 depth: usize,
118 max: usize,
119 span: Option<Span>,
120 },
121
122 #[error("CAL-E008: Unbound parameter \"${name}\"")]
124 UnboundParameter { name: String, span: Option<Span> },
125
126 #[error("CAL-E009: Duplicate parameter \"${name}\"")]
128 DuplicateParameter { name: String, span: Option<Span> },
129
130 #[error("CAL-E010: Limit {value} exceeds maximum allowed ({max})")]
132 LimitExceeded {
133 value: u64,
134 max: u64,
135 span: Option<Span>,
136 },
137
138 #[error("CAL-E011: IN set too large ({count} elements, max {max})")]
140 InSetTooLarge {
141 count: usize,
142 max: usize,
143 span: Option<Span>,
144 },
145
146 #[error("CAL-E012: Too many pipeline stages ({count}, max {max})")]
148 TooManyPipelineStages {
149 count: usize,
150 max: usize,
151 span: Option<Span>,
152 },
153
154 #[error("CAL-E013: Too many set operands ({count}, max {max})")]
157 TooManySetOperands {
158 count: usize,
159 max: usize,
160 span: Option<Span>,
161 },
162
163 #[error("CAL-E014: Empty query")]
165 EmptyQuery { span: Option<Span> },
166
167 #[error("CAL-E015: Invalid hash \"{found}\"")]
169 InvalidHash { found: String, span: Option<Span> },
170
171 #[error("CAL-E016: Reason too long ({length} chars, max {max})")]
174 ReasonTooLong {
175 length: usize,
176 max: usize,
177 span: Option<Span>,
178 },
179
180 #[error("CAL-E017: Unknown EVOLVE field \"{found}\"")]
183 UnknownEvolveField {
184 found: String,
185 span: Option<Span>,
186 suggestion: Option<String>,
187 },
188
189 #[error("CAL-E018: Missing BECAUSE reason clause")]
192 MissingReason { span: Option<Span> },
193
194 #[error("CAL-E019: Missing SET clause")]
197 MissingSetClause { span: Option<Span> },
198
199 #[error("CAL-E020: Incompatible types: {left} vs {right}")]
203 IncompatibleTypes {
204 left: String,
205 right: String,
206 span: Option<Span>,
207 suggestion: Option<String>,
208 },
209
210 #[error(
213 "CAL-E021: Pipeline type mismatch: stage \"{stage}\" expected {expected}, got {found}"
214 )]
215 PipelineTypeMismatch {
216 stage: String,
217 expected: String,
218 found: String,
219 span: Option<Span>,
220 },
221
222 #[error("CAL-E022: Extractor \"{extractor}\" requires facts, got {found}")]
225 ExtractorRequiresFacts {
226 extractor: String,
227 found: String,
228 span: Option<Span>,
229 },
230
231 #[error("CAL-E030: Budget exceeded: {detail}")]
235 BudgetExceeded { detail: String, span: Option<Span> },
236
237 #[error("CAL-E031: Query timeout after {elapsed_ms}ms (limit {limit_ms}ms)")]
239 QueryTimeout {
240 elapsed_ms: u64,
241 limit_ms: u64,
242 span: Option<Span>,
243 },
244
245 #[error("CAL-E092: Invalid query: {detail}")]
251 InvalidQuery { detail: String, span: Option<Span> },
252
253 #[error("CAL-E090: Crypto error during query execution: {detail}")]
261 CryptoError { detail: String, span: Option<Span> },
262
263 #[error("CAL-E091: Grain not found for hash \"{hash}\"")]
267 HashNotFound { hash: String, span: Option<Span> },
268
269 #[error("CAL-E060: Field \"{field}\" is not available on grain type \"{grain_type}\"")]
274 FieldNotOnGrainType {
275 field: String,
276 grain_type: String,
277 span: Option<Span>,
278 suggestion: Option<String>,
279 },
280
281 #[error("CAL-E061: Engine-level field \"{field}\" cannot be used {context}; it narrows the scan and has no per-grain value to filter on")]
287 EngineFieldNotFilterable {
288 field: String,
289 context: String,
291 span: Option<Span>,
292 },
293
294 #[error("CAL-E032: Too many ASSEMBLE sources ({count}, max {max})")]
297 AssembleTooManySources {
298 count: usize,
299 max: usize,
300 span: Option<Span>,
301 },
302
303 #[error("CAL-E033: ASSEMBLE budget exceeded ({value} {unit}, max {max})")]
305 AssembleBudgetExceeded {
306 value: u64,
307 max: u64,
308 unit: String,
309 span: Option<Span>,
310 },
311
312 #[error("CAL-E034: Duplicate ASSEMBLE source label \"{label}\"")]
314 AssembleDuplicateLabel { label: String, span: Option<Span> },
315
316 #[error(
324 "CAL-E122: pinned ASSEMBLE source(s) [{}] need {required} tokens but BUDGET is {budget} — a PIN is never summarised or dropped, so raise the budget or shorten the pinned text",
325 labels.join(", ")
326 )]
327 AssemblePinnedBudgetExceeded {
328 labels: Vec<String>,
329 required: u32,
330 budget: u32,
331 span: Option<Span>,
332 },
333
334 #[error("CAL-E035: PRIORITY references unknown source label \"{label}\"")]
336 AssemblePriorityMismatch { label: String, span: Option<Span> },
337
338 #[error("CAL-E036: Too many LET bindings ({count}, max {max})")]
341 TooManyLetBindings {
342 count: usize,
343 max: usize,
344 span: Option<Span>,
345 },
346
347 #[error("CAL-E037: Circular reference in LET binding \"${name}\"")]
349 LetCircularReference { name: String, span: Option<Span> },
350
351 #[error("CAL-E038: LET chain depth exceeded ({depth}, max {max})")]
353 LetDepthExceeded {
354 depth: usize,
355 max: usize,
356 span: Option<Span>,
357 },
358
359 #[error("CAL-E039: Too many COALESCE branches ({count}, max {max})")]
362 CoalesceTooManyBranches {
363 count: usize,
364 max: usize,
365 span: Option<Span>,
366 },
367
368 #[error("CAL-E071: ASSEMBLE timeout after {elapsed_ms}ms (limit {limit_ms}ms)")]
371 AssembleTimeout {
372 elapsed_ms: u64,
373 limit_ms: u64,
374 span: Option<Span>,
375 },
376
377 #[error("CAL-E117: Template nesting too deep (max {max} levels)")]
381 TemplateNestingTooDeep { max: usize, span: Option<Span> },
382
383 #[error("CAL-E118: Too many templates ({count}, max {max})")]
385 TooManyTemplates {
386 count: usize,
387 max: usize,
388 span: Option<Span>,
389 },
390
391 #[error("CAL-E119: Template \"{name}\" cannot extend the 'data' preset")]
394 CannotExtendData { name: String, span: Option<Span> },
395
396 #[error("CAL-E120: Invalid JSON+CAL: {detail}")]
399 InvalidJsonCal { detail: String, span: Option<Span> },
400
401 #[error("CAL-E121: Not authorized: {detail}")]
407 NotAuthorized { detail: String, span: Option<Span> },
408
409 #[error("CAL-E070: Invalid UTF-8 or unsafe character in query: {detail}")]
414 InvalidUtf8 { detail: String, span: Option<Span> },
415
416 #[error("CAL-E080: ACCUMULATE requires at least one ADD operation")]
419 MissingAccumulateOps { span: Option<Span> },
420
421 #[error(
423 "CAL-E081: ADD delta applied to non-numeric field \"{field}\" (current value: {current})"
424 )]
425 AccumulateNonNumericField {
426 field: String,
427 current: String,
428 span: Option<Span>,
429 },
430
431 #[error("CAL-E082: No grain found for ACCUMULATE target (subject=\"{subject}\", relation=\"{relation}\")")]
433 AccumulateTipNotFound {
434 subject: String,
435 relation: String,
436 span: Option<Span>,
437 },
438
439 #[error(
446 "CAL-E083: ACCUMULATE retry budget exhausted (subject=\"{subject}\", relation=\"{relation}\")"
447 )]
448 AccumulateRetryExhausted {
449 subject: String,
450 relation: String,
451 span: Option<Span>,
452 },
453
454 #[error("CAL-E084: ACCUMULATE internal failure")]
459 AccumulateInternal { span: Option<Span> },
460
461 #[error(
468 "CAL-E085: ACCUMULATE backpressure: per-key inflight cap exceeded (subject=\"{subject}\", relation=\"{relation}\")"
469 )]
470 AccumulateBackpressureRejected {
471 subject: String,
472 relation: String,
473 span: Option<Span>,
474 },
475
476 #[error("CAL-E040: Template too large ({size} bytes, max {max})")]
479 TemplateTooLarge {
480 size: usize,
481 max: usize,
482 span: Option<Span>,
483 },
484
485 #[error("CAL-E041: Nested {{{{#each}}}} blocks are not allowed")]
487 TemplateNestedEach { span: Option<Span> },
488
489 #[error("CAL-E042: Unknown template variable \"{name}\"")]
491 TemplateUnknownVariable {
492 name: String,
493 span: Option<Span>,
494 suggestion: Option<String>,
495 },
496
497 #[error("CAL-E043: Unknown template filter \"{name}\"")]
499 TemplateUnknownFilter { name: String, span: Option<Span> },
500
501 #[error("CAL-E115: Invalid template name \"{name}\"")]
504 TemplateInvalidName { name: String, span: Option<Span> },
505
506 #[error("CAL-E044: Tier 1 (Evolve) is not enabled: {statement}")]
510 Tier1NotEnabled {
511 statement: String,
512 span: Option<Span>,
513 },
514
515 #[error("CAL-E045: Template \"{name}\" not found")]
517 TemplateNotFound { name: String, span: Option<Span> },
518
519 #[error("CAL-E046: Built-in template \"{name}\" cannot be modified")]
521 TemplateBuiltinImmutable { name: String, span: Option<Span> },
522
523 #[error("CAL-E047: Template \"{name}\" extends unknown parent \"{parent}\"")]
525 TemplateParentNotFound {
526 name: String,
527 parent: String,
528 span: Option<Span>,
529 },
530
531 #[error("CAL-E048: Template \"{name}\" exceeds maximum inheritance depth (1 level)")]
533 TemplateInheritanceDepth { name: String, span: Option<Span> },
534
535 #[error("CAL-E049: Template syntax error: {detail}")]
537 TemplateSyntaxError { detail: String, span: Option<Span> },
538
539 #[error("CAL-E050: Rendered output too large ({size} bytes, max {max})")]
541 RenderOutputTooLarge {
542 size: usize,
543 max: usize,
544 span: Option<Span>,
545 },
546
547 #[error("CAL-E051: Saved query \"{name}\" not found")]
550 QueryNotFound { name: String, span: Option<Span> },
551
552 #[error("CAL-E052: Saved query \"{name}\" already exists")]
554 DuplicateQueryName { name: String, span: Option<Span> },
555
556 #[error("CAL-E053: Too many saved queries ({count}, max {max})")]
558 TooManyQueries {
559 count: usize,
560 max: usize,
561 span: Option<Span>,
562 },
563
564 #[error("CAL-E054: Query body too large ({size} bytes, max {max})")]
566 QueryBodyTooLarge {
567 size: usize,
568 max: usize,
569 span: Option<Span>,
570 },
571
572 #[error("CAL-E055: Too many query parameters ({count}, max {max})")]
574 TooManyQueryParams {
575 count: usize,
576 max: usize,
577 span: Option<Span>,
578 },
579
580 #[error("CAL-E056: Missing required parameter \"${name}\" for query \"{query}\"")]
582 MissingQueryParam {
583 name: String,
584 query: String,
585 span: Option<Span>,
586 },
587
588 #[error("CAL-E057: RUN is not allowed inside DEFINE QUERY body")]
590 RecursiveQuery { span: Option<Span> },
591
592 #[error("CAL-E058: Write statement \"{stmt}\" not allowed in DEFINE QUERY body")]
594 WriteInQueryBody { stmt: String, span: Option<Span> },
595
596 #[error("CAL-E059: Invalid query body: {detail}")]
598 InvalidQueryBody { detail: String, span: Option<Span> },
599
600 #[error("CAL-E100: Unsupported CAL version {version}")]
604 UnsupportedVersion { version: u32, span: Option<Span> },
605
606 #[error("CAL-E110: Too many formats in multi-format list ({count}, max {max})")]
609 TooManyFormats {
610 count: usize,
611 max: usize,
612 span: Option<Span>,
613 },
614
615 #[error("CAL-E111: Too many user variables ({count}, max {max})")]
618 TooManyUserVars {
619 count: usize,
620 max: usize,
621 span: Option<Span>,
622 },
623
624 #[error("CAL-E112: User variable \"{key}\" too large ({size} bytes, max {max})")]
626 UserVarTooLarge {
627 key: String,
628 size: usize,
629 max: usize,
630 span: Option<Span>,
631 },
632
633 #[error("CAL-E113: Duplicate format key \"{key}\" in multi-format list")]
636 DuplicateFormatKey { key: String, span: Option<Span> },
637
638 #[error("CAL-E114: insufficient scope: '{statement}' requires '{required}' scope")]
641 InsufficientScope { required: String, statement: String },
642
643 #[error(
649 "CAL-E116: WITH {feature} needs an external LLM and is not implemented in Areev — \
650 the engine takes no LLM dependency by design (these belong in your agent loop). \
651 Want it built in? Open a feature request at \
652 https://github.com/AreevAI/areev/issues — we'll build it if there's demand."
653 )]
654 LlmFeatureUnavailable { feature: String },
655}
656
657impl CalError {
658 pub fn code(&self) -> &'static str {
660 match self {
661 Self::QueryTooLong { .. } => "CAL-E001",
662 Self::UnexpectedToken { .. } => "CAL-E002",
663 Self::UnknownGrainType { .. } => "CAL-E003",
664 Self::UnknownField { .. } => "CAL-E004",
665 Self::UnterminatedString { .. } => "CAL-E005",
666 Self::InvalidNumber { .. } => "CAL-E006",
667 Self::NestingTooDeep { .. } => "CAL-E007",
668 Self::UnboundParameter { .. } => "CAL-E008",
669 Self::DuplicateParameter { .. } => "CAL-E009",
670 Self::LimitExceeded { .. } => "CAL-E010",
671 Self::InSetTooLarge { .. } => "CAL-E011",
672 Self::TooManyPipelineStages { .. } => "CAL-E012",
673 Self::TooManySetOperands { .. } => "CAL-E013",
674 Self::EmptyQuery { .. } => "CAL-E014",
675 Self::InvalidHash { .. } => "CAL-E015",
676 Self::ReasonTooLong { .. } => "CAL-E016",
677 Self::UnknownEvolveField { .. } => "CAL-E017",
678 Self::MissingReason { .. } => "CAL-E018",
679 Self::MissingSetClause { .. } => "CAL-E019",
680 Self::IncompatibleTypes { .. } => "CAL-E020",
681 Self::PipelineTypeMismatch { .. } => "CAL-E021",
682 Self::ExtractorRequiresFacts { .. } => "CAL-E022",
683 Self::BudgetExceeded { .. } => "CAL-E030",
684 Self::QueryTimeout { .. } => "CAL-E031",
685 Self::CryptoError { .. } => "CAL-E090",
686 Self::HashNotFound { .. } => "CAL-E091",
687 Self::InvalidQuery { .. } => "CAL-E092",
688 Self::FieldNotOnGrainType { .. } => "CAL-E060",
689 Self::EngineFieldNotFilterable { .. } => "CAL-E061",
690 Self::AssembleTooManySources { .. } => "CAL-E032",
691 Self::AssembleBudgetExceeded { .. } => "CAL-E033",
692 Self::AssembleDuplicateLabel { .. } => "CAL-E034",
693 Self::AssemblePinnedBudgetExceeded { .. } => "CAL-E122",
694 Self::AssemblePriorityMismatch { .. } => "CAL-E035",
695 Self::TooManyLetBindings { .. } => "CAL-E036",
696 Self::LetCircularReference { .. } => "CAL-E037",
697 Self::LetDepthExceeded { .. } => "CAL-E038",
698 Self::CoalesceTooManyBranches { .. } => "CAL-E039",
699 Self::AssembleTimeout { .. } => "CAL-E071",
700 Self::TemplateNestingTooDeep { .. } => "CAL-E117",
701 Self::TooManyTemplates { .. } => "CAL-E118",
702 Self::CannotExtendData { .. } => "CAL-E119",
703 Self::InvalidJsonCal { .. } => "CAL-E120",
704 Self::NotAuthorized { .. } => "CAL-E121",
705 Self::InvalidUtf8 { .. } => "CAL-E070",
706 Self::TemplateTooLarge { .. } => "CAL-E040",
707 Self::TemplateNestedEach { .. } => "CAL-E041",
708 Self::TemplateUnknownVariable { .. } => "CAL-E042",
709 Self::TemplateUnknownFilter { .. } => "CAL-E043",
710 Self::TemplateInvalidName { .. } => "CAL-E115",
711 Self::Tier1NotEnabled { .. } => "CAL-E044",
712 Self::TemplateNotFound { .. } => "CAL-E045",
713 Self::TemplateBuiltinImmutable { .. } => "CAL-E046",
714 Self::TemplateParentNotFound { .. } => "CAL-E047",
715 Self::TemplateInheritanceDepth { .. } => "CAL-E048",
716 Self::TemplateSyntaxError { .. } => "CAL-E049",
717 Self::RenderOutputTooLarge { .. } => "CAL-E050",
718 Self::UnsupportedVersion { .. } => "CAL-E100",
719 Self::TooManyFormats { .. } => "CAL-E110",
720 Self::TooManyUserVars { .. } => "CAL-E111",
721 Self::UserVarTooLarge { .. } => "CAL-E112",
722 Self::DuplicateFormatKey { .. } => "CAL-E113",
723 Self::InsufficientScope { .. } => "CAL-E114",
724 Self::LlmFeatureUnavailable { .. } => "CAL-E116",
725 Self::MissingAccumulateOps { .. } => "CAL-E080",
726 Self::AccumulateNonNumericField { .. } => "CAL-E081",
727 Self::AccumulateTipNotFound { .. } => "CAL-E082",
728 Self::AccumulateRetryExhausted { .. } => "CAL-E083",
729 Self::AccumulateInternal { .. } => "CAL-E084",
730 Self::AccumulateBackpressureRejected { .. } => "CAL-E085",
731 Self::QueryNotFound { .. } => "CAL-E051",
732 Self::DuplicateQueryName { .. } => "CAL-E052",
733 Self::TooManyQueries { .. } => "CAL-E053",
734 Self::QueryBodyTooLarge { .. } => "CAL-E054",
735 Self::TooManyQueryParams { .. } => "CAL-E055",
736 Self::MissingQueryParam { .. } => "CAL-E056",
737 Self::RecursiveQuery { .. } => "CAL-E057",
738 Self::WriteInQueryBody { .. } => "CAL-E058",
739 Self::InvalidQueryBody { .. } => "CAL-E059",
740 }
741 }
742
743 pub fn span(&self) -> Option<Span> {
745 match self {
746 Self::QueryTooLong { span, .. }
747 | Self::UnexpectedToken { span, .. }
748 | Self::UnknownGrainType { span, .. }
749 | Self::UnknownField { span, .. }
750 | Self::UnterminatedString { span, .. }
751 | Self::InvalidNumber { span, .. }
752 | Self::NestingTooDeep { span, .. }
753 | Self::UnboundParameter { span, .. }
754 | Self::DuplicateParameter { span, .. }
755 | Self::LimitExceeded { span, .. }
756 | Self::InSetTooLarge { span, .. }
757 | Self::TooManyPipelineStages { span, .. }
758 | Self::TooManySetOperands { span, .. }
759 | Self::EmptyQuery { span, .. }
760 | Self::InvalidHash { span, .. }
761 | Self::ReasonTooLong { span, .. }
762 | Self::UnknownEvolveField { span, .. }
763 | Self::MissingReason { span, .. }
764 | Self::MissingSetClause { span, .. }
765 | Self::IncompatibleTypes { span, .. }
766 | Self::PipelineTypeMismatch { span, .. }
767 | Self::ExtractorRequiresFacts { span, .. }
768 | Self::BudgetExceeded { span, .. }
769 | Self::QueryTimeout { span, .. }
770 | Self::InvalidQuery { span, .. }
771 | Self::CryptoError { span, .. }
772 | Self::FieldNotOnGrainType { span, .. }
773 | Self::EngineFieldNotFilterable { span, .. }
774 | Self::AssemblePinnedBudgetExceeded { span, .. }
775 | Self::AssembleTooManySources { span, .. }
776 | Self::AssembleBudgetExceeded { span, .. }
777 | Self::AssembleDuplicateLabel { span, .. }
778 | Self::AssemblePriorityMismatch { span, .. }
779 | Self::TooManyLetBindings { span, .. }
780 | Self::LetCircularReference { span, .. }
781 | Self::LetDepthExceeded { span, .. }
782 | Self::CoalesceTooManyBranches { span, .. }
783 | Self::AssembleTimeout { span, .. }
784 | Self::InvalidJsonCal { span, .. }
785 | Self::NotAuthorized { span, .. }
786 | Self::TemplateTooLarge { span, .. }
787 | Self::TemplateNestedEach { span, .. }
788 | Self::TemplateUnknownVariable { span, .. }
789 | Self::TemplateUnknownFilter { span, .. }
790 | Self::TemplateInvalidName { span, .. }
791 | Self::TemplateNotFound { span, .. }
792 | Self::TemplateBuiltinImmutable { span, .. }
793 | Self::TemplateParentNotFound { span, .. }
794 | Self::TemplateInheritanceDepth { span, .. }
795 | Self::TemplateSyntaxError { span, .. }
796 | Self::RenderOutputTooLarge { span, .. }
797 | Self::UnsupportedVersion { span, .. }
798 | Self::TooManyFormats { span, .. }
799 | Self::TooManyUserVars { span, .. }
800 | Self::UserVarTooLarge { span, .. }
801 | Self::DuplicateFormatKey { span, .. }
802 | Self::MissingAccumulateOps { span, .. }
803 | Self::AccumulateNonNumericField { span, .. }
804 | Self::AccumulateTipNotFound { span, .. }
805 | Self::AccumulateRetryExhausted { span, .. }
806 | Self::AccumulateInternal { span, .. }
807 | Self::AccumulateBackpressureRejected { span, .. }
808 | Self::QueryNotFound { span, .. }
809 | Self::DuplicateQueryName { span, .. }
810 | Self::TooManyQueries { span, .. }
811 | Self::TemplateNestingTooDeep { span, .. }
812 | Self::TooManyTemplates { span, .. }
813 | Self::CannotExtendData { span, .. }
814 | Self::QueryBodyTooLarge { span, .. }
815 | Self::TooManyQueryParams { span, .. }
816 | Self::MissingQueryParam { span, .. }
817 | Self::RecursiveQuery { span, .. }
818 | Self::WriteInQueryBody { span, .. }
819 | Self::InvalidQueryBody { span, .. }
820 | Self::HashNotFound { span, .. }
821 | Self::Tier1NotEnabled { span, .. }
822 | Self::InvalidUtf8 { span, .. } => *span,
823 Self::InsufficientScope { .. } | Self::LlmFeatureUnavailable { .. } => None,
824 }
825 }
826
827 pub fn suggestion(&self) -> Option<&str> {
829 match self {
830 Self::UnexpectedToken { suggestion, .. }
831 | Self::UnknownGrainType { suggestion, .. }
832 | Self::UnknownField { suggestion, .. }
833 | Self::UnknownEvolveField { suggestion, .. }
834 | Self::IncompatibleTypes { suggestion, .. }
835 | Self::FieldNotOnGrainType { suggestion, .. }
836 | Self::TemplateUnknownVariable { suggestion, .. } => suggestion.as_deref(),
837 _ => None,
838 }
839 }
840
841 pub fn with_suggestion(self, hint: &str) -> Self {
846 let hint = Some(hint.to_string());
847 match self {
848 Self::UnexpectedToken {
849 expected,
850 found,
851 span,
852 ..
853 } => Self::UnexpectedToken {
854 expected,
855 found,
856 span,
857 suggestion: hint,
858 },
859 Self::UnknownGrainType { found, span, .. } => Self::UnknownGrainType {
860 found,
861 span,
862 suggestion: hint,
863 },
864 Self::UnknownField { found, span, .. } => Self::UnknownField {
865 found,
866 span,
867 suggestion: hint,
868 },
869 Self::UnknownEvolveField { found, span, .. } => Self::UnknownEvolveField {
870 found,
871 span,
872 suggestion: hint,
873 },
874 Self::IncompatibleTypes {
875 left, right, span, ..
876 } => Self::IncompatibleTypes {
877 left,
878 right,
879 span,
880 suggestion: hint,
881 },
882 Self::FieldNotOnGrainType {
883 field,
884 grain_type,
885 span,
886 ..
887 } => Self::FieldNotOnGrainType {
888 field,
889 grain_type,
890 span,
891 suggestion: hint,
892 },
893 Self::TemplateUnknownVariable { name, span, .. } => Self::TemplateUnknownVariable {
894 name,
895 span,
896 suggestion: hint,
897 },
898 other => other,
899 }
900 }
901
902 pub fn with_span(self, new_span: Span) -> Self {
904 let s = Some(new_span);
905 match self {
906 Self::QueryTooLong { length, max, .. } => Self::QueryTooLong {
907 length,
908 max,
909 span: s,
910 },
911 Self::UnexpectedToken {
912 expected,
913 found,
914 suggestion,
915 ..
916 } => Self::UnexpectedToken {
917 expected,
918 found,
919 span: s,
920 suggestion,
921 },
922 Self::UnknownGrainType {
923 found, suggestion, ..
924 } => Self::UnknownGrainType {
925 found,
926 span: s,
927 suggestion,
928 },
929 Self::UnknownField {
930 found, suggestion, ..
931 } => Self::UnknownField {
932 found,
933 span: s,
934 suggestion,
935 },
936 Self::UnterminatedString { .. } => Self::UnterminatedString { span: s },
937 Self::InvalidNumber { found, .. } => Self::InvalidNumber { found, span: s },
938 Self::NestingTooDeep { depth, max, .. } => Self::NestingTooDeep {
939 depth,
940 max,
941 span: s,
942 },
943 Self::UnboundParameter { name, .. } => Self::UnboundParameter { name, span: s },
944 Self::DuplicateParameter { name, .. } => Self::DuplicateParameter { name, span: s },
945 Self::LimitExceeded { value, max, .. } => Self::LimitExceeded {
946 value,
947 max,
948 span: s,
949 },
950 Self::InSetTooLarge { count, max, .. } => Self::InSetTooLarge {
951 count,
952 max,
953 span: s,
954 },
955 Self::TooManyPipelineStages { count, max, .. } => Self::TooManyPipelineStages {
956 count,
957 max,
958 span: s,
959 },
960 Self::TooManySetOperands { count, max, .. } => Self::TooManySetOperands {
961 count,
962 max,
963 span: s,
964 },
965 Self::EmptyQuery { .. } => Self::EmptyQuery { span: s },
966 Self::InvalidHash { found, .. } => Self::InvalidHash { found, span: s },
967 Self::ReasonTooLong { length, max, .. } => Self::ReasonTooLong {
968 length,
969 max,
970 span: s,
971 },
972 Self::UnknownEvolveField {
973 found, suggestion, ..
974 } => Self::UnknownEvolveField {
975 found,
976 span: s,
977 suggestion,
978 },
979 Self::MissingReason { .. } => Self::MissingReason { span: s },
980 Self::MissingSetClause { .. } => Self::MissingSetClause { span: s },
981 Self::IncompatibleTypes {
982 left,
983 right,
984 suggestion,
985 ..
986 } => Self::IncompatibleTypes {
987 left,
988 right,
989 span: s,
990 suggestion,
991 },
992 Self::PipelineTypeMismatch {
993 stage,
994 expected,
995 found,
996 ..
997 } => Self::PipelineTypeMismatch {
998 stage,
999 expected,
1000 found,
1001 span: s,
1002 },
1003 Self::ExtractorRequiresFacts {
1004 extractor, found, ..
1005 } => Self::ExtractorRequiresFacts {
1006 extractor,
1007 found,
1008 span: s,
1009 },
1010 Self::BudgetExceeded { detail, .. } => Self::BudgetExceeded { detail, span: s },
1011 Self::InvalidQuery { detail, .. } => Self::InvalidQuery { detail, span: s },
1012 Self::CryptoError { detail, .. } => Self::CryptoError { detail, span: s },
1013 Self::HashNotFound { hash, .. } => Self::HashNotFound { hash, span: s },
1014 Self::Tier1NotEnabled { statement, .. } => Self::Tier1NotEnabled { statement, span: s },
1015 Self::InvalidUtf8 { detail, .. } => Self::InvalidUtf8 { detail, span: s },
1016 Self::QueryTimeout {
1017 elapsed_ms,
1018 limit_ms,
1019 ..
1020 } => Self::QueryTimeout {
1021 elapsed_ms,
1022 limit_ms,
1023 span: s,
1024 },
1025 Self::FieldNotOnGrainType {
1026 field,
1027 grain_type,
1028 suggestion,
1029 ..
1030 } => Self::FieldNotOnGrainType {
1031 field,
1032 grain_type,
1033 span: s,
1034 suggestion,
1035 },
1036 Self::EngineFieldNotFilterable { field, context, .. } => {
1037 Self::EngineFieldNotFilterable {
1038 field,
1039 context,
1040 span: s,
1041 }
1042 }
1043 Self::AssembleTooManySources { count, max, .. } => Self::AssembleTooManySources {
1044 count,
1045 max,
1046 span: s,
1047 },
1048 Self::AssembleBudgetExceeded {
1049 value, max, unit, ..
1050 } => Self::AssembleBudgetExceeded {
1051 value,
1052 max,
1053 unit,
1054 span: s,
1055 },
1056 Self::AssembleDuplicateLabel { label, .. } => {
1057 Self::AssembleDuplicateLabel { label, span: s }
1058 }
1059 Self::AssemblePriorityMismatch { label, .. } => {
1060 Self::AssemblePriorityMismatch { label, span: s }
1061 }
1062 Self::TooManyLetBindings { count, max, .. } => Self::TooManyLetBindings {
1063 count,
1064 max,
1065 span: s,
1066 },
1067 Self::LetCircularReference { name, .. } => Self::LetCircularReference { name, span: s },
1068 Self::LetDepthExceeded { depth, max, .. } => Self::LetDepthExceeded {
1069 depth,
1070 max,
1071 span: s,
1072 },
1073 Self::CoalesceTooManyBranches { count, max, .. } => Self::CoalesceTooManyBranches {
1074 count,
1075 max,
1076 span: s,
1077 },
1078 Self::AssembleTimeout {
1079 elapsed_ms,
1080 limit_ms,
1081 ..
1082 } => Self::AssembleTimeout {
1083 elapsed_ms,
1084 limit_ms,
1085 span: s,
1086 },
1087 Self::InvalidJsonCal { detail, .. } => Self::InvalidJsonCal { detail, span: s },
1088 Self::NotAuthorized { detail, .. } => Self::NotAuthorized { detail, span: s },
1089 Self::TemplateTooLarge { size, max, .. } => {
1090 Self::TemplateTooLarge { size, max, span: s }
1091 }
1092 Self::TemplateNestedEach { .. } => Self::TemplateNestedEach { span: s },
1093 Self::TemplateUnknownVariable {
1094 name, suggestion, ..
1095 } => Self::TemplateUnknownVariable {
1096 name,
1097 span: s,
1098 suggestion,
1099 },
1100 Self::TemplateUnknownFilter { name, .. } => {
1101 Self::TemplateUnknownFilter { name, span: s }
1102 }
1103 Self::TemplateInvalidName { name, .. } => Self::TemplateInvalidName { name, span: s },
1104 Self::TemplateNotFound { name, .. } => Self::TemplateNotFound { name, span: s },
1105 Self::TemplateBuiltinImmutable { name, .. } => {
1106 Self::TemplateBuiltinImmutable { name, span: s }
1107 }
1108 Self::TemplateParentNotFound { name, parent, .. } => Self::TemplateParentNotFound {
1109 name,
1110 parent,
1111 span: s,
1112 },
1113 Self::TemplateInheritanceDepth { name, .. } => {
1114 Self::TemplateInheritanceDepth { name, span: s }
1115 }
1116 Self::TemplateSyntaxError { detail, .. } => {
1117 Self::TemplateSyntaxError { detail, span: s }
1118 }
1119 Self::RenderOutputTooLarge { size, max, .. } => {
1120 Self::RenderOutputTooLarge { size, max, span: s }
1121 }
1122 Self::UnsupportedVersion { version, .. } => {
1123 Self::UnsupportedVersion { version, span: s }
1124 }
1125 Self::TooManyFormats { count, max, .. } => Self::TooManyFormats {
1126 count,
1127 max,
1128 span: s,
1129 },
1130 Self::TooManyUserVars { count, max, .. } => Self::TooManyUserVars {
1131 count,
1132 max,
1133 span: s,
1134 },
1135 Self::UserVarTooLarge { key, size, max, .. } => Self::UserVarTooLarge {
1136 key,
1137 size,
1138 max,
1139 span: s,
1140 },
1141 Self::DuplicateFormatKey { key, .. } => Self::DuplicateFormatKey { key, span: s },
1142 Self::AssemblePinnedBudgetExceeded {
1143 labels,
1144 required,
1145 budget,
1146 ..
1147 } => Self::AssemblePinnedBudgetExceeded {
1148 labels,
1149 required,
1150 budget,
1151 span: s,
1152 },
1153 Self::MissingAccumulateOps { .. } => Self::MissingAccumulateOps { span: s },
1154 Self::AccumulateNonNumericField { field, current, .. } => {
1155 Self::AccumulateNonNumericField {
1156 field,
1157 current,
1158 span: s,
1159 }
1160 }
1161 Self::AccumulateTipNotFound {
1162 subject, relation, ..
1163 } => Self::AccumulateTipNotFound {
1164 subject,
1165 relation,
1166 span: s,
1167 },
1168 Self::AccumulateRetryExhausted {
1169 subject, relation, ..
1170 } => Self::AccumulateRetryExhausted {
1171 subject,
1172 relation,
1173 span: s,
1174 },
1175 Self::AccumulateInternal { .. } => Self::AccumulateInternal { span: s },
1176 Self::AccumulateBackpressureRejected {
1177 subject, relation, ..
1178 } => Self::AccumulateBackpressureRejected {
1179 subject,
1180 relation,
1181 span: s,
1182 },
1183 Self::QueryNotFound { name, .. } => Self::QueryNotFound { name, span: s },
1184 Self::DuplicateQueryName { name, .. } => Self::DuplicateQueryName { name, span: s },
1185 Self::CannotExtendData { name, .. } => Self::CannotExtendData { name, span: s },
1186 Self::TemplateNestingTooDeep { max, .. } => {
1187 Self::TemplateNestingTooDeep { max, span: s }
1188 }
1189 Self::TooManyTemplates { count, max, .. } => Self::TooManyTemplates {
1190 count,
1191 max,
1192 span: s,
1193 },
1194 Self::TooManyQueries { count, max, .. } => Self::TooManyQueries {
1195 count,
1196 max,
1197 span: s,
1198 },
1199 Self::QueryBodyTooLarge { size, max, .. } => {
1200 Self::QueryBodyTooLarge { size, max, span: s }
1201 }
1202 Self::TooManyQueryParams { count, max, .. } => Self::TooManyQueryParams {
1203 count,
1204 max,
1205 span: s,
1206 },
1207 Self::MissingQueryParam { name, query, .. } => Self::MissingQueryParam {
1208 name,
1209 query,
1210 span: s,
1211 },
1212 Self::RecursiveQuery { .. } => Self::RecursiveQuery { span: s },
1213 Self::WriteInQueryBody { stmt, .. } => Self::WriteInQueryBody { stmt, span: s },
1214 Self::InvalidQueryBody { detail, .. } => Self::InvalidQueryBody { detail, span: s },
1215 Self::InsufficientScope {
1217 required,
1218 statement,
1219 } => Self::InsufficientScope {
1220 required,
1221 statement,
1222 },
1223 Self::LlmFeatureUnavailable { feature } => Self::LlmFeatureUnavailable { feature },
1225 }
1226 }
1227
1228 pub fn diagnostic(&self) -> String {
1232 let mut msg = self.to_string();
1233 if let Some(span) = self.span() {
1234 msg.push_str(&format!(" at {}", span));
1235 }
1236 if let Some(hint) = self.suggestion() {
1237 msg.push_str(&format!(" (hint: {})", hint));
1238 }
1239 msg
1240 }
1241
1242 pub fn sanitize_for_client(&self) -> String {
1258 let code = self.code();
1259 let span_suffix = self
1260 .span()
1261 .map(|s| format!(" at {}", s))
1262 .unwrap_or_default();
1263 match self {
1264 Self::BudgetExceeded { .. } => {
1269 format!("{}: budget exceeded{}", code, span_suffix)
1270 }
1271 Self::InvalidQuery { .. } => {
1272 format!("{}: invalid query{}", code, span_suffix)
1273 }
1274 Self::CryptoError { .. } => {
1275 format!(
1276 "{}: crypto error during query execution{}",
1277 code, span_suffix
1278 )
1279 }
1280 Self::InvalidJsonCal { .. } => {
1281 format!("{}: invalid JSON+CAL input{}", code, span_suffix)
1282 }
1283 Self::NotAuthorized { detail, .. } => {
1284 format!("{}: not authorized: {}{}", code, detail, span_suffix)
1289 }
1290 Self::TemplateSyntaxError { .. } => {
1291 format!("{}: template syntax error{}", code, span_suffix)
1292 }
1293 Self::InvalidQueryBody { .. } => {
1294 format!("{}: invalid query body{}", code, span_suffix)
1295 }
1296 Self::AccumulateRetryExhausted {
1302 subject, relation, ..
1303 } => {
1304 format!(
1305 "{}: ACCUMULATE retry budget exhausted (subject=\"{}\", relation=\"{}\"){}",
1306 code,
1307 sanitize_echo(subject),
1308 sanitize_echo(relation),
1309 span_suffix
1310 )
1311 }
1312 Self::AccumulateInternal { .. } => {
1314 format!("{}: ACCUMULATE internal failure{}", code, span_suffix)
1315 }
1316 Self::AccumulateBackpressureRejected {
1320 subject, relation, ..
1321 } => {
1322 format!(
1323 "{}: ACCUMULATE backpressure: per-key inflight cap exceeded (subject=\"{}\", relation=\"{}\"){}",
1324 code,
1325 sanitize_echo(subject),
1326 sanitize_echo(relation),
1327 span_suffix
1328 )
1329 }
1330 _ => self.diagnostic(),
1334 }
1335 }
1336}
1337
1338fn sanitize_echo(s: &str) -> String {
1347 const MAX_ECHO_LEN: usize = 128;
1348 let mut out = String::with_capacity(s.len().min(MAX_ECHO_LEN));
1349 for ch in s.chars().take(MAX_ECHO_LEN) {
1350 if ch.is_control() || ('\u{202A}'..='\u{202E}').contains(&ch) {
1351 out.push('?');
1352 } else {
1353 out.push(ch);
1354 }
1355 }
1356 out
1357}
1358
1359#[derive(Debug, Clone, PartialEq)]
1365pub enum CalWarning {
1366 UnknownRelation {
1369 relation: String,
1370 span: Option<Span>,
1371 },
1372
1373 DomainFieldWithoutTag { field: String, span: Option<Span> },
1376
1377 UnknownDomainPrefix { prefix: String, span: Option<Span> },
1379
1380 UnknownExtensionOption { option: String, span: Option<Span> },
1383
1384 DuplicateSetField { field: String, span: Option<Span> },
1387
1388 UnusedQueryParam {
1391 name: String,
1392 query: String,
1393 span: Option<Span>,
1394 },
1395
1396 DeprecatedPipeOperator { span: Option<Span> },
1400
1401 IsCategoryOnNonRelation {
1405 field: String,
1406 category: String,
1407 span: Option<Span>,
1408 },
1409
1410 AssembleUnscopedSource {
1414 labels: Vec<String>,
1415 span: Option<Span>,
1416 },
1417
1418 UnrecognizedWhereField { field: String, span: Option<Span> },
1425
1426 EachIterationCapped {
1430 rendered: usize,
1431 total: usize,
1432 max: usize,
1433 },
1434
1435 ContradictionScanBounded { scanned: usize },
1443
1444 WithOptionInert {
1454 option: &'static str,
1455 statement: &'static str,
1456 why: &'static str,
1457 },
1458
1459 ScanBounded {
1472 stage: String,
1474 scanned: usize,
1475 },
1476
1477 PipelineStageInert {
1487 stage: String,
1488 payload: &'static str,
1489 why: &'static str,
1490 },
1491}
1492
1493impl CalWarning {
1494 pub fn code(&self) -> &'static str {
1496 match self {
1497 Self::UnknownRelation { .. } => "CAL-W001",
1498 Self::DomainFieldWithoutTag { .. } => "CAL-W002",
1499 Self::UnknownDomainPrefix { .. } => "CAL-W003",
1500 Self::UnknownExtensionOption { .. } => "CAL-W004",
1501 Self::DuplicateSetField { .. } => "CAL-W005",
1502 Self::UnusedQueryParam { .. } => "CAL-W006",
1503 Self::DeprecatedPipeOperator { .. } => "CAL-W007",
1504 Self::IsCategoryOnNonRelation { .. } => "CAL-W008",
1505 Self::AssembleUnscopedSource { .. } => "CAL-W009",
1506 Self::UnrecognizedWhereField { .. } => "CAL-W010",
1507 Self::EachIterationCapped { .. } => "CAL-W011",
1508 Self::ContradictionScanBounded { .. } => "CAL-W012",
1509 Self::WithOptionInert { .. } => "CAL-W014",
1510 Self::ScanBounded { .. } => "CAL-W015",
1511 Self::PipelineStageInert { .. } => "CAL-W016",
1512 }
1513 }
1514
1515 pub fn span(&self) -> Option<Span> {
1517 match self {
1518 Self::UnknownRelation { span, .. }
1519 | Self::DomainFieldWithoutTag { span, .. }
1520 | Self::UnknownDomainPrefix { span, .. }
1521 | Self::UnknownExtensionOption { span, .. }
1522 | Self::DuplicateSetField { span, .. }
1523 | Self::UnusedQueryParam { span, .. }
1524 | Self::DeprecatedPipeOperator { span }
1525 | Self::IsCategoryOnNonRelation { span, .. }
1526 | Self::AssembleUnscopedSource { span, .. }
1527 | Self::UnrecognizedWhereField { span, .. } => *span,
1528 Self::EachIterationCapped { .. }
1529 | Self::ContradictionScanBounded { .. }
1530 | Self::WithOptionInert { .. }
1531 | Self::ScanBounded { .. }
1532 | Self::PipelineStageInert { .. } => None,
1533 }
1534 }
1535}
1536
1537impl std::fmt::Display for CalWarning {
1538 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1539 match self {
1540 Self::UnknownRelation { relation, .. } => {
1541 write!(f, "CAL-W001: Unknown relation \"{}\"", relation)
1542 }
1543 Self::DomainFieldWithoutTag { field, .. } => {
1544 write!(f, "CAL-W002: Domain field \"{}\" used without @tag", field)
1545 }
1546 Self::UnknownDomainPrefix { prefix, .. } => {
1547 write!(f, "CAL-W003: Unknown domain prefix \"{}\"", prefix)
1548 }
1549 Self::UnknownExtensionOption { option, .. } => {
1550 write!(
1551 f,
1552 "CAL-W004: Unknown extension option \"{}\" (ignored)",
1553 option
1554 )
1555 }
1556 Self::DuplicateSetField { field, .. } => {
1557 write!(
1558 f,
1559 "CAL-W005: Duplicate SET field \"{}\" — only the last value is used",
1560 field
1561 )
1562 }
1563 Self::UnusedQueryParam { name, query, .. } => {
1564 write!(
1565 f,
1566 "CAL-W006: Parameter \"${}\" supplied but not referenced in query \"{}\"",
1567 name, query
1568 )
1569 }
1570 Self::DeprecatedPipeOperator { .. } => {
1571 write!(
1572 f,
1573 "CAL-W007: Bare pipe operator `|` is deprecated (CAL 1.1). Use direct clause syntax instead, e.g. `RECALL facts ORDER BY confidence DESC LIMIT 10`"
1574 )
1575 }
1576 Self::IsCategoryOnNonRelation {
1577 field, category, ..
1578 } => {
1579 write!(
1580 f,
1581 "CAL-W008: IS {} used on field '{}' — IS CATEGORY is only meaningful on the 'relation' field; this condition was ignored",
1582 category, field
1583 )
1584 }
1585 Self::AssembleUnscopedSource { labels, .. } => {
1586 write!(
1587 f,
1588 "CAL-W009: ASSEMBLE source(s) [{}] have no subject filter while other sources do — results may include data from unrelated subjects",
1589 labels.join(", ")
1590 )
1591 }
1592 Self::UnrecognizedWhereField { field, .. } => {
1593 write!(
1594 f,
1595 "CAL-W010: WHERE field '{}' is not a recognized field on any grain type; it will match only grains that carry it. Check the field name.",
1596 field
1597 )
1598 }
1599 Self::EachIterationCapped {
1600 rendered,
1601 total,
1602 max,
1603 } => {
1604 write!(
1605 f,
1606 "CAL-W011: {{{{#each}}}} rendered {rendered} of {total} grains (§10.8 caps iteration at {max}) — the result set is complete, this rendering is not"
1607 )
1608 }
1609 Self::ContradictionScanBounded { scanned } => {
1610 write!(
1611 f,
1612 "CAL-W012: CONTRADICTIONS examined the first {scanned} matching grains (the executor's max_limit) — grains past that were not checked, so this is not a complete all-clear. Narrow the query with WHERE/ABOUT/SINCE to be sure."
1613 )
1614 }
1615 Self::WithOptionInert {
1616 option,
1617 statement,
1618 why,
1619 } => {
1620 write!(
1621 f,
1622 "CAL-W014: WITH {option} has no effect on {statement} — {why}. The result is the same as without it."
1623 )
1624 }
1625 Self::ScanBounded { stage, scanned } => {
1626 write!(
1627 f,
1628 "CAL-W015: {stage} ran over the first {scanned} matching grains (the executor's max_limit) and that scan came back full — grains past it were never considered, so this is a bounded answer, not the true one. Narrow the query with WHERE/ABOUT/SINCE, or raise max_limit."
1629 )
1630 }
1631 Self::PipelineStageInert {
1632 stage,
1633 payload,
1634 why,
1635 } => {
1636 write!(
1637 f,
1638 "CAL-W016: {stage} has no effect on a {payload} result — {why}. The stage was skipped; the result is the same as without it."
1639 )
1640 }
1641 }
1642 }
1643}
1644
1645pub type CalResult<T> = std::result::Result<T, CalError>;
1651
1652impl From<CalError> for areev_core::error::AreevError {
1657 fn from(e: CalError) -> Self {
1658 areev_core::error::AreevError::Validation(e.diagnostic())
1659 }
1660}
1661
1662#[cfg(test)]
1667mod tests {
1668 use super::*;
1669
1670 #[test]
1671 fn test_error_codes_match_display() {
1672 let err = CalError::QueryTooLong {
1673 length: 5000,
1674 max: 4096,
1675 span: None,
1676 };
1677 assert!(err.to_string().starts_with("CAL-E001"));
1678 assert_eq!(err.code(), "CAL-E001");
1679 }
1680
1681 #[test]
1682 fn test_invalid_query_is_e092_not_budget() {
1683 let err = CalError::InvalidQuery {
1686 detail: "VAL-E001: validation error: bad filter".into(),
1687 span: None,
1688 };
1689 assert_eq!(err.code(), "CAL-E092");
1690 assert!(err.to_string().starts_with("CAL-E092"));
1691 let sanitized = err.sanitize_for_client();
1693 assert!(sanitized.starts_with("CAL-E092"));
1694 assert!(!sanitized.contains("bad filter"));
1695 }
1696
1697 #[test]
1698 fn test_with_suggestion() {
1699 let err = CalError::UnknownGrainType {
1700 found: "facts".into(),
1701 span: None,
1702 suggestion: None,
1703 };
1704 let err = err.with_suggestion("did you mean \"facts\"?");
1705 assert_eq!(err.suggestion(), Some("did you mean \"facts\"?"));
1706 }
1707
1708 #[test]
1709 fn test_with_span() {
1710 let err = CalError::EmptyQuery { span: None };
1711 assert!(err.span().is_none());
1712 let err = err.with_span(Span::new(0, 5, 1, 1));
1713 assert_eq!(err.span(), Some(Span::new(0, 5, 1, 1)));
1714 }
1715
1716 #[test]
1717 fn test_diagnostic_with_span_and_suggestion() {
1718 let err = CalError::UnknownField {
1719 found: "titel".into(),
1720 span: Some(Span::new(10, 15, 1, 11)),
1721 suggestion: Some("did you mean \"title\"?".into()),
1722 };
1723 let diag = err.diagnostic();
1724 assert!(diag.contains("CAL-E004"));
1725 assert!(diag.contains("at 1:11"));
1726 assert!(diag.contains("hint: did you mean \"title\"?"));
1727 }
1728
1729 #[test]
1735 fn test_sanitize_strips_detail_for_leaky_variants() {
1736 let leaky_detail = "user_id=alice@example.com /var/lib/areev/db blob 0xABCDEF missing dek";
1740 let err = CalError::BudgetExceeded {
1741 detail: leaky_detail.into(),
1742 span: Some(Span::new(10, 15, 2, 5)),
1743 };
1744 let sanitized = err.sanitize_for_client();
1745 assert!(
1746 sanitized.starts_with("CAL-E030"),
1747 "sanitised error must carry the CAL code, got: {sanitized}"
1748 );
1749 assert!(
1750 !sanitized.contains(leaky_detail),
1751 "sanitised error must NOT contain the inner detail: {sanitized}"
1752 );
1753 assert!(
1754 !sanitized.contains("alice@example.com"),
1755 "sanitised error must NOT contain user identifiers: {sanitized}"
1756 );
1757 assert!(
1758 !sanitized.contains("/var/lib/areev/db"),
1759 "sanitised error must NOT contain internal paths: {sanitized}"
1760 );
1761 assert!(
1764 sanitized.contains("2:5"),
1765 "sanitised error should keep the public span: {sanitized}"
1766 );
1767
1768 let diag = err.diagnostic();
1771 assert!(
1772 diag.contains(leaky_detail),
1773 "diagnostic() must preserve the full detail for server logs"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_sanitize_strips_detail_for_all_leaky_variants() {
1779 let variants = [
1781 CalError::BudgetExceeded {
1782 detail: "internal backend=Fjall key=aabbcc".into(),
1783 span: None,
1784 },
1785 CalError::CryptoError {
1786 detail: "DEK 0xDEADBEEF destroyed for user alice".into(),
1787 span: None,
1788 },
1789 CalError::InvalidJsonCal {
1790 detail: "expected field `tok_xyz` at pointer /auth/token".into(),
1791 span: None,
1792 },
1793 CalError::TemplateSyntaxError {
1794 detail: "unclosed {{alice.secret}} at /tmpl/1".into(),
1795 span: None,
1796 },
1797 CalError::InvalidQueryBody {
1798 detail: "grain 0xA1B2 under namespace ns_internal".into(),
1799 span: None,
1800 },
1801 ];
1802 for err in variants {
1803 let sanitized = err.sanitize_for_client();
1804 let code = err.code();
1805 assert!(
1806 sanitized.starts_with(code),
1807 "{code}: sanitised output must start with the code, got: {sanitized}"
1808 );
1809 for leaky in ["0xDEADBEEF", "alice", "0xA1B2", "aabbcc", "tok_xyz"] {
1812 assert!(
1813 !sanitized.contains(leaky),
1814 "{code}: sanitised must not contain '{leaky}', got: {sanitized}"
1815 );
1816 }
1817 }
1818 }
1819
1820 #[test]
1821 fn test_sanitize_passthrough_for_bounded_variants() {
1822 let err = CalError::UnknownField {
1827 found: "titel".into(),
1828 span: Some(Span::new(10, 15, 1, 11)),
1829 suggestion: Some("did you mean \"title\"?".into()),
1830 };
1831 let sanitized = err.sanitize_for_client();
1832 assert_eq!(sanitized, err.diagnostic());
1833 assert!(sanitized.contains("CAL-E004"));
1834 assert!(sanitized.contains("titel"));
1835 assert!(sanitized.contains("at 1:11"));
1836 assert!(sanitized.contains("hint: did you mean \"title\"?"));
1837 }
1838
1839 #[test]
1840 fn test_warning_codes() {
1841 let w = CalWarning::UnknownRelation {
1842 relation: "foobar".into(),
1843 span: None,
1844 };
1845 assert_eq!(w.code(), "CAL-W001");
1846 assert!(w.to_string().starts_with("CAL-W001"));
1847 }
1848
1849 #[test]
1850 fn test_span_display() {
1851 let span = Span::new(10, 20, 3, 5);
1852 assert_eq!(format!("{}", span), "3:5");
1853 }
1854
1855 #[test]
1856 fn test_into_areev_error() {
1857 let err = CalError::EmptyQuery { span: None };
1858 let areev_err: areev_core::error::AreevError = err.into();
1859 match areev_err {
1860 areev_core::error::AreevError::Validation(msg) => {
1861 assert!(msg.contains("CAL-E014"));
1862 }
1863 other => panic!("expected Validation, got {:?}", other),
1864 }
1865 }
1866
1867 #[test]
1868 fn test_with_suggestion_on_non_suggestion_variant() {
1869 let err = CalError::EmptyQuery { span: None };
1872 let err = err.with_suggestion("this should be ignored");
1873 assert!(err.suggestion().is_none());
1874 }
1875
1876 #[test]
1881 fn test_phase2_error_codes_match_display() {
1882 let test_cases: Vec<(CalError, &str)> = vec![
1883 (
1884 CalError::AssembleTooManySources {
1885 count: 10,
1886 max: 8,
1887 span: None,
1888 },
1889 "CAL-E032",
1890 ),
1891 (
1892 CalError::AssembleBudgetExceeded {
1893 value: 200_000,
1894 max: 100_000,
1895 unit: "tokens".into(),
1896 span: None,
1897 },
1898 "CAL-E033",
1899 ),
1900 (
1901 CalError::AssembleDuplicateLabel {
1902 label: "src1".into(),
1903 span: None,
1904 },
1905 "CAL-E034",
1906 ),
1907 (
1908 CalError::AssemblePriorityMismatch {
1909 label: "src2".into(),
1910 span: None,
1911 },
1912 "CAL-E035",
1913 ),
1914 (
1915 CalError::TooManyLetBindings {
1916 count: 6,
1917 max: 5,
1918 span: None,
1919 },
1920 "CAL-E036",
1921 ),
1922 (
1923 CalError::LetCircularReference {
1924 name: "x".into(),
1925 span: None,
1926 },
1927 "CAL-E037",
1928 ),
1929 (
1930 CalError::LetDepthExceeded {
1931 depth: 4,
1932 max: 3,
1933 span: None,
1934 },
1935 "CAL-E038",
1936 ),
1937 (
1938 CalError::CoalesceTooManyBranches {
1939 count: 6,
1940 max: 5,
1941 span: None,
1942 },
1943 "CAL-E039",
1944 ),
1945 (
1946 CalError::InvalidJsonCal {
1948 detail: "bad json".into(),
1949 span: None,
1950 },
1951 "CAL-E120",
1952 ),
1953 (
1954 CalError::NotAuthorized {
1955 detail: "AUT-E001: principal agent:bot lacks write on namespace \"caller\"".into(),
1956 span: None,
1957 },
1958 "CAL-E121",
1959 ),
1960 (
1961 CalError::AssembleTimeout {
1962 elapsed_ms: 6000,
1963 limit_ms: 5000,
1964 span: None,
1965 },
1966 "CAL-E071",
1967 ),
1968 (CalError::MissingAccumulateOps { span: None }, "CAL-E080"),
1969 (
1970 CalError::AccumulateNonNumericField {
1971 field: "alpha".into(),
1972 current: "str".into(),
1973 span: None,
1974 },
1975 "CAL-E081",
1976 ),
1977 (
1978 CalError::AccumulateTipNotFound {
1979 subject: "x".into(),
1980 relation: "y".into(),
1981 span: None,
1982 },
1983 "CAL-E082",
1984 ),
1985 ];
1986 for (err, expected_code) in test_cases {
1987 assert_eq!(
1988 err.code(),
1989 expected_code,
1990 "code() mismatch for error: {}",
1991 err
1992 );
1993 assert!(
1994 err.to_string().starts_with(expected_code),
1995 "Display output should start with {}, got: {}",
1996 expected_code,
1997 err
1998 );
1999 }
2000 }
2001
2002 #[test]
2003 fn test_all_error_codes_have_unique_codes() {
2004 let errors: Vec<CalError> = vec![
2006 CalError::QueryTooLong {
2007 length: 0,
2008 max: 0,
2009 span: None,
2010 },
2011 CalError::UnexpectedToken {
2012 expected: "".into(),
2013 found: "".into(),
2014 span: None,
2015 suggestion: None,
2016 },
2017 CalError::UnknownGrainType {
2018 found: "".into(),
2019 span: None,
2020 suggestion: None,
2021 },
2022 CalError::UnknownField {
2023 found: "".into(),
2024 span: None,
2025 suggestion: None,
2026 },
2027 CalError::UnterminatedString { span: None },
2028 CalError::InvalidNumber {
2029 found: "".into(),
2030 span: None,
2031 },
2032 CalError::NestingTooDeep {
2033 depth: 0,
2034 max: 0,
2035 span: None,
2036 },
2037 CalError::UnboundParameter {
2038 name: "".into(),
2039 span: None,
2040 },
2041 CalError::DuplicateParameter {
2042 name: "".into(),
2043 span: None,
2044 },
2045 CalError::LimitExceeded {
2046 value: 0,
2047 max: 0,
2048 span: None,
2049 },
2050 CalError::InSetTooLarge {
2051 count: 0,
2052 max: 0,
2053 span: None,
2054 },
2055 CalError::TooManyPipelineStages {
2056 count: 0,
2057 max: 0,
2058 span: None,
2059 },
2060 CalError::TooManySetOperands {
2061 count: 0,
2062 max: 0,
2063 span: None,
2064 },
2065 CalError::EmptyQuery { span: None },
2066 CalError::InvalidHash {
2067 found: "".into(),
2068 span: None,
2069 },
2070 CalError::ReasonTooLong {
2071 length: 0,
2072 max: 0,
2073 span: None,
2074 },
2075 CalError::UnknownEvolveField {
2076 found: "".into(),
2077 span: None,
2078 suggestion: None,
2079 },
2080 CalError::MissingReason { span: None },
2081 CalError::MissingSetClause { span: None },
2082 CalError::IncompatibleTypes {
2083 left: "".into(),
2084 right: "".into(),
2085 span: None,
2086 suggestion: None,
2087 },
2088 CalError::PipelineTypeMismatch {
2089 stage: "".into(),
2090 expected: "".into(),
2091 found: "".into(),
2092 span: None,
2093 },
2094 CalError::ExtractorRequiresFacts {
2095 extractor: "".into(),
2096 found: "".into(),
2097 span: None,
2098 },
2099 CalError::BudgetExceeded {
2100 detail: "".into(),
2101 span: None,
2102 },
2103 CalError::QueryTimeout {
2104 elapsed_ms: 0,
2105 limit_ms: 0,
2106 span: None,
2107 },
2108 CalError::InvalidQuery {
2109 detail: "".into(),
2110 span: None,
2111 },
2112 CalError::FieldNotOnGrainType {
2113 field: "".into(),
2114 grain_type: "".into(),
2115 span: None,
2116 suggestion: None,
2117 },
2118 CalError::AssembleTooManySources {
2119 count: 0,
2120 max: 0,
2121 span: None,
2122 },
2123 CalError::AssembleBudgetExceeded {
2124 value: 0,
2125 max: 0,
2126 unit: "".into(),
2127 span: None,
2128 },
2129 CalError::AssembleDuplicateLabel {
2130 label: "".into(),
2131 span: None,
2132 },
2133 CalError::AssemblePriorityMismatch {
2134 label: "".into(),
2135 span: None,
2136 },
2137 CalError::TooManyLetBindings {
2138 count: 0,
2139 max: 0,
2140 span: None,
2141 },
2142 CalError::LetCircularReference {
2143 name: "".into(),
2144 span: None,
2145 },
2146 CalError::LetDepthExceeded {
2147 depth: 0,
2148 max: 0,
2149 span: None,
2150 },
2151 CalError::CoalesceTooManyBranches {
2152 count: 0,
2153 max: 0,
2154 span: None,
2155 },
2156 CalError::AssembleTimeout {
2157 elapsed_ms: 0,
2158 limit_ms: 0,
2159 span: None,
2160 },
2161 CalError::InvalidJsonCal {
2162 detail: "".into(),
2163 span: None,
2164 },
2165 CalError::TemplateTooLarge {
2166 size: 0,
2167 max: 0,
2168 span: None,
2169 },
2170 CalError::TemplateNestedEach { span: None },
2171 CalError::TemplateUnknownVariable {
2172 name: "".into(),
2173 span: None,
2174 suggestion: None,
2175 },
2176 CalError::TemplateUnknownFilter {
2177 name: "".into(),
2178 span: None,
2179 },
2180 CalError::TemplateInvalidName {
2181 name: "".into(),
2182 span: None,
2183 },
2184 CalError::TemplateNotFound {
2185 name: "".into(),
2186 span: None,
2187 },
2188 CalError::TemplateBuiltinImmutable {
2189 name: "".into(),
2190 span: None,
2191 },
2192 CalError::TemplateParentNotFound {
2193 name: "".into(),
2194 parent: "".into(),
2195 span: None,
2196 },
2197 CalError::TemplateInheritanceDepth {
2198 name: "".into(),
2199 span: None,
2200 },
2201 CalError::TemplateSyntaxError {
2202 detail: "".into(),
2203 span: None,
2204 },
2205 CalError::RenderOutputTooLarge {
2206 size: 0,
2207 max: 0,
2208 span: None,
2209 },
2210 CalError::UnsupportedVersion {
2211 version: 0,
2212 span: None,
2213 },
2214 CalError::TooManyFormats {
2215 count: 0,
2216 max: 0,
2217 span: None,
2218 },
2219 CalError::TooManyUserVars {
2220 count: 0,
2221 max: 0,
2222 span: None,
2223 },
2224 CalError::UserVarTooLarge {
2225 key: "".into(),
2226 size: 0,
2227 max: 0,
2228 span: None,
2229 },
2230 CalError::DuplicateFormatKey {
2231 key: "".into(),
2232 span: None,
2233 },
2234 CalError::MissingAccumulateOps { span: None },
2235 CalError::AccumulateNonNumericField {
2236 field: "".into(),
2237 current: "".into(),
2238 span: None,
2239 },
2240 CalError::AccumulateTipNotFound {
2241 subject: "".into(),
2242 relation: "".into(),
2243 span: None,
2244 },
2245 ];
2246 let mut codes = std::collections::HashSet::new();
2247 for err in &errors {
2248 let code = err.code();
2249 assert!(
2250 codes.insert(code),
2251 "Duplicate error code found: {} (shared between multiple variants)",
2252 code
2253 );
2254 }
2255 assert_eq!(
2257 codes.len(),
2258 errors.len(),
2259 "all error variants should have unique codes"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_phase2_with_span_preserves_fields() {
2265 let span = Span::new(10, 20, 1, 11);
2266 let err = CalError::TooManyLetBindings {
2267 count: 6,
2268 max: 5,
2269 span: None,
2270 };
2271 let err = err.with_span(span);
2272 assert_eq!(err.span(), Some(span));
2273 match err {
2275 CalError::TooManyLetBindings { count, max, .. } => {
2276 assert_eq!(count, 6);
2277 assert_eq!(max, 5);
2278 }
2279 _ => panic!("wrong variant after with_span"),
2280 }
2281 }
2282}