Skip to main content

lance_core/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::fmt;
5
6use arrow_schema::ArrowError;
7use snafu::{IntoError as _, Location, Snafu};
8
9type BoxedError = Box<dyn std::error::Error + Send + Sync + 'static>;
10
11#[cfg(feature = "backtrace")]
12mod backtrace_support {
13    use std::backtrace::Backtrace;
14
15    use snafu::{AsBacktrace, GenerateImplicitData};
16
17    #[derive(Debug)]
18    pub struct MaybeBacktrace(pub Option<Backtrace>);
19
20    impl GenerateImplicitData for MaybeBacktrace {
21        fn generate() -> Self {
22            Self(<Option<Backtrace>>::generate())
23        }
24    }
25
26    impl AsBacktrace for MaybeBacktrace {
27        fn as_backtrace(&self) -> Option<&Backtrace> {
28            self.0.as_ref()
29        }
30    }
31}
32
33#[cfg(not(feature = "backtrace"))]
34mod backtrace_support {
35    use std::backtrace::Backtrace;
36
37    use snafu::{AsBacktrace, GenerateImplicitData};
38
39    #[derive(Debug)]
40    pub struct MaybeBacktrace;
41
42    impl GenerateImplicitData for MaybeBacktrace {
43        fn generate() -> Self {
44            Self
45        }
46    }
47
48    impl AsBacktrace for MaybeBacktrace {
49        fn as_backtrace(&self) -> Option<&Backtrace> {
50            None
51        }
52    }
53}
54
55use backtrace_support::MaybeBacktrace;
56
57/// Error for when a requested field is not found in a schema.
58///
59/// This error computes suggestions lazily (only when displayed) to avoid
60/// computing Levenshtein distance when the error is created but never shown.
61#[derive(Debug)]
62pub struct FieldNotFoundError {
63    pub field_name: String,
64    pub candidates: Vec<String>,
65}
66
67impl fmt::Display for FieldNotFoundError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(f, "Field '{}' not found.", self.field_name)?;
70        let suggestion =
71            crate::levenshtein::find_best_suggestion(&self.field_name, &self.candidates);
72        if let Some(suggestion) = suggestion {
73            write!(f, " Did you mean '{}'?", suggestion)?;
74        }
75        write!(f, "\nAvailable fields: [")?;
76        for (i, candidate) in self.candidates.iter().take(10).enumerate() {
77            if i > 0 {
78                write!(f, ", ")?;
79            }
80            write!(f, "'{}'", candidate)?;
81        }
82        if self.candidates.len() > 10 {
83            let remaining = self.candidates.len() - 10;
84            write!(f, ", ... and {} more]", remaining)?;
85        } else {
86            write!(f, "]")?;
87        }
88        Ok(())
89    }
90}
91
92impl std::error::Error for FieldNotFoundError {}
93
94/// A manifest commit returned an error and its final outcome could not be
95/// determined safely.
96///
97/// This is wrapped in [`Error::Wrapped`] so Lance can expose a structured
98/// source without adding a variant to the exhaustive public [`Error`] enum.
99#[derive(Debug)]
100pub struct CommitStatusUnknownError {
101    version: u64,
102    source: BoxedError,
103}
104
105impl CommitStatusUnknownError {
106    /// Return the manifest version whose commit outcome is unknown.
107    pub fn version(&self) -> u64 {
108        self.version
109    }
110}
111
112impl std::fmt::Display for CommitStatusUnknownError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        write!(
115            f,
116            "Commit result for version {} is unknown: the commit may or may not have been \
117             applied; check the table state before retrying: {}",
118            self.version, self.source
119        )
120    }
121}
122
123impl std::error::Error for CommitStatusUnknownError {
124    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
125        Some(self.source.as_ref())
126    }
127}
128
129/// Allocates error on the heap and then places `e` into it.
130#[inline]
131pub fn box_error(e: impl std::error::Error + Send + Sync + 'static) -> BoxedError {
132    Box::new(e)
133}
134
135/// Why a writer is fenced. Both reasons are terminal, but callers must tell them
136/// apart (a peer takeover vs. our own failure) rather than parse the message.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub enum FenceReason {
139    /// A successor writer claimed a higher epoch; this writer lost ownership.
140    PeerClaimedEpoch,
141    /// Our own WAL persistence failed, so in-memory state may have diverged from
142    /// the durable WAL. The writer must be reopened to replay.
143    PersistenceFailure,
144}
145
146impl std::fmt::Display for FenceReason {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        // Stable strings — surfaced in error messages.
149        let s = match self {
150            Self::PeerClaimedEpoch => "peer claimed epoch",
151            Self::PersistenceFailure => "persistence failure",
152        };
153        f.write_str(s)
154    }
155}
156
157#[derive(Debug, Snafu)]
158#[snafu(visibility(pub))]
159pub enum Error {
160    #[snafu(display("Invalid user input: {source}, {location}"))]
161    InvalidInput {
162        source: BoxedError,
163        #[snafu(implicit)]
164        location: Location,
165        #[snafu(implicit)]
166        backtrace: MaybeBacktrace,
167    },
168    #[snafu(display("Dataset already exists: {uri}, {location}"))]
169    DatasetAlreadyExists {
170        uri: String,
171        #[snafu(implicit)]
172        location: Location,
173        #[snafu(implicit)]
174        backtrace: MaybeBacktrace,
175    },
176    #[snafu(display("Append with different schema: {difference}, location: {location}"))]
177    SchemaMismatch {
178        difference: String,
179        #[snafu(implicit)]
180        location: Location,
181        #[snafu(implicit)]
182        backtrace: MaybeBacktrace,
183    },
184    #[snafu(display("Dataset at path {path} was not found: {source}, {location}"))]
185    DatasetNotFound {
186        path: String,
187        source: BoxedError,
188        #[snafu(implicit)]
189        location: Location,
190        #[snafu(implicit)]
191        backtrace: MaybeBacktrace,
192    },
193    #[snafu(display("Encountered corrupt file {path}: {source}, {location}"))]
194    CorruptFile {
195        path: object_store::path::Path,
196        source: BoxedError,
197        #[snafu(implicit)]
198        location: Location,
199        #[snafu(implicit)]
200        backtrace: MaybeBacktrace,
201    },
202    #[snafu(display("Not supported: {source}, {location}"))]
203    NotSupported {
204        source: BoxedError,
205        #[snafu(implicit)]
206        location: Location,
207        #[snafu(implicit)]
208        backtrace: MaybeBacktrace,
209    },
210    #[snafu(display("Commit conflict for version {version}: {source}, {location}"))]
211    CommitConflict {
212        version: u64,
213        source: BoxedError,
214        #[snafu(implicit)]
215        location: Location,
216        #[snafu(implicit)]
217        backtrace: MaybeBacktrace,
218    },
219    #[snafu(display("Incompatible transaction: {source}, {location}"))]
220    IncompatibleTransaction {
221        source: BoxedError,
222        #[snafu(implicit)]
223        location: Location,
224        #[snafu(implicit)]
225        backtrace: MaybeBacktrace,
226    },
227    #[snafu(display("Retryable commit conflict for version {version}: {source}, {location}"))]
228    RetryableCommitConflict {
229        version: u64,
230        source: BoxedError,
231        #[snafu(implicit)]
232        location: Location,
233        #[snafu(implicit)]
234        backtrace: MaybeBacktrace,
235    },
236    #[snafu(display("Too many concurrent writers. {message}, {location}"))]
237    TooMuchWriteContention {
238        message: String,
239        #[snafu(implicit)]
240        location: Location,
241        #[snafu(implicit)]
242        backtrace: MaybeBacktrace,
243    },
244    #[snafu(display("Operation timed out: {message}, {location}"))]
245    Timeout {
246        message: String,
247        #[snafu(implicit)]
248        location: Location,
249    },
250    #[snafu(display(
251        "Encountered internal error. Please file a bug report at https://github.com/lance-format/lance/issues. {message}, {location}"
252    ))]
253    Internal {
254        message: String,
255        #[snafu(implicit)]
256        location: Location,
257        #[snafu(implicit)]
258        backtrace: MaybeBacktrace,
259    },
260    #[snafu(display("A prerequisite task failed: {message}, {location}"))]
261    PrerequisiteFailed {
262        message: String,
263        #[snafu(implicit)]
264        location: Location,
265        #[snafu(implicit)]
266        backtrace: MaybeBacktrace,
267    },
268    #[snafu(display("Unprocessable: {message}, {location}"))]
269    Unprocessable {
270        message: String,
271        #[snafu(implicit)]
272        location: Location,
273        #[snafu(implicit)]
274        backtrace: MaybeBacktrace,
275    },
276    #[snafu(display("LanceError(Arrow): {message}, {location}"))]
277    Arrow {
278        message: String,
279        #[snafu(implicit)]
280        location: Location,
281        #[snafu(implicit)]
282        backtrace: MaybeBacktrace,
283    },
284    #[snafu(display("LanceError(Schema): {message}, {location}"))]
285    Schema {
286        message: String,
287        #[snafu(implicit)]
288        location: Location,
289        #[snafu(implicit)]
290        backtrace: MaybeBacktrace,
291    },
292    #[snafu(display("Not found: {uri}, {location}"))]
293    NotFound {
294        uri: String,
295        #[snafu(implicit)]
296        location: Location,
297        #[snafu(implicit)]
298        backtrace: MaybeBacktrace,
299    },
300    #[snafu(display("LanceError(IO): {source}, {location}"))]
301    IO {
302        source: BoxedError,
303        #[snafu(implicit)]
304        location: Location,
305        #[snafu(implicit)]
306        backtrace: MaybeBacktrace,
307    },
308    #[snafu(display("LanceError(Index): {message}, {location}"))]
309    Index {
310        message: String,
311        #[snafu(implicit)]
312        location: Location,
313        #[snafu(implicit)]
314        backtrace: MaybeBacktrace,
315    },
316    #[snafu(display("Lance index not found: {identity}, {location}"))]
317    IndexNotFound {
318        identity: String,
319        #[snafu(implicit)]
320        location: Location,
321        #[snafu(implicit)]
322        backtrace: MaybeBacktrace,
323    },
324    #[snafu(display("Cannot infer storage location from: {message}"))]
325    InvalidTableLocation { message: String },
326    /// Stream early stop
327    Stop,
328    #[snafu(display("Wrapped error: {error}, {location}"))]
329    Wrapped {
330        #[snafu(source)]
331        error: BoxedError,
332        #[snafu(implicit)]
333        location: Location,
334        #[snafu(implicit)]
335        backtrace: MaybeBacktrace,
336    },
337    #[snafu(display("Cloned error: {message}, {location}"))]
338    Cloned {
339        message: String,
340        #[snafu(implicit)]
341        location: Location,
342        #[snafu(implicit)]
343        backtrace: MaybeBacktrace,
344    },
345    #[snafu(display("Query Execution error: {message}, {location}"))]
346    Execution {
347        message: String,
348        #[snafu(implicit)]
349        location: Location,
350        #[snafu(implicit)]
351        backtrace: MaybeBacktrace,
352    },
353    #[snafu(display("Ref is invalid: {message}"))]
354    InvalidRef { message: String },
355    #[snafu(display("Ref conflict error: {message}"))]
356    RefConflict { message: String },
357    #[snafu(display("Ref not found error: {message}"))]
358    RefNotFound { message: String },
359    #[snafu(display("Cleanup error: {message}"))]
360    Cleanup { message: String },
361    #[snafu(display("Version not found error: {message}"))]
362    VersionNotFound { message: String },
363    #[snafu(display("Version conflict error: {message}"))]
364    VersionConflict {
365        message: String,
366        major_version: u16,
367        minor_version: u16,
368        #[snafu(implicit)]
369        location: Location,
370        #[snafu(implicit)]
371        backtrace: MaybeBacktrace,
372    },
373    #[snafu(display("Namespace error: {source}, {location}"))]
374    Namespace {
375        source: BoxedError,
376        #[snafu(implicit)]
377        location: Location,
378        #[snafu(implicit)]
379        backtrace: MaybeBacktrace,
380    },
381    /// External error passed through from user code.
382    ///
383    /// This variant preserves errors that users pass into Lance APIs (e.g., via streams
384    /// with custom error types). The original error can be recovered using [`Error::into_external`]
385    /// or inspected using [`Error::external_source`].
386    #[snafu(transparent)]
387    External { source: BoxedError },
388
389    /// A requested field was not found in a schema.
390    #[snafu(transparent)]
391    FieldNotFound { source: FieldNotFoundError },
392
393    #[snafu(display(
394        "Spill disk cap of {cap_bytes} bytes exceeded; currently using {used_bytes} bytes, {location}"
395    ))]
396    DiskCapExceeded {
397        cap_bytes: u64,
398        used_bytes: u64,
399        #[snafu(implicit)]
400        location: Location,
401    },
402    /// A writer has been fenced and must stop (see [`FenceReason`]). The message
403    /// keeps the `Writer fenced` prefix for legacy string consumers; new code
404    /// should match on [`Error::fence_reason`].
405    #[snafu(display("Writer fenced ({reason}): {message}, {location}"))]
406    Fenced {
407        reason: FenceReason,
408        message: String,
409        #[snafu(implicit)]
410        location: Location,
411    },
412}
413
414impl Error {
415    /// Returns the captured Rust backtrace, if available.
416    ///
417    /// Requires the `backtrace` feature to be enabled at compile time
418    /// and `RUST_BACKTRACE=1` at runtime.
419    #[cfg(feature = "backtrace")]
420    pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> {
421        match self {
422            Self::InvalidInput { backtrace, .. }
423            | Self::DatasetAlreadyExists { backtrace, .. }
424            | Self::SchemaMismatch { backtrace, .. }
425            | Self::DatasetNotFound { backtrace, .. }
426            | Self::CorruptFile { backtrace, .. }
427            | Self::NotSupported { backtrace, .. }
428            | Self::CommitConflict { backtrace, .. }
429            | Self::IncompatibleTransaction { backtrace, .. }
430            | Self::RetryableCommitConflict { backtrace, .. }
431            | Self::TooMuchWriteContention { backtrace, .. }
432            | Self::Internal { backtrace, .. }
433            | Self::PrerequisiteFailed { backtrace, .. }
434            | Self::Unprocessable { backtrace, .. }
435            | Self::Arrow { backtrace, .. }
436            | Self::Schema { backtrace, .. }
437            | Self::NotFound { backtrace, .. }
438            | Self::IO { backtrace, .. }
439            | Self::Index { backtrace, .. }
440            | Self::IndexNotFound { backtrace, .. }
441            | Self::Wrapped { backtrace, .. }
442            | Self::Cloned { backtrace, .. }
443            | Self::Execution { backtrace, .. }
444            | Self::VersionConflict { backtrace, .. }
445            | Self::Namespace { backtrace, .. } => {
446                use snafu::AsBacktrace;
447                backtrace.as_backtrace()
448            }
449            // Variants without a backtrace field — listed explicitly so that
450            // adding a new variant with a backtrace field triggers a compiler error.
451            Self::InvalidTableLocation { .. }
452            | Self::Stop
453            | Self::InvalidRef { .. }
454            | Self::RefConflict { .. }
455            | Self::RefNotFound { .. }
456            | Self::Cleanup { .. }
457            | Self::VersionNotFound { .. }
458            | Self::External { .. }
459            | Self::FieldNotFound { .. }
460            | Self::Timeout { .. }
461            | Self::DiskCapExceeded { .. }
462            | Self::Fenced { .. } => None,
463        }
464    }
465
466    /// Returns the captured Rust backtrace, if available.
467    ///
468    /// Always returns `None` when the `backtrace` feature is not enabled.
469    #[cfg(not(feature = "backtrace"))]
470    pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> {
471        None
472    }
473
474    #[track_caller]
475    pub fn corrupt_file(path: object_store::path::Path, message: impl Into<String>) -> Self {
476        CorruptFileSnafu { path }.into_error(message.into().into())
477    }
478
479    /// Reports a corrupt file when the caller only has a logical/section name
480    /// rather than the real file path (for example, a decoder that validates an
481    /// in-memory buffer and does not know where it came from).
482    ///
483    /// `name` is carried in the `path` field of the resulting [`Error::CorruptFile`]
484    /// variant and is NOT a filesystem path; callers that have the real path should
485    /// use [`Self::corrupt_file`] instead.
486    #[track_caller]
487    pub fn corrupt_file_named(name: &str, message: impl Into<String>) -> Self {
488        Self::corrupt_file(object_store::path::Path::from(name), message)
489    }
490
491    #[track_caller]
492    pub fn invalid_input(message: impl Into<String>) -> Self {
493        InvalidInputSnafu.into_error(message.into().into())
494    }
495
496    #[track_caller]
497    pub fn invalid_input_source(source: BoxedError) -> Self {
498        InvalidInputSnafu.into_error(source)
499    }
500
501    #[track_caller]
502    pub fn io(message: impl Into<String>) -> Self {
503        IOSnafu.into_error(message.into().into())
504    }
505
506    /// A successor writer claimed a higher epoch; this writer lost ownership.
507    #[track_caller]
508    pub fn fenced_by_peer(message: impl Into<String>) -> Self {
509        FencedSnafu {
510            reason: FenceReason::PeerClaimedEpoch,
511            message: message.into(),
512        }
513        .build()
514    }
515
516    /// Our WAL persistence failed; in-memory state may have diverged from the
517    /// durable WAL, so the writer must be reopened to replay.
518    #[track_caller]
519    pub fn writer_poisoned(message: impl Into<String>) -> Self {
520        FencedSnafu {
521            reason: FenceReason::PersistenceFailure,
522            message: message.into(),
523        }
524        .build()
525    }
526
527    /// The [`FenceReason`] if this is [`Error::Fenced`], else `None`. Prefer this
528    /// over matching the error message to decide how to react to a fence.
529    pub fn fence_reason(&self) -> Option<FenceReason> {
530        match self {
531            Self::Fenced { reason, .. } => Some(*reason),
532            _ => None,
533        }
534    }
535
536    #[track_caller]
537    pub fn io_source(source: BoxedError) -> Self {
538        IOSnafu.into_error(source)
539    }
540
541    #[track_caller]
542    pub fn dataset_already_exists(uri: impl Into<String>) -> Self {
543        DatasetAlreadyExistsSnafu { uri: uri.into() }.build()
544    }
545
546    #[track_caller]
547    pub fn dataset_not_found(path: impl Into<String>, source: BoxedError) -> Self {
548        DatasetNotFoundSnafu { path: path.into() }.into_error(source)
549    }
550
551    #[track_caller]
552    pub fn version_conflict(
553        message: impl Into<String>,
554        major_version: u16,
555        minor_version: u16,
556    ) -> Self {
557        VersionConflictSnafu {
558            message: message.into(),
559            major_version,
560            minor_version,
561        }
562        .build()
563    }
564
565    #[track_caller]
566    pub fn not_found(uri: impl Into<String>) -> Self {
567        NotFoundSnafu { uri: uri.into() }.build()
568    }
569
570    /// Return whether this error or one of its typed sources is a missing object.
571    pub fn is_not_found(&self) -> bool {
572        match self {
573            Self::NotFound { .. } => true,
574            Self::Wrapped { error, .. }
575                if error.downcast_ref::<CommitStatusUnknownError>().is_some() =>
576            {
577                false
578            }
579            Self::IO { source, .. } | Self::Wrapped { error: source, .. } => {
580                error_source_is_not_found(source.as_ref())
581            }
582            _ => false,
583        }
584    }
585
586    #[track_caller]
587    pub fn wrapped(error: BoxedError) -> Self {
588        WrappedSnafu.into_error(error)
589    }
590
591    #[track_caller]
592    pub fn schema(message: impl Into<String>) -> Self {
593        SchemaSnafu {
594            message: message.into(),
595        }
596        .build()
597    }
598
599    #[track_caller]
600    pub fn not_supported(message: impl Into<String>) -> Self {
601        NotSupportedSnafu.into_error(message.into().into())
602    }
603
604    #[track_caller]
605    pub fn not_supported_source(source: BoxedError) -> Self {
606        NotSupportedSnafu.into_error(source)
607    }
608
609    #[track_caller]
610    pub fn internal(message: impl Into<String>) -> Self {
611        InternalSnafu {
612            message: message.into(),
613        }
614        .build()
615    }
616
617    #[track_caller]
618    pub fn timeout(message: impl Into<String>) -> Self {
619        TimeoutSnafu {
620            message: message.into(),
621        }
622        .build()
623    }
624
625    #[track_caller]
626    pub fn namespace(message: impl Into<String>) -> Self {
627        NamespaceSnafu.into_error(message.into().into())
628    }
629
630    #[track_caller]
631    pub fn namespace_source(source: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
632        NamespaceSnafu.into_error(source)
633    }
634
635    #[track_caller]
636    pub fn arrow(message: impl Into<String>) -> Self {
637        ArrowSnafu {
638            message: message.into(),
639        }
640        .build()
641    }
642
643    #[track_caller]
644    pub fn execution(message: impl Into<String>) -> Self {
645        ExecutionSnafu {
646            message: message.into(),
647        }
648        .build()
649    }
650
651    #[track_caller]
652    pub fn cloned(message: impl Into<String>) -> Self {
653        ClonedSnafu {
654            message: message.into(),
655        }
656        .build()
657    }
658
659    #[track_caller]
660    pub fn schema_mismatch(difference: impl Into<String>) -> Self {
661        SchemaMismatchSnafu {
662            difference: difference.into(),
663        }
664        .build()
665    }
666
667    #[track_caller]
668    pub fn unprocessable(message: impl Into<String>) -> Self {
669        UnprocessableSnafu {
670            message: message.into(),
671        }
672        .build()
673    }
674
675    #[track_caller]
676    pub fn too_much_write_contention(message: impl Into<String>) -> Self {
677        TooMuchWriteContentionSnafu {
678            message: message.into(),
679        }
680        .build()
681    }
682
683    #[track_caller]
684    pub fn prerequisite_failed(message: impl Into<String>) -> Self {
685        PrerequisiteFailedSnafu {
686            message: message.into(),
687        }
688        .build()
689    }
690
691    #[track_caller]
692    pub fn index(message: impl Into<String>) -> Self {
693        IndexSnafu {
694            message: message.into(),
695        }
696        .build()
697    }
698
699    #[track_caller]
700    pub fn index_not_found(identity: impl Into<String>) -> Self {
701        IndexNotFoundSnafu {
702            identity: identity.into(),
703        }
704        .build()
705    }
706
707    #[track_caller]
708    pub fn commit_conflict_source(version: u64, source: BoxedError) -> Self {
709        CommitConflictSnafu { version }.into_error(source)
710    }
711
712    #[track_caller]
713    pub fn retryable_commit_conflict_source(version: u64, source: BoxedError) -> Self {
714        RetryableCommitConflictSnafu { version }.into_error(source)
715    }
716
717    #[track_caller]
718    pub fn commit_status_unknown_source(version: u64, source: BoxedError) -> Self {
719        Self::wrapped(box_error(CommitStatusUnknownError { version, source }))
720    }
721
722    /// Return whether this error represents a commit whose final outcome could
723    /// not be determined safely.
724    pub fn is_commit_status_unknown(&self) -> bool {
725        matches!(
726            self,
727            Self::Wrapped { error, .. }
728                if error.downcast_ref::<CommitStatusUnknownError>().is_some()
729        )
730    }
731
732    #[track_caller]
733    pub fn incompatible_transaction_source(source: BoxedError) -> Self {
734        IncompatibleTransactionSnafu.into_error(source)
735    }
736
737    #[track_caller]
738    pub fn disk_cap_exceeded(cap_bytes: u64, used_bytes: u64) -> Self {
739        DiskCapExceededSnafu {
740            cap_bytes,
741            used_bytes,
742        }
743        .build()
744    }
745
746    /// Create an External error from a boxed error source.
747    pub fn external(source: BoxedError) -> Self {
748        Self::External { source }
749    }
750
751    /// Create a FieldNotFound error with the given field name and available candidates.
752    pub fn field_not_found(field_name: impl Into<String>, candidates: Vec<String>) -> Self {
753        Self::FieldNotFound {
754            source: FieldNotFoundError {
755                field_name: field_name.into(),
756                candidates,
757            },
758        }
759    }
760
761    /// Returns a reference to the external error source if this is an `External` variant.
762    ///
763    /// This allows downcasting to recover the original error type.
764    pub fn external_source(&self) -> Option<&BoxedError> {
765        match self {
766            Self::External { source } => Some(source),
767            _ => None,
768        }
769    }
770
771    /// Consumes the error and returns the external source if this is an `External` variant.
772    ///
773    /// Returns `Err(self)` if this is not an `External` variant, allowing for chained handling.
774    pub fn into_external(self) -> std::result::Result<BoxedError, Self> {
775        match self {
776            Self::External { source } => Ok(source),
777            other => Err(other),
778        }
779    }
780}
781
782fn error_source_is_not_found(source: &(dyn std::error::Error + 'static)) -> bool {
783    if let Some(error) = source.downcast_ref::<Error>() {
784        return error.is_not_found();
785    }
786    if let Some(error) = source.downcast_ref::<object_store::Error>() {
787        return matches!(error, object_store::Error::NotFound { .. })
788            || std::error::Error::source(error).is_some_and(error_source_is_not_found);
789    }
790    source.source().is_some_and(error_source_is_not_found)
791}
792
793pub trait LanceOptionExt<T> {
794    /// Unwraps an option, returning an internal error if the option is None.
795    ///
796    /// Can be used when an option is expected to have a value.
797    fn expect_ok(self) -> Result<T>;
798}
799
800impl<T> LanceOptionExt<T> for Option<T> {
801    #[track_caller]
802    fn expect_ok(self) -> Result<T> {
803        self.ok_or_else(|| Error::internal("Expected option to have value"))
804    }
805}
806
807pub type Result<T> = std::result::Result<T, Error>;
808pub type ArrowResult<T> = std::result::Result<T, ArrowError>;
809#[cfg(feature = "datafusion")]
810pub type DataFusionResult<T> = std::result::Result<T, datafusion_common::DataFusionError>;
811
812impl From<ArrowError> for Error {
813    #[track_caller]
814    fn from(e: ArrowError) -> Self {
815        match e {
816            ArrowError::ExternalError(source) => {
817                // Try to downcast to lance_core::Error first to recover the original
818                match source.downcast::<Self>() {
819                    Ok(lance_err) => *lance_err,
820                    Err(source) => Self::External { source },
821                }
822            }
823            other => Self::arrow(other.to_string()),
824        }
825    }
826}
827
828impl From<&ArrowError> for Error {
829    #[track_caller]
830    fn from(e: &ArrowError) -> Self {
831        Self::arrow(e.to_string())
832    }
833}
834
835impl From<std::io::Error> for Error {
836    #[track_caller]
837    fn from(e: std::io::Error) -> Self {
838        // A lance `Error` may have been wrapped in an `io::Error` (e.g. via
839        // `io::Error::other(Error::...)`) to cross an `AsyncWrite`/`AsyncRead`
840        // boundary. Recover it so typed errors such as `DiskCapExceeded`
841        // survive the round-trip instead of collapsing into an opaque `IO`.
842        if e.get_ref().is_some_and(|inner| inner.is::<Self>()) {
843            return *e
844                .into_inner()
845                .expect("checked Some above")
846                .downcast::<Self>()
847                .expect("checked type above");
848        }
849        Self::io_source(box_error(e))
850    }
851}
852
853impl From<object_store::Error> for Error {
854    #[track_caller]
855    fn from(e: object_store::Error) -> Self {
856        match e {
857            // source intentionally dropped; Error::NotFound carries only the path
858            object_store::Error::NotFound { path, .. } => Self::not_found(path),
859            other => Self::io_source(box_error(other)),
860        }
861    }
862}
863
864impl From<prost::DecodeError> for Error {
865    #[track_caller]
866    fn from(e: prost::DecodeError) -> Self {
867        Self::io_source(box_error(e))
868    }
869}
870
871impl From<prost::EncodeError> for Error {
872    #[track_caller]
873    fn from(e: prost::EncodeError) -> Self {
874        Self::io_source(box_error(e))
875    }
876}
877
878impl From<prost::UnknownEnumValue> for Error {
879    #[track_caller]
880    fn from(e: prost::UnknownEnumValue) -> Self {
881        Self::io_source(box_error(e))
882    }
883}
884
885impl From<tokio::task::JoinError> for Error {
886    #[track_caller]
887    fn from(e: tokio::task::JoinError) -> Self {
888        Self::io_source(box_error(e))
889    }
890}
891
892impl From<object_store::path::Error> for Error {
893    #[track_caller]
894    fn from(e: object_store::path::Error) -> Self {
895        Self::io_source(box_error(e))
896    }
897}
898
899impl From<url::ParseError> for Error {
900    #[track_caller]
901    fn from(e: url::ParseError) -> Self {
902        Self::io_source(box_error(e))
903    }
904}
905
906impl From<serde_json::Error> for Error {
907    #[track_caller]
908    fn from(e: serde_json::Error) -> Self {
909        Self::arrow(e.to_string())
910    }
911}
912
913impl From<Error> for ArrowError {
914    fn from(value: Error) -> Self {
915        match value {
916            // Pass through external errors directly
917            Error::External { source } => Self::ExternalError(source),
918            // Preserve schema errors with their specific type
919            Error::Schema { message, .. } => Self::SchemaError(message),
920            // Wrap all other lance errors so they can be recovered
921            e => Self::ExternalError(Box::new(e)),
922        }
923    }
924}
925
926#[cfg(feature = "datafusion")]
927impl From<datafusion_sql::sqlparser::parser::ParserError> for Error {
928    #[track_caller]
929    fn from(e: datafusion_sql::sqlparser::parser::ParserError) -> Self {
930        Self::io_source(box_error(e))
931    }
932}
933
934#[cfg(feature = "datafusion")]
935impl From<datafusion_sql::sqlparser::tokenizer::TokenizerError> for Error {
936    #[track_caller]
937    fn from(e: datafusion_sql::sqlparser::tokenizer::TokenizerError) -> Self {
938        Self::io_source(box_error(e))
939    }
940}
941
942#[cfg(feature = "datafusion")]
943impl From<Error> for datafusion_common::DataFusionError {
944    #[track_caller]
945    fn from(e: Error) -> Self {
946        Self::External(Box::new(e))
947    }
948}
949
950#[cfg(feature = "datafusion")]
951impl From<datafusion_common::DataFusionError> for Error {
952    #[track_caller]
953    fn from(e: datafusion_common::DataFusionError) -> Self {
954        match e {
955            datafusion_common::DataFusionError::SQL(..)
956            | datafusion_common::DataFusionError::Plan(..)
957            | datafusion_common::DataFusionError::Configuration(..)
958            | datafusion_common::DataFusionError::SchemaError(..) => {
959                Self::invalid_input_source(box_error(e))
960            }
961            datafusion_common::DataFusionError::ArrowError(arrow_err, _) => Self::from(*arrow_err),
962            datafusion_common::DataFusionError::NotImplemented(..) => {
963                Self::not_supported_source(box_error(e))
964            }
965            datafusion_common::DataFusionError::Execution(..) => Self::execution(e.to_string()),
966            datafusion_common::DataFusionError::Shared(shared) => {
967                // DataFusion shares an error across consumers (e.g. a join's
968                // build-side error fanned out to every probe partition) behind an
969                // `Arc`. If we are the sole owner we can recurse for full fidelity;
970                // otherwise the inner error can't be moved out, so we preserve its
971                // message under the execution category (its concrete type is lost).
972                match std::sync::Arc::try_unwrap(shared) {
973                    Ok(inner) => Self::from(inner),
974                    Err(shared) => Self::execution(shared.to_string()),
975                }
976            }
977            datafusion_common::DataFusionError::External(source) => {
978                // Try to downcast to lance_core::Error first
979                match source.downcast::<Self>() {
980                    Ok(lance_err) => *lance_err,
981                    Err(source) => Self::External { source },
982                }
983            }
984            _ => Self::io_source(box_error(e)),
985        }
986    }
987}
988
989// This is a bit odd but some object_store functions only accept
990// Stream<Result<T, ObjectStoreError>> and so we need to convert
991// to ObjectStoreError to call the methods.
992impl From<Error> for object_store::Error {
993    fn from(err: Error) -> Self {
994        Self::Generic {
995            store: "N/A",
996            source: Box::new(err),
997        }
998    }
999}
1000
1001#[track_caller]
1002pub fn get_caller_location() -> &'static std::panic::Location<'static> {
1003    std::panic::Location::caller()
1004}
1005
1006/// Wrap an error in a new error type that implements Clone
1007///
1008/// This is useful when two threads/streams share a common fallible source
1009/// Definite not-found errors preserve typed source-chain detection and their
1010/// human-readable representation. Timeout and I/O errors preserve their error
1011/// categories. Other cloned results use Error::Cloned with the string
1012/// representation of the base error.
1013pub struct CloneableError(pub Error);
1014
1015struct DisplayError(Error);
1016
1017impl fmt::Debug for DisplayError {
1018    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1019        fmt::Display::fmt(self, f)
1020    }
1021}
1022
1023impl fmt::Display for DisplayError {
1024    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1025        fmt::Display::fmt(&self.0, f)
1026    }
1027}
1028
1029impl std::error::Error for DisplayError {
1030    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1031        Some(&self.0)
1032    }
1033}
1034
1035impl Clone for CloneableError {
1036    #[track_caller]
1037    fn clone(&self) -> Self {
1038        match &self.0 {
1039            Error::NotFound { uri, .. } => Self(Error::wrapped(Box::new(DisplayError(
1040                Error::not_found(uri.clone()),
1041            )))),
1042            error if error.is_not_found() => Self(Error::wrapped(Box::new(DisplayError(
1043                Error::not_found(error.to_string()),
1044            )))),
1045            Error::Timeout { message, .. } => Self(Error::timeout(message.clone())),
1046            Error::IO { source, .. } => Self(Error::io(source.to_string())),
1047            error => Self(Error::cloned(error.to_string())),
1048        }
1049    }
1050}
1051
1052#[derive(Clone)]
1053pub struct CloneableResult<T: Clone>(pub std::result::Result<T, CloneableError>);
1054
1055impl<T: Clone> From<Result<T>> for CloneableResult<T> {
1056    fn from(result: Result<T>) -> Self {
1057        Self(result.map_err(CloneableError))
1058    }
1059}
1060
1061#[cfg(test)]
1062mod test {
1063    use super::*;
1064    use std::error::Error as _;
1065    use std::fmt;
1066
1067    #[test]
1068    fn cloneable_error_preserves_not_found_contract() {
1069        let original = CloneableError(Error::not_found("metadata.lance"));
1070        let cloned = original.clone();
1071        let cloned_again = cloned.clone();
1072        assert!(matches!(original.0, Error::NotFound { .. }));
1073        assert!(cloned.0.is_not_found());
1074        assert!(cloned_again.0.is_not_found());
1075        assert!(cloned.0.to_string().to_lowercase().contains("not found"));
1076        assert!(
1077            cloned_again
1078                .0
1079                .to_string()
1080                .to_lowercase()
1081                .contains("not found")
1082        );
1083        assert!(
1084            format!("{:?}", cloned.0)
1085                .to_lowercase()
1086                .contains("not found")
1087        );
1088        assert!(cloned.0.source().is_some_and(|source| source.is::<Error>()
1089            || source.source().is_some_and(|source| source.is::<Error>())));
1090        let downstream_error = Error::wrapped(Box::new(Error::io_source(Box::new(
1091            object_store::Error::Generic {
1092                store: "N/A",
1093                source: Box::new(cloned.0),
1094            },
1095        ))));
1096        assert!(downstream_error.is_not_found());
1097        assert!(
1098            format!("{downstream_error:?}")
1099                .to_lowercase()
1100                .contains("not found")
1101        );
1102
1103        let original = CloneableError(Error::timeout("metadata read timed out"));
1104        let cloned = original.clone();
1105        assert!(matches!(original.0, Error::Timeout { .. }));
1106        assert!(matches!(cloned.0, Error::Timeout { .. }));
1107
1108        let original = CloneableError(Error::io("metadata read was denied"));
1109        let cloned = original.clone();
1110        assert!(matches!(original.0, Error::IO { .. }));
1111        assert!(matches!(cloned.0, Error::IO { .. }));
1112    }
1113
1114    #[test]
1115    fn test_caller_location_capture() {
1116        let current_fn = get_caller_location();
1117        // make sure ? captures the correct location
1118        // .into() WILL NOT capture the correct location
1119        let f: Box<dyn Fn() -> Result<()>> = Box::new(|| {
1120            Err(object_store::Error::Generic {
1121                store: "",
1122                source: "".into(),
1123            })?;
1124            Ok(())
1125        });
1126        match f().unwrap_err() {
1127            Error::IO { location, .. } => {
1128                // +4 is the beginning of object_store::Error::Generic...
1129                assert_eq!(location.line(), current_fn.line() + 4, "{}", location)
1130            }
1131            #[allow(unreachable_patterns)]
1132            _ => panic!("expected ObjectStore error"),
1133        }
1134    }
1135
1136    #[test]
1137    fn test_caller_location_capture_not_found() {
1138        let current_fn = get_caller_location();
1139        let f: Box<dyn Fn() -> Result<()>> = Box::new(|| {
1140            Err(object_store::Error::NotFound {
1141                path: "some/path".to_string(),
1142                source: "not found".into(),
1143            })?;
1144            Ok(())
1145        });
1146        match f().unwrap_err() {
1147            Error::NotFound { location, .. } => {
1148                // +2 is the beginning of object_store::Error::NotFound...
1149                assert_eq!(location.line(), current_fn.line() + 2, "{}", location)
1150            }
1151            #[allow(unreachable_patterns)]
1152            other => panic!("expected NotFound, got {:?}", other),
1153        }
1154    }
1155
1156    #[test]
1157    fn test_object_store_not_found_converts_to_not_found() {
1158        let os_err = object_store::Error::NotFound {
1159            path: "test/path".to_string(),
1160            source: "no such file".into(),
1161        };
1162        let lance_err: Error = os_err.into();
1163        match lance_err {
1164            Error::NotFound { uri, .. } => {
1165                assert_eq!(uri, "test/path");
1166            }
1167            other => panic!("Expected NotFound, got {:?}", other),
1168        }
1169    }
1170
1171    #[derive(Debug)]
1172    struct MyCustomError {
1173        code: i32,
1174        message: String,
1175    }
1176
1177    impl fmt::Display for MyCustomError {
1178        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1179            write!(f, "MyCustomError({}): {}", self.code, self.message)
1180        }
1181    }
1182
1183    impl std::error::Error for MyCustomError {}
1184
1185    #[test]
1186    fn test_io_error_recovers_wrapped_lance_error() {
1187        // A lance Error wrapped in io::Error::other should round-trip back to
1188        // the original variant rather than collapsing into Error::IO.
1189        let io_err = std::io::Error::other(Error::disk_cap_exceeded(100, 50));
1190        let recovered: Error = io_err.into();
1191        match recovered {
1192            Error::DiskCapExceeded {
1193                cap_bytes,
1194                used_bytes,
1195                ..
1196            } => {
1197                assert_eq!(cap_bytes, 100);
1198                assert_eq!(used_bytes, 50);
1199            }
1200            other => panic!("expected DiskCapExceeded, got {other:?}"),
1201        }
1202    }
1203
1204    #[test]
1205    fn test_io_error_without_lance_error_stays_io() {
1206        // A plain io::Error (no wrapped lance Error) should become Error::IO.
1207        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
1208        let converted: Error = io_err.into();
1209        assert!(matches!(converted, Error::IO { .. }));
1210    }
1211
1212    #[test]
1213    fn test_commit_status_unknown_is_structured_without_masking_as_not_found() {
1214        let error = Error::commit_status_unknown_source(
1215            42,
1216            box_error(Error::not_found("temporarily invisible manifest")),
1217        );
1218
1219        assert!(error.is_commit_status_unknown());
1220        assert!(!error.is_not_found());
1221        assert!(error.to_string().contains("version 42 is unknown"));
1222        let Error::Wrapped { error, .. } = error else {
1223            panic!("commit-status-unknown must use the semver-compatible wrapper")
1224        };
1225        let status = error
1226            .downcast_ref::<CommitStatusUnknownError>()
1227            .expect("wrapper must retain the typed commit status");
1228        assert_eq!(status.version(), 42);
1229    }
1230
1231    #[test]
1232    fn test_external_error_creation() {
1233        let custom_err = MyCustomError {
1234            code: 42,
1235            message: "test error".to_string(),
1236        };
1237        let err = Error::external(Box::new(custom_err));
1238
1239        match &err {
1240            Error::External { source } => {
1241                let recovered = source.downcast_ref::<MyCustomError>().unwrap();
1242                assert_eq!(recovered.code, 42);
1243                assert_eq!(recovered.message, "test error");
1244            }
1245            _ => panic!("Expected External variant"),
1246        }
1247    }
1248
1249    #[test]
1250    fn test_external_source_method() {
1251        let custom_err = MyCustomError {
1252            code: 123,
1253            message: "source test".to_string(),
1254        };
1255        let err = Error::external(Box::new(custom_err));
1256
1257        let source = err.external_source().expect("should have external source");
1258        let recovered = source.downcast_ref::<MyCustomError>().unwrap();
1259        assert_eq!(recovered.code, 123);
1260
1261        // Test that non-External variants return None
1262        let io_err = Error::io("test");
1263        assert!(io_err.external_source().is_none());
1264    }
1265
1266    #[test]
1267    fn test_into_external_method() {
1268        let custom_err = MyCustomError {
1269            code: 456,
1270            message: "into test".to_string(),
1271        };
1272        let err = Error::external(Box::new(custom_err));
1273
1274        match err.into_external() {
1275            Ok(source) => {
1276                let recovered = source.downcast::<MyCustomError>().unwrap();
1277                assert_eq!(recovered.code, 456);
1278            }
1279            Err(_) => panic!("Expected Ok"),
1280        }
1281
1282        // Test that non-External variants return Err(self)
1283        let io_err = Error::io("test");
1284        match io_err.into_external() {
1285            Err(Error::IO { .. }) => {}
1286            _ => panic!("Expected Err with IO variant"),
1287        }
1288    }
1289
1290    #[test]
1291    fn test_arrow_external_error_conversion() {
1292        let custom_err = MyCustomError {
1293            code: 789,
1294            message: "arrow test".to_string(),
1295        };
1296        let arrow_err = ArrowError::ExternalError(Box::new(custom_err));
1297        let lance_err: Error = arrow_err.into();
1298
1299        match lance_err {
1300            Error::External { source } => {
1301                let recovered = source.downcast_ref::<MyCustomError>().unwrap();
1302                assert_eq!(recovered.code, 789);
1303            }
1304            _ => panic!("Expected External variant, got {:?}", lance_err),
1305        }
1306    }
1307
1308    #[test]
1309    fn test_external_to_arrow_roundtrip() {
1310        let custom_err = MyCustomError {
1311            code: 999,
1312            message: "roundtrip".to_string(),
1313        };
1314        let lance_err = Error::external(Box::new(custom_err));
1315        let arrow_err: ArrowError = lance_err.into();
1316
1317        match arrow_err {
1318            ArrowError::ExternalError(source) => {
1319                let recovered = source.downcast_ref::<MyCustomError>().unwrap();
1320                assert_eq!(recovered.code, 999);
1321            }
1322            _ => panic!("Expected ExternalError variant"),
1323        }
1324    }
1325
1326    #[cfg(feature = "datafusion")]
1327    #[test]
1328    fn test_datafusion_schema_error_is_invalid_input() {
1329        // Schema errors from DataFusion (e.g., a filter referencing an unknown
1330        // column) are user-input failures, not internal lance failures. They
1331        // must surface as `Error::InvalidInput` so downstream FFI/Python
1332        // bindings can map them to the right user-facing error code.
1333        use datafusion_common::Column;
1334
1335        let schema_err = datafusion_common::SchemaError::FieldNotFound {
1336            field: Box::new(Column::from_name("missing_col")),
1337            valid_fields: vec![],
1338        };
1339        let df_err =
1340            datafusion_common::DataFusionError::SchemaError(Box::new(schema_err), Box::new(None));
1341        let lance_err: Error = df_err.into();
1342
1343        match lance_err {
1344            Error::InvalidInput { .. } => {
1345                assert!(
1346                    lance_err.to_string().contains("missing_col"),
1347                    "expected the column name to survive in the error message, got: {lance_err}"
1348                );
1349            }
1350            _ => panic!("Expected InvalidInput variant, got {:?}", lance_err),
1351        }
1352    }
1353
1354    #[cfg(feature = "datafusion")]
1355    #[test]
1356    fn test_datafusion_external_error_conversion() {
1357        let custom_err = MyCustomError {
1358            code: 111,
1359            message: "datafusion test".to_string(),
1360        };
1361        let df_err = datafusion_common::DataFusionError::External(Box::new(custom_err));
1362        let lance_err: Error = df_err.into();
1363
1364        match lance_err {
1365            Error::External { source } => {
1366                let recovered = source.downcast_ref::<MyCustomError>().unwrap();
1367                assert_eq!(recovered.code, 111);
1368            }
1369            _ => panic!("Expected External variant"),
1370        }
1371    }
1372
1373    #[cfg(feature = "datafusion")]
1374    #[test]
1375    fn test_datafusion_arrow_external_error_conversion() {
1376        // Test the nested case: ArrowError::ExternalError inside DataFusionError::ArrowError
1377        let custom_err = MyCustomError {
1378            code: 222,
1379            message: "nested test".to_string(),
1380        };
1381        let arrow_err = ArrowError::ExternalError(Box::new(custom_err));
1382        let df_err = datafusion_common::DataFusionError::ArrowError(Box::new(arrow_err), None);
1383        let lance_err: Error = df_err.into();
1384
1385        match lance_err {
1386            Error::External { source } => {
1387                let recovered = source.downcast_ref::<MyCustomError>().unwrap();
1388                assert_eq!(recovered.code, 222);
1389            }
1390            _ => panic!("Expected External variant, got {:?}", lance_err),
1391        }
1392    }
1393
1394    /// Test that lance_core::Error round-trips through ArrowError.
1395    ///
1396    /// This simulates the case where a user defines an iterator in terms of
1397    /// lance_core::Error, and the error goes through Arrow's error type
1398    /// (e.g., via RecordBatchIterator) before being converted back.
1399    #[test]
1400    fn test_lance_error_roundtrip_through_arrow() {
1401        let original = Error::invalid_input("test validation error");
1402
1403        // Simulate what happens when using ? in an Arrow context
1404        let arrow_err: ArrowError = original.into();
1405
1406        // Convert back to lance error (as happens when Lance consumes the stream)
1407        let recovered: Error = arrow_err.into();
1408
1409        // Should get back the original lance error directly (not wrapped in External)
1410        match recovered {
1411            Error::InvalidInput { .. } => {
1412                assert!(recovered.to_string().contains("test validation error"));
1413            }
1414            _ => panic!("Expected InvalidInput variant, got {:?}", recovered),
1415        }
1416    }
1417
1418    /// Test that lance_core::Error round-trips through DataFusionError.
1419    ///
1420    /// This simulates the case where a user defines a stream in terms of
1421    /// lance_core::Error, and the error goes through DataFusion's error type
1422    /// (e.g., via SendableRecordBatchStream) before being converted back.
1423    #[cfg(feature = "datafusion")]
1424    #[test]
1425    fn test_lance_error_roundtrip_through_datafusion() {
1426        let original = Error::invalid_input("test validation error");
1427
1428        // Simulate what happens when using ? in a DataFusion context
1429        let df_err: datafusion_common::DataFusionError = original.into();
1430
1431        // Convert back to lance error (as happens when Lance consumes the stream)
1432        let recovered: Error = df_err.into();
1433
1434        // Should get back the original lance error directly (not wrapped in External)
1435        match recovered {
1436            Error::InvalidInput { .. } => {
1437                assert!(recovered.to_string().contains("test validation error"));
1438            }
1439            _ => panic!("Expected InvalidInput variant, got {:?}", recovered),
1440        }
1441    }
1442
1443    #[test]
1444    fn test_backtrace_accessor() {
1445        // Verify that backtrace() returns the expected result based on feature state
1446        let err = Error::io("test backtrace");
1447        let bt = err.backtrace();
1448        #[cfg(feature = "backtrace")]
1449        {
1450            // With the backtrace feature enabled, whether a backtrace is captured
1451            // depends on the RUST_BACKTRACE env var at runtime. We just verify
1452            // the accessor doesn't panic and returns a valid Option.
1453            let _ = bt;
1454        }
1455        #[cfg(not(feature = "backtrace"))]
1456        {
1457            // Without the backtrace feature, this must always be None.
1458            assert!(bt.is_none());
1459        }
1460    }
1461
1462    #[test]
1463    fn test_backtrace_captured_when_feature_enabled() {
1464        // Test that backtrace is actually captured when the feature is on and
1465        // RUST_BACKTRACE=1 is set in the environment before the process starts.
1466        //
1467        // NOTE: std::backtrace::Backtrace caches the RUST_BACKTRACE env check,
1468        // so set_var at runtime does not reliably enable capture. This test
1469        // verifies the accessor works correctly in both cases:
1470        // - If RUST_BACKTRACE=1 was set before the test binary started, we get Some.
1471        // - If not, we get None (even with the feature on), which is expected.
1472        #[cfg(feature = "backtrace")]
1473        {
1474            let err = Error::io("backtrace capture test");
1475            if std::env::var("RUST_BACKTRACE").is_ok() {
1476                assert!(
1477                    err.backtrace().is_some(),
1478                    "Expected a backtrace when RUST_BACKTRACE=1 and backtrace feature is enabled"
1479                );
1480            }
1481            // When RUST_BACKTRACE is not set, backtrace() may return None even
1482            // with the feature enabled — this is correct runtime gating behavior.
1483        }
1484        #[cfg(not(feature = "backtrace"))]
1485        {
1486            let err = Error::io("backtrace capture test");
1487            assert!(err.backtrace().is_none());
1488        }
1489    }
1490
1491    #[test]
1492    fn test_backtrace_returns_none_for_variants_without_location() {
1493        let err = Error::InvalidTableLocation {
1494            message: "test".to_string(),
1495        };
1496        assert!(err.backtrace().is_none());
1497
1498        let err = Error::InvalidRef {
1499            message: "test".to_string(),
1500        };
1501        assert!(err.backtrace().is_none());
1502
1503        let err = Error::Stop;
1504        assert!(err.backtrace().is_none());
1505    }
1506}