Skip to main content

chtypes/
error.rs

1//! Errors, and the one sentinel that must never be confused with a rejection.
2
3use std::path::PathBuf;
4
5/// `CHS_CODE_UNSUPPORTED` from `include/chtypes.h`: "this build refuses to
6/// answer", and never a real ClickHouse error code.
7///
8/// Mapping it onto a rejection manufactures an over-reject the product never
9/// made; mapping it onto an acceptance manufactures an over-accept, which is
10/// the cardinal sin. It is exposed here so a caller can branch on it.
11pub const CODE_UNSUPPORTED: i32 = -2;
12
13/// The `chs_*` ABI revision this crate was written against — `CHS_ABI_REVISION`
14/// in `include/chtypes.h`.
15///
16/// This crate `dlopen`s artifacts rather than compiling against the header, so
17/// this is a hand-kept mirror and MUST be bumped in the same cycle the header
18/// is. [`crate::Library`] refuses to load an artifact reporting a different
19/// nonzero revision; 0 means the artifact predates the probe, which is
20/// ignorance rather than incompatibility (`spec/artifact.md` §Loading).
21///
22/// Revision 3 (2026-08-31): `chs_rows` gained `export_format` / `doc_flags` /
23/// `out_bytes`, and the `chs_filter_compile` / `chs_filter_free` /
24/// `chs_filter_rows` trio joined the surface.
25///
26/// Revision 4 (2026-08-31, the filter phase-2 cycle): `chs_filter_compile`
27/// gained `params_json` (`{name:Type}` query parameters), and the block twin
28/// joined — `chs_block_parse` / `chs_block_free` / `chs_filter_eval`. This
29/// crate therefore speaks 4 and refuses revision-3 artifacts: calling the
30/// 5-argument `chs_filter_compile` against the 4-argument revision-3 artifact
31/// is undefined behaviour, which is exactly what this gate exists to refuse.
32pub const ABI_REVISION: i32 = 4;
33
34/// `CHTYPES_ARTIFACT_MISSING` — no installed artifact answers for the line
35/// (`docs/fetch.md` §7). The code every SDK shares for [`Error::ArtifactMissing`].
36pub const CODE_ARTIFACT_MISSING: &str = "CHTYPES_ARTIFACT_MISSING";
37/// `CHTYPES_ARTIFACT_UNTRUSTED` — the release's `SHA256SUMS` is unsigned or
38/// mis-signed (§3 step 0); nothing was downloaded around it.
39pub const CODE_ARTIFACT_UNTRUSTED: &str = "CHTYPES_ARTIFACT_UNTRUSTED";
40/// `CHTYPES_ARTIFACT_CORRUPT` — any hash mismatch anywhere in the chain (§3).
41pub const CODE_ARTIFACT_CORRUPT: &str = "CHTYPES_ARTIFACT_CORRUPT";
42/// `CHTYPES_ARTIFACT_PINNED` — the release offers something other than what
43/// the lock file pins (§5).
44pub const CODE_ARTIFACT_PINNED: &str = "CHTYPES_ARTIFACT_PINNED";
45/// `CHTYPES_ARTIFACT_UNPUBLISHED` — the release publishes nothing for the
46/// requested line or exact patch on this platform (§2).
47pub const CODE_ARTIFACT_UNPUBLISHED: &str = "CHTYPES_ARTIFACT_UNPUBLISHED";
48/// `CHTYPES_SOURCE_UNREACHABLE` — the source could not be reached, or was not
49/// consulted because the fetch was offline.
50pub const CODE_SOURCE_UNREACHABLE: &str = "CHTYPES_SOURCE_UNREACHABLE";
51
52/// This SDK's fetch command, as the "Install it:" line of
53/// [`Error::ArtifactMissing`] spells it (`docs/fetch.md` §6: the crate's
54/// `[[bin]]`, reached through `cargo install chtypes`).
55pub const FETCH_COMMAND: &str = "cargo install chtypes && chtypes fetch";
56
57/// `Result` with this crate's [`Error`].
58pub type Result<T> = std::result::Result<T, Error>;
59
60/// Everything that can go wrong loading an artifact or asking it a question.
61///
62/// Three different answers travel through this one type, and a caller must
63/// keep them apart (`spec/c-abi.md` §Error model):
64///
65/// * **A rejection** — [`Error::Schema`]: ClickHouse itself refused, with its
66///   own code and message. The DDL or profile can never exist on that server
67///   and the tenant has to be told.
68/// * **A decline** — [`Error::Unsupported`] / [`Error::PredatesFeature`]:
69///   this build refuses to answer ([`CODE_UNSUPPORTED`]). A real server might
70///   well have accepted the input, so the caller must fall back to the server
71///   (validate cautiously, forward unpreviewed) rather than report a tenant
72///   error. Mapping a decline onto a rejection manufactures an over-reject;
73///   both over-accepts and over-rejects are budgeted at zero.
74/// * **Everything else** is the machinery: loading, parsing, argument
75///   marshalling. No ClickHouse verdict was reached at all
76///   ([`Error::code`] answers `None`).
77///
78/// Note what is *not* an error: a row the server would reject comes back as
79/// `Ok` with [`crate::Outcome::Rejected`] in the result — the `Result` is
80/// about whether the question could be asked, and the verdict lives in the
81/// answer.
82#[derive(Debug, thiserror::Error)]
83#[non_exhaustive]
84pub enum Error {
85    /// The registry directory could not be read.
86    #[error("chtypes: registry {dir}: {source}")]
87    Registry {
88        /// The directory that could not be read.
89        dir: PathBuf,
90        /// The underlying I/O failure.
91        #[source]
92        source: std::io::Error,
93    },
94
95    /// A directory carried a `manifest.json` and the library still would not
96    /// `dlopen`. That is broken, not absent, so it aborts the scan.
97    #[error("chtypes: dlopen {path}: {message}")]
98    Load {
99        /// The shared library that would not load.
100        path: PathBuf,
101        /// `dlerror()`'s text.
102        message: String,
103    },
104
105    /// The library loaded but does not export the four mandatory `chs_*`
106    /// symbols, so it is not a chtypes artifact.
107    #[error("chtypes: {path} does not export the chtypes C API (missing {symbol})")]
108    NotAnArtifact {
109        /// The library that loaded but is not a chtypes artifact.
110        path: PathBuf,
111        /// The first mandatory symbol found missing.
112        symbol: &'static str,
113    },
114
115    /// `manifest.library_bytes` disagrees with the file on disk. A move that
116    /// reported success and truncated a 232 MB library looks identical to one
117    /// that worked, which is exactly why this is checked.
118    #[error("chtypes: {path}: manifest says {expected} bytes, file is {actual}")]
119    CorruptArtifact {
120        /// The library whose size disagrees with its manifest.
121        path: PathBuf,
122        /// `manifest.library_bytes`.
123        expected: u64,
124        /// The size on disk.
125        actual: u64,
126    },
127
128    /// `chs_clickhouse_version()` disagrees with `manifest.clickhouse_version`:
129    /// the right bytes in the wrong directory, the one corruption a checksum
130    /// cannot catch.
131    #[error("chtypes: {path}: library reports ClickHouse {reported}, manifest says {manifest}")]
132    VersionMismatch {
133        /// The library that disagrees with its manifest.
134        path: PathBuf,
135        /// What `chs_clickhouse_version()` said — the authority.
136        reported: String,
137        /// What `manifest.clickhouse_version` claimed.
138        manifest: String,
139    },
140
141    /// `chs_init` returned nonzero. The one reachable failure is an unknown
142    /// `timezone`, and `message` is ClickHouse's own text saying so — a bare
143    /// `rc` cannot say which name was rejected.
144    #[error("chtypes: chs_init failed for {path}: rc={rc}: {message}")]
145    Init {
146        /// The library whose initialisation failed.
147        path: PathBuf,
148        /// `chs_init`'s nonzero return.
149        rc: i32,
150        /// ClickHouse's own message, from `chs_init`'s `out_err`.
151        message: String,
152    },
153
154    /// The same artifact image is already initialized with a different
155    /// configuration. `dlopen` refcounts one image per path, so `chs_init`
156    /// runs at most once per artifact — a second load asking for a different
157    /// timezone cannot be honoured and must not silently re-timezone the
158    /// first load's live libraries.
159    #[error(
160        "chtypes: {path} is already initialized with timezone {have:?}; \
161         cannot re-initialize with {want:?} (one image per path — \
162         chs_init runs at most once)"
163    )]
164    InitConflict {
165        /// The artifact whose image is already initialized.
166        path: PathBuf,
167        /// The timezone the image was initialized with.
168        have: String,
169        /// The conflicting timezone this load requested.
170        want: String,
171    },
172
173    /// The directory exists and holds no loadable artifact. An empty registry is
174    /// a configuration mistake, not an empty result.
175    #[error("chtypes: no version artifacts under {dir}")]
176    EmptyRegistry {
177        /// The directory that held no loadable artifact.
178        dir: PathBuf,
179    },
180
181    /// No artifact answers for the requested version. Naming what *is* loaded is
182    /// part of the contract: answering 26.7 semantics from a 25.8 artifact would
183    /// be a lie, so there is deliberately no nearest-match fallback.
184    #[error("chtypes: no vendored build for ClickHouse {requested} (have {loaded})")]
185    NoSuchVersion {
186        /// The version that was asked for.
187        requested: String,
188        /// The minor lines that *are* loaded.
189        loaded: String,
190    },
191
192    /// The environment variable naming a registry is unset.
193    #[error("chtypes: ${var} is not set")]
194    NoRegistryEnv {
195        /// The variable that is unset.
196        var: &'static str,
197    },
198
199    /// ClickHouse itself rejected the schema, with its own error code.
200    ///
201    /// `code` is ALWAYS a real ClickHouse error code: a decline is a
202    /// DIFFERENT variant ([`Error::Unsupported`] / [`Error::PredatesFeature`]),
203    /// never this one carrying a negative sentinel (spec/bindings.md rule 12).
204    #[error("{}", schema_display(*code, message, column.as_deref()))]
205    Schema {
206        /// ClickHouse's own error code.
207        code: i32,
208        /// ClickHouse's own message.
209        message: String,
210        /// The offending column, when the failure is attributable to one.
211        /// Never guessed: populated only when the C layer's own structured
212        /// answer names one — which no schema-path entry point does today, so
213        /// the compile/engine/TTL/validate paths always carry `None` and a
214        /// message that names a column rides through verbatim in `message`.
215        /// The field stays for callers that KNOW a column (a gateway's
216        /// EPHEMERAL decline names columns it detected itself).
217        column: Option<String>,
218    },
219
220    /// This build refuses to answer: [`CODE_UNSUPPORTED`]. A real server might
221    /// well have accepted the input — this is not a rejection and MUST NOT be
222    /// reported as one.
223    ///
224    /// The rendered message keeps the frozen `[-2]` shape [`Error::Schema`]
225    /// renders its code with (spec/bindings.md rule 12): the conformance
226    /// drivers put this exact string on the protocol wire as an `unsupported`
227    /// scope, so the rendering is part of the contract even though the
228    /// sentinel is not a field. Whatever negative integer the binding saw
229    /// internally (`-1` a guarded exception, `-2`), the rendered code is
230    /// always the header's `CHS_CODE_UNSUPPORTED`.
231    #[error("chtypes: [-2] {message}")]
232    Unsupported {
233        /// Why this build declines, in its own words.
234        message: String,
235    },
236
237    /// The artifact does not export a symbol this call needs, i.e. it predates
238    /// the feature. Reported as unsupported at call time, never as a load
239    /// failure. Renders with the same frozen `[-2]` shape as
240    /// [`Error::Unsupported`] — a binding-internal missing-symbol sentinel
241    /// must never leak into the rendering (spec/bindings.md rule 12).
242    #[error("chtypes: [-2] this artifact predates {feature} (rebuild it)")]
243    PredatesFeature {
244        /// The symbol or capability the artifact does not export.
245        feature: &'static str,
246    },
247
248    /// The result document could not be parsed even after the bare-denormal
249    /// repair.
250    ///
251    /// This is a hard error on purpose: the previous behaviour — retrying the
252    /// parse through `String::from_utf8_lossy` — silently replaced a `String`
253    /// column's bytes with U+FFFD, which then read downstream as a coercion that
254    /// never happened. A document this crate cannot read exactly is reported,
255    /// never approximated.
256    #[error("chtypes: bad result document at byte {offset}: {message}")]
257    BadDocument {
258        /// What the reader expected, in its own words.
259        message: String,
260        /// The byte offset in the (denormal-repaired) document.
261        offset: usize,
262    },
263
264    /// A string argument contained an interior NUL, so it cannot cross the C
265    /// boundary.
266    #[error("chtypes: interior NUL byte in {what}")]
267    Nul {
268        /// Which argument carried the NUL.
269        what: &'static str,
270    },
271
272    /// A discovery-query result could not be parsed, or a discovered table
273    /// description could not be reconstructed into DDL (`crate::discover`).
274    /// Client-side and carries no ClickHouse code: the server never saw a
275    /// question it could reject.
276    #[error("chtypes: {message}")]
277    Discovery {
278        /// What went wrong, in the parser's own words.
279        message: String,
280    },
281
282    /// A [`crate::Filter`] and a [`crate::Block`] from two DIFFERENT loaded
283    /// libraries were paired in an eval — refused here, because no handle
284    /// ever crosses a `dlopen`'d image boundary. A pair from two schemas of
285    /// the SAME library is NOT this error: the C layer itself answers that
286    /// with a rejected result document, code 1002 (`spec/c-abi.md` §Blocks).
287    #[error(
288        "chtypes: filter (ClickHouse {filter_version}) and block (ClickHouse {block_version}) \
289         come from different libraries"
290    )]
291    CrossLibrary {
292        /// The filter's library, by its own reported version.
293        filter_version: String,
294        /// The block's library, by its own reported version.
295        block_version: String,
296    },
297
298    /// No installed artifact answers for the requested ClickHouse line on this
299    /// platform: the §1 search path was walked and none of its directories
300    /// holds `<line>/manifest.json` (`docs/fetch.md` §7). The message is the
301    /// one every SDK renders, verbatim apart from the bracketed parts, and
302    /// [`Error::artifact_code`] answers [`CODE_ARTIFACT_MISSING`].
303    ///
304    /// Raised by the search-path registry ([`crate::Registry::from_search_path`])
305    /// with autofetch off; a registry over one explicit directory keeps
306    /// answering [`Error::NoSuchVersion`], which names what IS loaded.
307    #[error("{}", artifact_missing_display(line, platform, looked_in))]
308    ArtifactMissing {
309        /// The minor line that was asked for (`25.8`).
310        line: String,
311        /// `<os>-<arch>`, the artifact spelling (`linux-arm64`).
312        platform: String,
313        /// Every directory that was tried, in search order.
314        looked_in: Vec<PathBuf>,
315    },
316
317    /// The release's `SHA256SUMS` did not verify (`docs/fetch.md` §3 step 0):
318    /// no `SHA256SUMS.sig`, a malformed one, or a signature under no trusted
319    /// key. Nothing was downloaded around it. Code [`CODE_ARTIFACT_UNTRUSTED`].
320    #[error("chtypes: {origin}: SHA256SUMS is not trusted: {reason}")]
321    ArtifactUntrusted {
322        /// The source the release was read from.
323        origin: String,
324        /// Why, in the verifier's own words.
325        reason: String,
326    },
327
328    /// A hash disagreed somewhere in the chain (`docs/fetch.md` §3): the index
329    /// and `SHA256SUMS`, the downloaded tarball, the library inside it, or the
330    /// installed library re-hashed in place. Reported, never repaired. Code
331    /// [`CODE_ARTIFACT_CORRUPT`].
332    #[error("chtypes: {subject}: sha256 is {actual}, expected {expected}")]
333    ArtifactCorrupt {
334        /// What was hashed, or which two records disagree.
335        subject: String,
336        /// The sha256 the chain said it should be.
337        expected: String,
338        /// The sha256 that was found.
339        actual: String,
340    },
341
342    /// The release offers something other than what the lock file pins for
343    /// this `<os>-<arch>/<minor>` (`docs/fetch.md` §5, `--frozen`). Code
344    /// [`CODE_ARTIFACT_PINNED`].
345    #[error("chtypes: {key}: {message}")]
346    ArtifactPinned {
347        /// The lock key, `<os>-<arch>/<minor>`.
348        key: String,
349        /// What was pinned and what was offered.
350        message: String,
351    },
352
353    /// The release publishes nothing for the requested line (or exact patch —
354    /// a hard requirement) on this platform (`docs/fetch.md` §2). Code
355    /// [`CODE_ARTIFACT_UNPUBLISHED`].
356    #[error(
357        "chtypes: {origin} publishes no artifact for ClickHouse {requested} on {platform} (it has: {offered})"
358    )]
359    ArtifactUnpublished {
360        /// The line or exact patch that was asked for.
361        requested: String,
362        /// `<os>-<arch>`.
363        platform: String,
364        /// The source that was consulted.
365        origin: String,
366        /// What the release does publish, for the message.
367        offered: String,
368    },
369
370    /// The source could not be reached — or was not consulted at all because
371    /// the fetch was offline. Code [`CODE_SOURCE_UNREACHABLE`].
372    #[error("chtypes: {origin}: {message}")]
373    SourceUnreachable {
374        /// The source that was (or would have been) contacted.
375        origin: String,
376        /// The transport's own words, or `offline`.
377        message: String,
378    },
379
380    /// Fetch machinery that reached no verdict: an unreadable release listing,
381    /// an unwritable install directory, an unusable option. No artifact code.
382    #[error("chtypes: fetch: {message}")]
383    Fetch {
384        /// What went wrong.
385        message: String,
386    },
387}
388
389impl Error {
390    /// The ClickHouse error code, or [`CODE_UNSUPPORTED`] for the two
391    /// unsupported shapes. `None` for loader-level failures, which have no code.
392    pub fn code(&self) -> Option<i32> {
393        match self {
394            Error::Schema { code, .. } => Some(*code),
395            Error::Unsupported { .. } | Error::PredatesFeature { .. } => Some(CODE_UNSUPPORTED),
396            _ => None,
397        }
398    }
399
400    /// Whether this is the "I decline to guess" sentinel rather than a
401    /// ClickHouse rejection.
402    pub fn is_unsupported(&self) -> bool {
403        self.code() == Some(CODE_UNSUPPORTED)
404    }
405
406    /// The shared artifact code (`docs/fetch.md` §7) — `CHTYPES_ARTIFACT_MISSING`,
407    /// `…_UNTRUSTED`, `…_CORRUPT`, `…_PINNED`, `…_UNPUBLISHED` or
408    /// `CHTYPES_SOURCE_UNREACHABLE` — for the fetch and lookup failures, `None`
409    /// for everything else. Distinct from [`Error::code`], which is the
410    /// ClickHouse error code of a rejection.
411    pub fn artifact_code(&self) -> Option<&'static str> {
412        match self {
413            Error::ArtifactMissing { .. } => Some(CODE_ARTIFACT_MISSING),
414            Error::ArtifactUntrusted { .. } => Some(CODE_ARTIFACT_UNTRUSTED),
415            Error::ArtifactCorrupt { .. } => Some(CODE_ARTIFACT_CORRUPT),
416            Error::ArtifactPinned { .. } => Some(CODE_ARTIFACT_PINNED),
417            Error::ArtifactUnpublished { .. } => Some(CODE_ARTIFACT_UNPUBLISHED),
418            Error::SourceUnreachable { .. } => Some(CODE_SOURCE_UNREACHABLE),
419            _ => None,
420        }
421    }
422
423    /// Build the right variant from a C code. The SIGN decides
424    /// (spec/bindings.md rule 12, spec/c-abi.md §Error model): a positive
425    /// code is the server's own refusal and rides through verbatim; ANY
426    /// negative code is this library declining — `-2` "I will not guess",
427    /// `-1` a guarded exception, and any sentinel a later era adds — and
428    /// becomes [`Error::Unsupported`]. Keying on the sign rather than on
429    /// `== CODE_UNSUPPORTED` means a negative sentinel can never become
430    /// "an `Error::Schema` with a negative code", which that variant's own
431    /// contract forbids.
432    pub(crate) fn from_code(code: i32, message: String) -> Error {
433        if code < 0 {
434            Error::Unsupported { message }
435        } else {
436            Error::Schema {
437                code,
438                message,
439                column: None,
440            }
441        }
442    }
443}
444
445/// The frozen rendering the refusal variant shares with its peers in every
446/// SDK: `chtypes: [<code>] <msg>`, with `chtypes: column "<c>": …` when a
447/// column is attributed (spec/bindings.md rule 12 — the shape the conformance
448/// drivers put on the wire).
449fn schema_display(code: i32, message: &str, column: Option<&str>) -> String {
450    match column {
451        Some(c) => format!("chtypes: column {c:?}: [{code}] {message}"),
452        None => format!("chtypes: [{code}] {message}"),
453    }
454}
455
456/// The §7 message, verbatim apart from the bracketed parts: the line, the
457/// platform, the directories that were looked in, and this SDK's own fetch
458/// command ([`FETCH_COMMAND`]).
459fn artifact_missing_display(line: &str, platform: &str, looked_in: &[PathBuf]) -> String {
460    let dirs: Vec<String> = looked_in.iter().map(|d| d.display().to_string()).collect();
461    format!(
462        "chtypes: no artifact for ClickHouse {line} ({platform}). Looked in: {}.\n\
463         Install it:  {FETCH_COMMAND} {line}\n\
464         or set CHTYPES_AUTOFETCH=1 to fetch on first use.",
465        dirs.join(", ")
466    )
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn from_code_keys_on_the_sign() {
475        // A positive code is the server's own refusal, verbatim; ANY negative
476        // code is a decline — -2 "I will not guess", -1 a guarded exception,
477        // and any sentinel a later era adds. A negative Error::Schema must be
478        // unmakeable through the funnel (spec/bindings.md rule 12).
479        assert!(matches!(
480            Error::from_code(115, "bad name".into()),
481            Error::Schema { code: 115, .. }
482        ));
483        assert!(matches!(
484            Error::from_code(50, "unknown family".into()),
485            Error::Schema { code: 50, .. }
486        ));
487        assert!(matches!(
488            Error::from_code(-2, "declined".into()),
489            Error::Unsupported { .. }
490        ));
491        assert!(matches!(
492            Error::from_code(-1, "guarded exception".into()),
493            Error::Unsupported { .. }
494        ));
495        assert!(matches!(
496            Error::from_code(-3, "future sentinel".into()),
497            Error::Unsupported { .. }
498        ));
499    }
500
501    #[test]
502    fn the_rendered_sentinel_shape_is_frozen() {
503        // The decline renders the header's -2 in the same shape the refusal
504        // renders its code — the conformance drivers put this exact string on
505        // the protocol wire as an `unsupported` scope (spec/bindings.md rule
506        // 12). Internal sentinels never leak into the rendering.
507        let decline = Error::Unsupported {
508            message: "engine not modelled".into(),
509        };
510        assert_eq!(decline.to_string(), "chtypes: [-2] engine not modelled");
511        let predates = Error::PredatesFeature {
512            feature: "chs_schema_engine",
513        };
514        assert_eq!(
515            predates.to_string(),
516            "chtypes: [-2] this artifact predates chs_schema_engine (rebuild it)"
517        );
518        let refusal = Error::Schema {
519            code: 115,
520            message: "bad name".into(),
521            column: None,
522        };
523        assert_eq!(refusal.to_string(), "chtypes: [115] bad name");
524        // Column-attributed shape — for callers that KNOW one, never guessed.
525        let attributed = Error::Schema {
526            code: 469,
527            message: "constraint".into(),
528            column: Some("e".into()),
529        };
530        assert_eq!(
531            attributed.to_string(),
532            "chtypes: column \"e\": [469] constraint"
533        );
534    }
535
536    #[test]
537    fn the_artifact_missing_message_is_the_spec_s_verbatim() {
538        // docs/fetch.md §7: one message in every SDK, verbatim apart from the
539        // bracketed parts; the "Install it:" line names THIS SDK's command.
540        let err = Error::ArtifactMissing {
541            line: "25.8".into(),
542            platform: "linux-arm64".into(),
543            looked_in: vec![
544                PathBuf::from("/home/u/.cache/chtypes/artifacts/linux-arm64"),
545                PathBuf::from("/usr/local/share/chtypes/artifacts/linux-arm64"),
546                PathBuf::from("/opt/chtypes/artifacts/linux-arm64"),
547            ],
548        };
549        assert_eq!(
550            err.to_string(),
551            "chtypes: no artifact for ClickHouse 25.8 (linux-arm64). Looked in: \
552             /home/u/.cache/chtypes/artifacts/linux-arm64, \
553             /usr/local/share/chtypes/artifacts/linux-arm64, \
554             /opt/chtypes/artifacts/linux-arm64.\n\
555             Install it:  cargo install chtypes && chtypes fetch 25.8\n\
556             or set CHTYPES_AUTOFETCH=1 to fetch on first use."
557        );
558        assert_eq!(err.artifact_code(), Some("CHTYPES_ARTIFACT_MISSING"));
559        // The ClickHouse-code accessor stays what it was: no verdict here.
560        assert_eq!(err.code(), None);
561    }
562
563    #[test]
564    fn every_artifact_code_is_the_shared_spelling() {
565        let cases: Vec<(Error, &str)> = vec![
566            (
567                Error::ArtifactUntrusted {
568                    origin: "s".into(),
569                    reason: "r".into(),
570                },
571                "CHTYPES_ARTIFACT_UNTRUSTED",
572            ),
573            (
574                Error::ArtifactCorrupt {
575                    subject: "x".into(),
576                    expected: "aa".into(),
577                    actual: "bb".into(),
578                },
579                "CHTYPES_ARTIFACT_CORRUPT",
580            ),
581            (
582                Error::ArtifactPinned {
583                    key: "linux-arm64/25.8".into(),
584                    message: "m".into(),
585                },
586                "CHTYPES_ARTIFACT_PINNED",
587            ),
588            (
589                Error::ArtifactUnpublished {
590                    requested: "25.8".into(),
591                    platform: "linux-arm64".into(),
592                    origin: "s".into(),
593                    offered: "nothing".into(),
594                },
595                "CHTYPES_ARTIFACT_UNPUBLISHED",
596            ),
597            (
598                Error::SourceUnreachable {
599                    origin: "s".into(),
600                    message: "offline".into(),
601                },
602                "CHTYPES_SOURCE_UNREACHABLE",
603            ),
604        ];
605        for (err, code) in cases {
606            assert_eq!(err.artifact_code(), Some(code), "{err}");
607            assert_eq!(err.code(), None, "{err}");
608        }
609        let plain = Error::Fetch {
610            message: "m".into(),
611        };
612        assert_eq!(plain.artifact_code(), None);
613        assert_eq!(
614            Error::Unsupported {
615                message: "m".into()
616            }
617            .artifact_code(),
618            None
619        );
620    }
621}