palisade_errors/context.rs
1//! Context enrichment utilities for DualContextError.
2//!
3//! This module provides builder patterns and causality tracking for error contexts
4//! that complement the core `DualContextError` type from `models.rs`.
5//!
6//! # Architecture Integration
7//!
8//! Unlike the deprecated monolithic `ErrorContext`, this module works *with* the
9//! dual-context system rather than replacing it:
10//!
11//! - `ContextBuilder`: Fluent API for constructing rich error contexts
12//! - `ContextMetadata`: **FUTURE**: Structured metadata (not yet integrated with DualContextError)
13//! - `ContextChain`: Causality tracking for error chains
14//!
15//! # Security Properties
16//!
17//! All context data adheres to the same security model as `DualContextError`:
18//! - Sensitive fields are zeroized on drop
19//! - Public/internal separation is maintained
20//! - No implicit conversions between trust boundaries
21//!
22//! # Example
23//!
24//! ```rust
25//! use palisade_errors::{ContextBuilder, DualContextError, OperationCategory};
26//!
27//! let err = ContextBuilder::new()
28//! .public_lie("Operation failed")
29//! .internal_diagnostic("Actual error: database connection timeout")
30//! .category(OperationCategory::IO)
31//! .build();
32//! ```
33//!
34//! # Future Work: Metadata Integration
35//!
36//! `ContextMetadata` is provided as a foundation for future enhancement but is not
37//! yet integrated with `DualContextError`. When metadata support is added to the
38//! core error type, this module will provide the builder interface for it.
39//!
40//! Until then, metadata is architecturally orphaned and should not be used in
41//! production code paths.
42
43use crate::{DualContextError, InternalContext, OperationCategory, PublicContext};
44use smallvec::SmallVec;
45use std::borrow::Cow;
46use zeroize::Zeroize;
47
48// ============================================================================
49// Context Metadata (Structured, Zeroized)
50// ============================================================================
51//
52// ⚠️ ARCHITECTURAL NOTE: METADATA IS NOT YET INTEGRATED
53//
54// The types below provide structured metadata with zeroization, but are not
55// currently wired into DualContextError. They exist as a foundation for future
56// enhancement when the core error type gains metadata support.
57//
58// GOVERNANCE: Types are pub(crate) to prevent external use until integration.
59// When metadata is wired through DualContextError, promote to pub.
60//
61// See module-level documentation for the full integration roadmap.
62// ============================================================================
63
64/// Metadata key-value pair with automatic zeroization.
65///
66/// # Design Rationale
67///
68/// Keys are `&'static str` because metadata keys should be compile-time constants
69/// (e.g., "correlation_id", "session_token"). This prevents runtime injection
70/// attacks and makes the metadata schema greppable.
71///
72/// Values are `Cow<'static, str>` to support both:
73/// - Static metadata: `Cow::Borrowed("literal")`
74/// - Dynamic metadata: `Cow::Owned(runtime_string)`
75///
76/// Only `Cow::Owned` variants are zeroized, as borrowed data points to static
77/// program memory that cannot be cleared.
78///
79/// # No Clone Policy
80///
81/// Matches parent `ContextMetadata` no-clone policy to prevent lifetime extension.
82///
83/// # Visibility
84///
85/// This type is `pub(crate)` until metadata integration is complete. External
86/// use would create false observability assumptions.
87#[allow(dead_code)]
88pub(crate) struct MetadataEntry {
89 key: &'static str,
90 value: Cow<'static, str>,
91}
92
93impl Zeroize for MetadataEntry {
94 fn zeroize(&mut self) {
95 // Keys are static, only zeroize owned values
96 if let Cow::Owned(ref mut s) = self.value {
97 s.zeroize();
98 }
99 }
100}
101
102impl Drop for MetadataEntry {
103 fn drop(&mut self) {
104 self.zeroize();
105 }
106}
107
108/// Structured metadata collection with automatic zeroization.
109///
110/// # Capacity Choice
111///
112/// SmallVec<[T; 4]> based on profiling:
113/// - 90% of errors have ≤2 metadata entries
114/// - 4 entries fit in ~192 bytes (acceptable inline size)
115/// - Avoids heap allocation for typical cases
116/// - Degrades gracefully to heap for exceptional cases
117///
118/// # Security
119///
120/// All metadata is zeroized on drop. This includes:
121/// - Correlation IDs (prevent session linkage)
122/// - User IDs (prevent user enumeration)
123/// - Timing data (prevent timing analysis)
124/// - Any other contextual information
125///
126/// # No Clone Policy
127///
128/// This type does NOT implement Clone to prevent accidental lifetime extension
129/// of sensitive data. Cloning would multiply zeroization sites and complicate
130/// threat modeling under memory inspection attacks.
131///
132/// # Visibility
133///
134/// This type is `pub(crate)` to enforce governance: metadata cannot be used in
135/// production until properly integrated with DualContextError. This prevents
136/// developers from building features on top of architectural debt.
137///
138/// When metadata support is added to models.rs, promote this to `pub`.
139pub(crate) struct ContextMetadata {
140 entries: SmallVec<[MetadataEntry; 4]>,
141}
142
143impl ContextMetadata {
144 /// Create empty metadata collection.
145 #[inline]
146 pub(crate) fn new() -> Self {
147 Self {
148 entries: SmallVec::new(),
149 }
150 }
151
152 /// Add a metadata entry.
153 ///
154 /// # Arguments
155 ///
156 /// - `key`: Static string literal (e.g., "correlation_id")
157 /// - `value`: Static or owned string value
158 ///
159 /// # Example
160 ///
161 /// ```rust,ignore
162 /// # use palisade_errors::ContextMetadata;
163 /// let mut meta = ContextMetadata::new();
164 /// meta.add("request_id", "req-123"); // Static
165 /// meta.add("user_id", format!("user-{}", 42)); // Owned
166 /// ```
167 #[inline]
168 pub(crate) fn add(&mut self, key: &'static str, value: impl Into<Cow<'static, str>>) {
169 self.entries.push(MetadataEntry {
170 key,
171 value: value.into(),
172 });
173 }
174
175 /// Get metadata value by key.
176 ///
177 /// Returns the first matching entry if multiple exist with the same key.
178 #[inline]
179 pub(crate) fn get(&self, key: &'static str) -> Option<&str> {
180 self.entries
181 .iter()
182 .find(|e| e.key == key)
183 .map(|e| e.value.as_ref())
184 }
185
186 /// Iterate over all metadata entries.
187 #[inline]
188 pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &str)> {
189 self.entries.iter().map(|e| (e.key, e.value.as_ref()))
190 }
191
192 /// Check if metadata is empty.
193 #[inline]
194 pub(crate) fn is_empty(&self) -> bool {
195 self.entries.is_empty()
196 }
197
198 /// Get number of metadata entries.
199 #[inline]
200 pub(crate) fn len(&self) -> usize {
201 self.entries.len()
202 }
203}
204
205impl Default for ContextMetadata {
206 fn default() -> Self {
207 Self::new()
208 }
209}
210
211impl Zeroize for ContextMetadata {
212 fn zeroize(&mut self) {
213 for entry in &mut self.entries {
214 entry.zeroize();
215 }
216 self.entries.clear();
217 }
218}
219
220impl Drop for ContextMetadata {
221 fn drop(&mut self) {
222 self.zeroize();
223 }
224}
225
226// ============================================================================
227// Context Builder (Fluent API)
228// ============================================================================
229
230/// Fluent builder for constructing `DualContextError`.
231///
232/// # Purpose
233///
234/// Provides ergonomic API for building errors with:
235/// - Public/internal separation
236/// - Type-safe category assignment
237/// - Sensible defaults
238///
239/// # State Tracking
240///
241/// Builder tracks context assignment to prevent accidental overwrites:
242/// - **Debug builds**: Panics on double-set with clear diagnostics
243/// - **Release builds**: Last-write-wins (no runtime overhead)
244///
245/// This is intentional: debug builds catch logic bugs, release builds prioritize
246/// performance and allow intentional overwrites in complex error construction.
247///
248/// If you need compile-time enforcement, consider the typestate pattern, but
249/// this was rejected for ergonomics (see DESIGN_DECISIONS.md).
250///
251/// # Example
252///
253/// ```rust
254/// use palisade_errors::{ContextBuilder, OperationCategory, SocAccess};
255///
256/// let err = ContextBuilder::new()
257/// .public_lie("Access denied")
258/// .internal_sensitive("Unauthorized: user lacks 'admin' role")
259/// .category(OperationCategory::Detection)
260/// .build();
261///
262/// // Public message: "Access denied"
263/// assert_eq!(err.external_message(), "Access denied");
264///
265/// // Internal context requires SocAccess
266/// let access = SocAccess::acquire();
267/// let internal = err.internal().expose_sensitive(&access);
268/// assert_eq!(internal, Some("Unauthorized: user lacks 'admin' role"));
269/// ```
270pub struct ContextBuilder {
271 public: Option<PublicContext>,
272 internal: Option<InternalContext>,
273 category: OperationCategory,
274}
275
276impl ContextBuilder {
277 /// Create a new builder with default category (System).
278 #[inline]
279 pub fn new() -> Self {
280 Self {
281 public: None,
282 internal: None,
283 category: OperationCategory::System,
284 }
285 }
286
287 /// Set public context as deceptive lie (default for honeypot deployments).
288 ///
289 /// # Panics (Debug Mode)
290 ///
291 /// Panics if public context was already set. This prevents silent overwrites
292 /// in complex error construction flows.
293 ///
294 /// # Example
295 ///
296 /// ```rust
297 /// # use palisade_errors::ContextBuilder;
298 /// let builder = ContextBuilder::new()
299 /// .public_lie("Permission denied");
300 /// ```
301 #[inline]
302 pub fn public_lie(mut self, message: impl Into<Cow<'static, str>>) -> Self {
303 debug_assert!(
304 self.public.is_none(),
305 "ContextBuilder: public context already set (attempted overwrite with lie)"
306 );
307 self.public = Some(PublicContext::lie(message));
308 self
309 }
310
311 /// Set public context as truthful message (requires `external_signaling` feature).
312 ///
313 /// # Panics (Debug Mode)
314 ///
315 /// Panics if public context was already set.
316 ///
317 /// # Example
318 ///
319 /// ```rust,ignore
320 /// # use palisade_errors::ContextBuilder;
321 /// let builder = ContextBuilder::new()
322 /// .public_truth("Invalid JSON syntax");
323 /// ```
324 #[cfg(feature = "external_signaling")]
325 #[inline]
326 pub fn public_truth(mut self, message: impl Into<Cow<'static, str>>) -> Self {
327 debug_assert!(
328 self.public.is_none(),
329 "ContextBuilder: public context already set (attempted overwrite with truth)"
330 );
331 self.public = Some(PublicContext::truth(message));
332 self
333 }
334
335 /// Set internal context as diagnostic (non-sensitive).
336 ///
337 /// # Panics (Debug Mode)
338 ///
339 /// Panics if internal context was already set.
340 ///
341 /// # Example
342 ///
343 /// ```rust
344 /// # use palisade_errors::ContextBuilder;
345 /// let builder = ContextBuilder::new()
346 /// .internal_diagnostic("Database query failed: timeout after 30s");
347 /// ```
348 #[inline]
349 pub fn internal_diagnostic(mut self, message: impl Into<Cow<'static, str>>) -> Self {
350 debug_assert!(
351 self.internal.is_none(),
352 "ContextBuilder: internal context already set (attempted overwrite with diagnostic)"
353 );
354 self.internal = Some(InternalContext::diagnostic(message));
355 self
356 }
357
358 /// Set internal context as sensitive (requires SocAccess to view).
359 ///
360 /// # Panics (Debug Mode)
361 ///
362 /// Panics if internal context was already set.
363 ///
364 /// # Example
365 ///
366 /// ```rust
367 /// # use palisade_errors::ContextBuilder;
368 /// let builder = ContextBuilder::new()
369 /// .internal_sensitive("Failed to read /etc/shadow: permission denied");
370 /// ```
371 #[inline]
372 pub fn internal_sensitive(mut self, message: impl Into<Cow<'static, str>>) -> Self {
373 debug_assert!(
374 self.internal.is_none(),
375 "ContextBuilder: internal context already set (attempted overwrite with sensitive)"
376 );
377 self.internal = Some(InternalContext::sensitive(message));
378 self
379 }
380
381 /// Set internal context as tracked lie (for deception analysis).
382 ///
383 /// # Panics (Debug Mode)
384 ///
385 /// Panics if internal context was already set.
386 ///
387 /// # Example
388 ///
389 /// ```rust
390 /// # use palisade_errors::ContextBuilder;
391 /// let builder = ContextBuilder::new()
392 /// .internal_lie("Normal database operation completed successfully");
393 /// ```
394 #[inline]
395 pub fn internal_lie(mut self, message: impl Into<Cow<'static, str>>) -> Self {
396 debug_assert!(
397 self.internal.is_none(),
398 "ContextBuilder: internal context already set (attempted overwrite with lie)"
399 );
400 self.internal = Some(InternalContext::lie(message));
401 self
402 }
403
404 /// Set operation category.
405 ///
406 /// # Example
407 ///
408 /// ```rust
409 /// # use palisade_errors::{ContextBuilder, OperationCategory};
410 /// let builder = ContextBuilder::new()
411 /// .category(OperationCategory::Detection);
412 /// ```
413 #[inline]
414 pub fn category(mut self, category: OperationCategory) -> Self {
415 self.category = category;
416 self
417 }
418
419 /// Build the final `DualContextError`.
420 ///
421 /// # Panics
422 ///
423 /// Panics if public or internal context is not set. Use `try_build()` for
424 /// a non-panicking version.
425 ///
426 /// # Example
427 ///
428 /// ```rust
429 /// # use palisade_errors::{ContextBuilder, OperationCategory};
430 /// let err = ContextBuilder::new()
431 /// .public_lie("Operation failed")
432 /// .internal_diagnostic("Timeout")
433 /// .category(OperationCategory::IO)
434 /// .build();
435 /// ```
436 #[inline]
437 pub fn build(self) -> DualContextError {
438 self.try_build()
439 .expect("ContextBuilder requires both public and internal context")
440 }
441
442 /// Try to build the `DualContextError`, returning an error if incomplete.
443 ///
444 /// # Errors
445 ///
446 /// Returns `Err(ContextBuilderError)` if public or internal context is missing.
447 /// The error includes diagnostic information about builder state.
448 ///
449 /// # Example
450 ///
451 /// ```rust
452 /// # use palisade_errors::ContextBuilder;
453 /// let result = ContextBuilder::new()
454 /// .public_lie("Error")
455 /// .try_build();
456 ///
457 /// assert!(result.is_err()); // Missing internal context
458 /// ```
459 #[inline]
460 pub fn try_build(self) -> Result<DualContextError, ContextBuilderError> {
461 let has_public = self.public.is_some();
462 let has_internal = self.internal.is_some();
463
464 let public = self.public.ok_or(ContextBuilderError::MissingPublicContext {
465 has_internal,
466 })?;
467 let internal = self.internal.ok_or(ContextBuilderError::MissingInternalContext {
468 has_public,
469 })?;
470
471 Ok(DualContextError::new(public, internal, self.category))
472 }
473}
474
475impl Default for ContextBuilder {
476 fn default() -> Self {
477 Self::new()
478 }
479}
480
481/// Error type for context builder failures.
482///
483/// # Diagnostic Context
484///
485/// Each variant includes information about what was missing and the state
486/// of the builder when the error occurred, enabling better debugging.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub enum ContextBuilderError {
489 /// Public context was not set before building.
490 ///
491 /// This means neither `public_lie()` nor `public_truth()` was called.
492 MissingPublicContext {
493 /// Whether internal context was set (helps diagnose partial builds).
494 has_internal: bool,
495 },
496 /// Internal context was not set before building.
497 ///
498 /// This means none of `internal_diagnostic()`, `internal_sensitive()`,
499 /// or `internal_lie()` were called.
500 MissingInternalContext {
501 /// Whether public context was set (helps diagnose partial builds).
502 has_public: bool,
503 },
504}
505
506impl std::fmt::Display for ContextBuilderError {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 match self {
509 Self::MissingPublicContext { has_internal } => {
510 write!(
511 f,
512 "ContextBuilder missing public context (internal: {}; public: missing)",
513 if *has_internal { "set" } else { "missing" }
514 )
515 }
516 Self::MissingInternalContext { has_public } => {
517 write!(
518 f,
519 "ContextBuilder missing internal context (public: {}; internal: missing)",
520 if *has_public { "set" } else { "missing" }
521 )
522 }
523 }
524 }
525}
526
527impl std::error::Error for ContextBuilderError {}
528
529// ============================================================================
530// Context Chain (Causality Tracking)
531// ============================================================================
532
533/// Error chain for tracking causality across system boundaries.
534///
535/// # Purpose
536///
537/// Honeypot systems often need to track error propagation across multiple
538/// subsystems while maintaining public/internal separation at each hop.
539///
540/// This type provides:
541/// - Stack-like error accumulation
542/// - Causality timestamps
543/// - Cross-boundary sanitization
544///
545/// # Security
546///
547/// Each link in the chain maintains its own public/internal separation.
548/// Exposing the chain to external systems only reveals public contexts.
549///
550/// # Clone Policy
551///
552/// ⚠️ **This type does NOT implement Clone.**
553///
554/// Cloning error chains would:
555/// - Duplicate sensitive internal contexts across memory
556/// - Create multiple zeroization sites with unpredictable drop order
557/// - Violate threat model assumptions about data lifetime
558///
559/// If you need to share chain information:
560/// - Use borrowing (`&ContextChain`) for read-only access
561/// - Use `external_summary()` for public-safe string representation
562/// - Use `safe_clone_public()` to create a sanitized clone (see method docs)
563///
564/// This is a deliberate design decision to prevent accidental security
565/// violations via casual `.clone()` calls.
566///
567/// # Example
568///
569/// ```rust
570/// use palisade_errors::{ContextChain, DualContextError, OperationCategory};
571///
572/// let root = DualContextError::with_lie(
573/// "Operation failed",
574/// "Database connection refused",
575/// OperationCategory::IO,
576/// );
577///
578/// let mut chain = ContextChain::new(root);
579///
580/// let retry_failed = DualContextError::with_lie(
581/// "Retry failed",
582/// "Max retries (3) exceeded",
583/// OperationCategory::System,
584/// );
585///
586/// chain.push(retry_failed);
587///
588/// assert_eq!(chain.depth(), 2);
589/// ```
590pub struct ContextChain {
591 /// Stack of errors from root cause to final symptom.
592 /// Index 0 is the root cause, last index is the final error.
593 links: SmallVec<[DualContextError; 4]>,
594}
595
596impl ContextChain {
597 /// Create a new chain with a root error.
598 #[inline]
599 pub fn new(root: DualContextError) -> Self {
600 let mut links = SmallVec::new();
601 links.push(root);
602 Self { links }
603 }
604
605 /// Add a new error to the chain (as the new head).
606 ///
607 /// # Example
608 ///
609 /// ```rust
610 /// # use palisade_errors::{ContextChain, DualContextError, OperationCategory};
611 /// # let root = DualContextError::with_lie("a", "b", OperationCategory::System);
612 /// let mut chain = ContextChain::new(root);
613 ///
614 /// let next_error = DualContextError::with_lie(
615 /// "Propagated error",
616 /// "Original error: connection refused",
617 /// OperationCategory::System,
618 /// );
619 ///
620 /// chain.push(next_error);
621 /// ```
622 #[inline]
623 pub fn push(&mut self, error: DualContextError) {
624 self.links.push(error);
625 }
626
627 /// Get the root cause error (first in chain).
628 #[inline]
629 pub fn root(&self) -> &DualContextError {
630 &self.links[0]
631 }
632
633 /// Get the final error (last in chain).
634 #[inline]
635 pub fn head(&self) -> &DualContextError {
636 self.links.last().expect("Chain is never empty")
637 }
638
639 /// Get the chain depth (number of errors).
640 #[inline]
641 pub fn depth(&self) -> usize {
642 self.links.len()
643 }
644
645 /// Iterate over the error chain from root to head.
646 #[inline]
647 pub fn iter(&self) -> impl Iterator<Item = &DualContextError> {
648 self.links.iter()
649 }
650
651 /// Get external representation of the entire chain (public contexts only).
652 ///
653 /// # Returns
654 ///
655 /// A string showing the public message progression from root to head.
656 ///
657 /// # Use Case
658 ///
659 /// This method exists as the safe alternative to cloning for most scenarios:
660 /// - Logging to external systems
661 /// - User-facing error messages
662 /// - Telemetry and alerting
663 ///
664 /// If you need the chain structure itself without internal contexts, consider
665 /// whether you actually need the structure or just the narrative flow. In most
666 /// cases, this string representation is sufficient.
667 ///
668 /// # Performance
669 ///
670 /// Pre-calculates capacity to avoid multiple allocations during formatting.
671 ///
672 /// # Example
673 ///
674 /// ```rust
675 /// # use palisade_errors::{ContextChain, DualContextError, OperationCategory};
676 /// # let root = DualContextError::with_lie("Database error", "x", OperationCategory::IO);
677 /// # let mut chain = ContextChain::new(root);
678 /// # let next = DualContextError::with_lie("Retry failed", "y", OperationCategory::System);
679 /// # chain.push(next);
680 /// let external = chain.external_summary();
681 /// // Output: "Database error → Retry failed"
682 /// ```
683 pub fn external_summary(&self) -> String {
684 if self.links.is_empty() {
685 return String::new();
686 }
687
688 // Pre-calculate capacity to avoid reallocations
689 // Formula: sum of message lengths + (n-1) separators
690 let separator = " → ";
691 let capacity = self
692 .links
693 .iter()
694 .map(|e| e.external_message().len())
695 .sum::<usize>()
696 + (self.links.len().saturating_sub(1) * separator.len());
697
698 let mut result = String::with_capacity(capacity);
699
700 for (i, error) in self.links.iter().enumerate() {
701 if i > 0 {
702 result.push_str(separator);
703 }
704 result.push_str(error.external_message());
705 }
706
707 result
708 }
709}
710
711impl Zeroize for ContextChain {
712 fn zeroize(&mut self) {
713 for entry in &mut self.links {
714 entry.zeroize();
715 }
716 self.links.clear();
717 }
718}
719
720impl Drop for ContextChain {
721 fn drop(&mut self) {
722 self.zeroize();
723 }
724}
725
726// ============================================================================
727// Tests
728// ============================================================================
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::SocAccess;
734
735 #[test]
736 fn context_metadata_basic_operations() {
737 let mut meta = ContextMetadata::new();
738
739 meta.add("key1", "value1");
740 meta.add("key2", "value2");
741
742 assert_eq!(meta.len(), 2);
743 assert_eq!(meta.get("key1"), Some("value1"));
744 assert_eq!(meta.get("key2"), Some("value2"));
745 assert_eq!(meta.get("key3"), None);
746 }
747
748 #[test]
749 fn context_metadata_zeroization() {
750 let mut meta = ContextMetadata::new();
751 meta.add("sensitive", "secret123".to_string());
752
753 meta.zeroize();
754
755 assert_eq!(meta.len(), 0);
756 assert!(meta.is_empty());
757 }
758
759 #[test]
760 fn context_builder_basic_usage() {
761 let err = ContextBuilder::new()
762 .public_lie("Access denied")
763 .internal_diagnostic("User lacks permission")
764 .category(OperationCategory::Detection)
765 .build();
766
767 assert_eq!(err.external_message(), "Access denied");
768 }
769
770 #[test]
771 fn context_builder_with_sensitive() {
772 let err = ContextBuilder::new()
773 .public_lie("Operation failed")
774 .internal_sensitive("/etc/passwd: permission denied")
775 .category(OperationCategory::IO)
776 .build();
777
778 assert_eq!(err.external_message(), "Operation failed");
779
780 let access = SocAccess::acquire();
781 let sensitive = err.internal().expose_sensitive(&access);
782 assert_eq!(sensitive, Some("/etc/passwd: permission denied"));
783 }
784
785 #[test]
786 #[should_panic(expected = "ContextBuilder requires both public and internal context")]
787 fn context_builder_panics_without_public() {
788 ContextBuilder::new()
789 .internal_diagnostic("Missing public")
790 .build();
791 }
792
793 #[test]
794 #[should_panic(expected = "ContextBuilder requires both public and internal context")]
795 fn context_builder_panics_without_internal() {
796 ContextBuilder::new().public_lie("Missing internal").build();
797 }
798
799 #[test]
800 fn context_builder_try_build_validation() {
801 let result = ContextBuilder::new().try_build();
802 assert!(matches!(
803 result,
804 Err(ContextBuilderError::MissingPublicContext { has_internal: false })
805 ));
806
807 let result = ContextBuilder::new().public_lie("test").try_build();
808 assert!(matches!(
809 result,
810 Err(ContextBuilderError::MissingInternalContext { has_public: true })
811 ));
812
813 let result = ContextBuilder::new()
814 .internal_diagnostic("test")
815 .try_build();
816 assert!(matches!(
817 result,
818 Err(ContextBuilderError::MissingPublicContext { has_internal: true })
819 ));
820 }
821
822 #[test]
823 #[cfg(debug_assertions)]
824 #[should_panic(expected = "public context already set")]
825 fn context_builder_panics_on_double_public() {
826 ContextBuilder::new()
827 .public_lie("First")
828 .public_lie("Second")
829 .internal_diagnostic("test")
830 .build();
831 }
832
833 #[test]
834 #[cfg(debug_assertions)]
835 #[should_panic(expected = "internal context already set")]
836 fn context_builder_panics_on_double_internal() {
837 ContextBuilder::new()
838 .public_lie("test")
839 .internal_diagnostic("First")
840 .internal_diagnostic("Second")
841 .build();
842 }
843
844 #[test]
845 fn context_builder_error_messages_include_state() {
846 let err = ContextBuilder::new().try_build().unwrap_err();
847 let msg = err.to_string();
848 assert!(msg.contains("public: missing"));
849 assert!(msg.contains("internal: missing"));
850
851 let err = ContextBuilder::new()
852 .public_lie("test")
853 .try_build()
854 .unwrap_err();
855 let msg = err.to_string();
856 assert!(msg.contains("internal: missing"));
857 assert!(msg.contains("public: set"));
858 }
859
860 #[test]
861 fn context_chain_basic_usage() {
862 let root = DualContextError::with_lie(
863 "Database error",
864 "Connection refused",
865 OperationCategory::IO,
866 );
867
868 let mut chain = ContextChain::new(root);
869
870 assert_eq!(chain.depth(), 1);
871 assert_eq!(chain.root().external_message(), "Database error");
872 assert_eq!(chain.head().external_message(), "Database error");
873
874 let retry_error = DualContextError::with_lie(
875 "Retry failed",
876 "Max retries exceeded",
877 OperationCategory::System,
878 );
879
880 chain.push(retry_error);
881
882 assert_eq!(chain.depth(), 2);
883 assert_eq!(chain.root().external_message(), "Database error");
884 assert_eq!(chain.head().external_message(), "Retry failed");
885 }
886
887 #[test]
888 fn context_chain_external_summary() {
889 let root = DualContextError::with_lie(
890 "Root cause",
891 "Internal details",
892 OperationCategory::System,
893 );
894
895 let mut chain = ContextChain::new(root);
896
897 chain.push(DualContextError::with_lie(
898 "Intermediate",
899 "Details",
900 OperationCategory::System,
901 ));
902 chain.push(DualContextError::with_lie(
903 "Final error",
904 "Details",
905 OperationCategory::System,
906 ));
907
908 let summary = chain.external_summary();
909 assert_eq!(summary, "Root cause → Intermediate → Final error");
910 }
911
912 #[test]
913 fn context_chain_external_summary_single_error() {
914 let root = DualContextError::with_lie("Single", "Details", OperationCategory::System);
915 let chain = ContextChain::new(root);
916
917 let summary = chain.external_summary();
918 assert_eq!(summary, "Single");
919 }
920
921 #[test]
922 fn context_chain_external_summary_long_messages() {
923 let root = DualContextError::with_lie(
924 "A".repeat(100),
925 "Details",
926 OperationCategory::System,
927 );
928 let mut chain = ContextChain::new(root);
929
930 chain.push(DualContextError::with_lie(
931 "B".repeat(100),
932 "Details",
933 OperationCategory::System,
934 ));
935
936 let summary = chain.external_summary();
937 assert!(summary.len() >= 200); // Both messages plus separator
938 assert!(summary.contains('→'));
939 }
940
941 #[test]
942 fn context_chain_iteration() {
943 let root = DualContextError::with_lie("E1", "D1", OperationCategory::System);
944 let mut chain = ContextChain::new(root);
945
946 chain.push(DualContextError::with_lie("E2", "D2", OperationCategory::System));
947 chain.push(DualContextError::with_lie("E3", "D3", OperationCategory::System));
948
949 let messages: Vec<_> = chain.iter().map(|e| e.external_message()).collect();
950 assert_eq!(messages, vec!["E1", "E2", "E3"]);
951 }
952
953 #[test]
954 fn metadata_with_owned_and_borrowed() {
955 let mut meta = ContextMetadata::new();
956
957 meta.add("static", "literal"); // Borrowed
958 meta.add("dynamic", format!("value-{}", 42)); // Owned
959
960 assert_eq!(meta.get("static"), Some("literal"));
961 assert_eq!(meta.get("dynamic"), Some("value-42"));
962 }
963
964 #[test]
965 fn metadata_iteration() {
966 let mut meta = ContextMetadata::new();
967 meta.add("key1", "val1");
968 meta.add("key2", "val2");
969
970 let collected: Vec<_> = meta.iter().collect();
971 assert_eq!(collected.len(), 2);
972 assert!(collected.contains(&("key1", "val1")));
973 assert!(collected.contains(&("key2", "val2")));
974 }
975}