Skip to main content

cactus_rs/
error.rs

1//! Error type for every fallible operation in this crate.
2//!
3//! ## Overview
4//!
5//! One [`Error`] enum covers the whole crate, and [`Result<T>`] is the alias every fallible
6//! function returns. The variants split into five groups:
7//!
8//! - **Engine ownership**: [`Error::EngineBusy`], [`Error::WeightsAlreadyLoaded`],
9//!   [`Error::WrongModel`]: the engine is one process-global runtime holding one model per kind,
10//!   so these report a rule of the C API rather than a failure
11//! - **Engine calls**: [`Error::LoadFailed`], [`Error::InitFailed`], [`Error::Complete`],
12//!   [`Error::Truncated`], [`Error::EmbedFailed`], [`Error::InteriorNul`]
13//! - **Audio**: [`Error::SpeechModelNotLoaded`], [`Error::AudioTooLong`],
14//!   [`Error::NonFiniteSample`], [`Error::Transcribe`], [`Error::EmbedAudio`],
15//!   [`Error::UnsupportedLanguage`], [`Error::InvalidKeyword`]
16//! - **Encoding and decoding**: [`Error::Tools`], [`Error::Envelope`], [`Error::Arguments`]
17//! - **Weights acquisition**: [`Error::UnsupportedWeights`], [`Error::Io`], [`Error::Download`],
18//!   [`Error::ChecksumMismatch`]
19//!
20//! [`Error::Load`], [`Error::Init`] and [`Error::Embed`] are kept so that code matching on them
21//! still compiles, but nothing returns them since 0.2.1: the `*Failed` variants replaced them and
22//! carry the engine's own reason, which [`Error::detail`] reads from any variant that has one.
23//!
24//! Every message is written for the person who will read it in a terminal: what happened, then a
25//! final `Help:` line saying what to do about it.
26//!
27//! ## Usage
28//!
29//! ```rust
30//! use cactus_rs::Error;
31//!
32//! let err = Error::UnsupportedWeights { tag: 0x05E1_2A83 };
33//! assert!(err.to_string().contains("Help:"));
34//! ```
35
36use thiserror::Error as ThisError;
37
38use crate::archive::MAGIC;
39use crate::model::Model;
40
41/// The number of bytes of a bad engine envelope kept in [`Error::Envelope`].
42///
43/// Envelopes run to a few hundred bytes of JSON and a truncated head is enough to recognise
44/// what came back; keeping all of it would put model output into log lines.
45const ENVELOPE_EXCERPT: usize = 200;
46
47/// What [`Error::UnsupportedWeights`] says about its tag.
48fn unsupported(tag: u32) -> String {
49    if tag == MAGIC {
50        format!(
51            "magic tag {tag:#010X} is right, but the tensor directory after it is truncated or \
52             malformed"
53        )
54    } else {
55        format!("magic tag {tag:#010X} is not a Needle 3 or Whistle archive")
56    }
57}
58
59/// The weights type each model is built from, as [`Error::WrongModel`] names it.
60const fn weights_type(model: Model) -> &'static str {
61    match model {
62        Model::Needle => "needle::Weights",
63        Model::Whistle => "whistle::Weights",
64    }
65}
66
67/// Everything that can go wrong while loading weights or driving the engine.
68///
69/// The enum is `#[non_exhaustive]`: matching it needs a `_` arm, and new variants are not a
70/// breaking change.
71///
72/// # Examples
73///
74/// ```rust
75/// use cactus_rs::Error;
76///
77/// let err = Error::Truncated { capacity: 65_536 };
78/// assert!(err.to_string().contains("65536"));
79/// assert!(err.to_string().contains("Help:"));
80/// ```
81#[derive(Debug, ThisError)]
82#[non_exhaustive]
83pub enum Error {
84    /// Another instance of the same model is alive in this process: a second
85    /// [`Needle`](crate::needle::Needle) while one exists, or a second Whistle while one exists.
86    #[error(
87        "the engine slot for this model is already in use by another instance in this process\n\
88         The engine is one process-global runtime with no handles that holds one model per \
89         kind, so one Needle and one Whistle can exist at a time, not two of either.\n\
90         Help: drop the existing instance before building another of the same model, or share \
91         it behind a mutex."
92    )]
93    EngineBusy,
94
95    /// Weights of the same kind were already loaded, and the new archive is a different one.
96    #[error(
97        "different weights of the same kind are already loaded in this process\n\
98         The engine copies the archive on the first load of each kind and offers no way to \
99         unload it.\n\
100         Help: build every instance of a model from the same weights, or run the second archive \
101         in its own process."
102    )]
103    WeightsAlreadyLoaded,
104
105    /// The archive holds the other kind of model than the one being built.
106    ///
107    /// Needle 3 and Whistle archives carry the same magic tag, so the kind is read from the
108    /// archive's tensor directory before the engine sees it. A wrong archive is never loaded:
109    /// the engine would have swapped it in over the model of that kind.
110    #[error(
111        "the weights are not a {expected} archive\n\
112         Needle 3 and Whistle archives share one magic tag, and this one holds the other model; \
113         it was not loaded.\n\
114         Help: build a {expected} from {} through {}, and hand this archive to the other \
115         model's builder.",
116        .expected.archive(),
117        weights_type(*.expected)
118    )]
119    WrongModel {
120        /// The model that was being built.
121        expected: Model,
122    },
123
124    /// The archive is not a Needle 3 or Whistle `.cact` file: a wrong magic tag, or the right
125    /// one over a tensor directory that cannot be read.
126    #[error(
127        "unsupported weights: {}\n\
128         Needle 3 and Whistle archives start with the little-endian tag 0x05E12A84; 0x05E12A83 \
129         is a Needle 2 archive, which the linked engine cannot read.\n\
130         Help: download needle3.cact from Cactus-Compute/needle3 or whistle.cact from \
131         Cactus-Compute/whistle, or call Weights::fetch().",
132        unsupported(*.tag)
133    )]
134    UnsupportedWeights {
135        /// The little-endian `u32` read from the first four bytes of the archive. It is the
136        /// right tag, 0x05E12A84, when the tensor directory after it is truncated or malformed.
137        tag: u32,
138    },
139
140    /// Not returned since 0.2.1; see [`Error::LoadFailed`], which carries the engine's reason.
141    #[error(
142        "the engine rejected the weight archive\n\
143         The magic tag was right but the engine could not load the bytes as a Needle 3 or \
144         Whistle model, which usually means a truncated or corrupted download.\n\
145         Help: delete the cached archive and fetch it again, or verify its SHA-256."
146    )]
147    Load,
148
149    /// `needle_load` rejected the archive; `detail` is the engine's own reason.
150    #[error(
151        "the engine rejected the {model} archive: {detail}\n\
152         The magic tag was right but the engine could not load the bytes, which usually means a \
153         truncated or corrupted download.\n\
154         Help: delete the cached archive and fetch it again, or verify its SHA-256."
155    )]
156    LoadFailed {
157        /// The model that was being built.
158        model: Model,
159        /// The engine's own reason, from `needle_last_error`.
160        detail: String,
161    },
162
163    /// Not returned since 0.2.1; see [`Error::InitFailed`], which carries the engine's reason.
164    #[error(
165        "the engine could not initialise the conversation prefix\n\
166         needle_init returned a non-positive token count, which happens when the weights are not \
167         loaded or the tool index path does not exist.\n\
168         Help: check that the tool index path exists and that the weights loaded successfully."
169    )]
170    Init,
171
172    /// `needle_init` failed; `detail` includes the engine's reason (e.g. the measured token count
173    /// when the prefix overflows the context).
174    #[error(
175        "the engine could not initialise the conversation prefix: {detail}\n\
176         The system prompt and the tool declarations must fit the context window together, and a \
177         tool index path must exist.\n\
178         Help: shorten the system prompt or declare fewer tools, and check that the tool index \
179         path exists."
180    )]
181    InitFailed {
182        /// The engine's own reason, from `needle_last_error`, with the count `needle_init`
183        /// returned.
184        detail: String,
185    },
186
187    /// `needle_complete` failed, with whatever detail the engine wrote into the buffer.
188    #[error(
189        "the engine failed to complete the request: {detail}\n\
190         Help: reset the conversation, shorten the input, or raise max_new_tokens."
191    )]
192    Complete {
193        /// The `error` field of the engine's error envelope, or the raw text when it had none.
194        detail: String,
195    },
196
197    /// The engine filled the output buffer and silently cut the envelope short.
198    #[error(
199        "the engine truncated its response to fill the {capacity} byte output buffer\n\
200         The engine truncates without reporting it, so the JSON envelope is incomplete.\n\
201         Help: lower max_new_tokens, ask for fewer tool calls in one turn, or turn word \
202         timestamps off."
203    )]
204    Truncated {
205        /// The capacity of the output buffer, in bytes, that the response filled.
206        capacity: usize,
207    },
208
209    /// Not returned since 0.2.1; see [`Error::EmbedFailed`], which carries the engine's reason.
210    #[error(
211        "the engine failed to embed the input\n\
212         needle_embed returned a negative value, which happens when no weights are loaded.\n\
213         Help: build the Needle from valid weights before embedding."
214    )]
215    Embed,
216
217    /// `needle_embed` failed on text: the engine's reason, or a float-count mismatch ("engine
218    /// wrote N floats, expected D").
219    #[error(
220        "the engine failed to embed the input: {detail}\n\
221         Help: build the Needle from valid weights before embedding, and report the reason above \
222         if they are."
223    )]
224    EmbedFailed {
225        /// The engine's own reason, from `needle_last_error`, or the float-count mismatch.
226        detail: String,
227    },
228
229    /// A string bound for the C API contains an interior NUL byte.
230    #[error(
231        "the {field} contains an interior NUL byte\n\
232         The engine takes NUL-terminated C strings, so a NUL inside the text would silently cut \
233         it short.\n\
234         Help: strip '\\0' from the {field} before passing it in."
235    )]
236    InteriorNul {
237        /// Which input held the NUL: `"system prompt"`, `"tools"`, `"input"`, `"tool index"` or
238        /// `"keywords"`.
239        field: &'static str,
240    },
241
242    /// An audio completion was asked for, and no speech model is loaded.
243    #[error(
244        "no speech model is loaded in this process\n\
245         An audio completion transcribes the clip with Whistle before Needle answers it, and \
246         the engine holds no Whistle weights yet.\n\
247         Help: build a Whistle once in this process before completing audio; its weights stay \
248         loaded after it is dropped."
249    )]
250    SpeechModelNotLoaded,
251
252    /// The clip is longer than the engine accepts.
253    #[error(
254        "the clip is {samples} samples long, more than the 480000 the engine accepts\n\
255         The engine takes at most 30 seconds of 16 kHz mono audio in one call.\n\
256         Help: split the clip into chunks of at most 30 seconds and send them one at a time."
257    )]
258    AudioTooLong {
259        /// The number of samples in the clip.
260        samples: usize,
261    },
262
263    /// A sample of the clip is NaN or infinite.
264    #[error(
265        "sample {index} of the clip is not a finite number\n\
266         The engine expects 16 kHz mono PCM in [-1, 1], and a NaN or an infinity would poison \
267         every frame it touches.\n\
268         Help: check the decoder or resampler that produced the clip, or replace non-finite \
269         samples with 0.0."
270    )]
271    NonFiniteSample {
272        /// The position of the first non-finite sample.
273        index: usize,
274    },
275
276    /// `needle_transcribe` failed, with the engine's reason.
277    #[error(
278        "the engine failed to transcribe the clip: {detail}\n\
279         Help: check that the clip is 16 kHz mono PCM in [-1, 1], and that the Whistle weights \
280         loaded successfully."
281    )]
282    Transcribe {
283        /// The engine's own reason, from `needle_last_error`.
284        detail: String,
285    },
286
287    /// `needle_embed` failed on a clip, with the engine's reason.
288    #[error(
289        "the engine failed to embed the clip: {detail}\n\
290         Help: check that the clip is 16 kHz mono PCM in [-1, 1], and that the Whistle weights \
291         loaded successfully."
292    )]
293    EmbedAudio {
294        /// The engine's own reason, from `needle_last_error`.
295        detail: String,
296    },
297
298    /// A language code is not one Whistle transcribes.
299    #[error(
300        "unsupported language {code:?}\n\
301         Whistle transcribes English, German, French, Spanish, Italian, Dutch and Polish.\n\
302         Help: use one of en, de, fr, es, it, nl or pl, or leave the language unset to detect it."
303    )]
304    UnsupportedLanguage {
305        /// The code that was given.
306        code: String,
307    },
308
309    /// A keyword holds a line break.
310    #[error(
311        "the keyword {keyword:?} contains a line break\n\
312         Keywords reach the engine as one newline-separated list, so a line break inside one \
313         would silently split it in two.\n\
314         Help: pass each word or phrase as its own keyword."
315    )]
316    InvalidKeyword {
317        /// The keyword as it was given.
318        keyword: String,
319    },
320
321    /// The engine's response was not the JSON envelope this crate expects.
322    #[error(
323        "the engine returned an envelope this crate could not parse: {source}\n\
324         Response: {text}\n\
325         Help: this is a bug or an engine newer than the pinned one; please report the response \
326         above."
327    )]
328    Envelope {
329        /// The first few hundred bytes of the response, for the report.
330        text: String,
331        /// The `serde_json` error that rejected it.
332        #[source]
333        source: serde_json::Error,
334    },
335
336    /// A tool call's arguments did not fit the requested Rust type.
337    #[error(
338        "the tool call arguments do not fit the requested type: {source}\n\
339         Help: the model fills arguments from the JSON schema you declared; relax the type, or \
340         tighten the schema so the engine cannot produce this shape."
341    )]
342    Arguments {
343        /// The `serde_json` error that rejected the arguments.
344        #[source]
345        source: serde_json::Error,
346    },
347
348    /// The tool declarations could not be written as JSON for the engine.
349    #[error(
350        "the tool declarations could not be serialised: {source}\n\
351         Help: check the `parameters` value of each Tool; it must be plain JSON."
352    )]
353    Tools {
354        /// The `serde_json` error that rejected the declarations.
355        #[source]
356        source: serde_json::Error,
357    },
358
359    /// Reading or writing a file failed.
360    #[error(
361        "filesystem error: {0}\n\
362         Help: check that the path exists and that this process may read and write it."
363    )]
364    Io(#[from] std::io::Error),
365
366    /// Downloading the weight archive failed.
367    #[error(
368        "could not download weights from {url}: {detail}\n\
369         Help: retry, or download the archive by hand and point CACTUS_NEEDLE_WEIGHTS (Needle) \
370         or CACTUS_WHISTLE_WEIGHTS (Whistle) at it."
371    )]
372    Download {
373        /// The URL that was being fetched.
374        url: String,
375        /// What went wrong, as reported by the HTTP client.
376        detail: String,
377    },
378
379    /// A downloaded archive did not match its pinned SHA-256.
380    #[error(
381        "the downloaded weights do not match their pinned checksum\n\
382         Expected sha256 {expected}, got {actual}.\n\
383         Help: the download was corrupted or the remote file changed; retry, and report the \
384         mismatch if it persists."
385    )]
386    ChecksumMismatch {
387        /// The SHA-256 this crate pins, lowercase hex.
388        expected: String,
389        /// The SHA-256 of the bytes that arrived, lowercase hex.
390        actual: String,
391    },
392}
393
394impl Error {
395    /// The underlying reason in the error's own words, for the variants that carry one.
396    ///
397    /// That is the engine's text for [`Error::Complete`], [`Error::Transcribe`],
398    /// [`Error::EmbedAudio`], [`Error::LoadFailed`], [`Error::InitFailed`] and
399    /// [`Error::EmbedFailed`], and the HTTP client's for [`Error::Download`]. Every other
400    /// variant returns `None`.
401    ///
402    /// # Examples
403    ///
404    /// ```rust
405    /// use cactus_rs::Error;
406    ///
407    /// let err = Error::InitFailed {
408    ///     detail: "prefix of 40000 tokens exceeds the context".to_owned(),
409    /// };
410    /// assert_eq!(err.detail(), Some("prefix of 40000 tokens exceeds the context"));
411    /// assert_eq!(Error::EngineBusy.detail(), None);
412    /// ```
413    #[must_use]
414    pub fn detail(&self) -> Option<&str> {
415        match self {
416            Error::Complete { detail }
417            | Error::Transcribe { detail }
418            | Error::EmbedAudio { detail }
419            | Error::LoadFailed { detail, .. }
420            | Error::InitFailed { detail }
421            | Error::EmbedFailed { detail }
422            | Error::Download { detail, .. } => Some(detail),
423            _ => None,
424        }
425    }
426
427    /// Builds an [`Error::Envelope`] with the response text truncated to a loggable excerpt.
428    ///
429    /// # Examples
430    ///
431    /// ```rust
432    /// use cactus_rs::Error;
433    ///
434    /// let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
435    /// let err = Error::envelope(&"x".repeat(1000), source);
436    /// assert!(err.to_string().contains("(truncated)"));
437    /// ```
438    #[must_use]
439    pub fn envelope(text: &str, source: serde_json::Error) -> Self {
440        let excerpt = match text.char_indices().nth(ENVELOPE_EXCERPT) {
441            Some((cut, _)) => format!("{}... (truncated)", &text[..cut]),
442            None => text.to_owned(),
443        };
444        Error::Envelope {
445            text: excerpt,
446            source,
447        }
448    }
449}
450
451/// The result type every fallible function in this crate returns.
452///
453/// # Examples
454///
455/// ```rust
456/// use cactus_rs::{Error, Result};
457///
458/// fn refuse() -> Result<()> {
459///     Err(Error::EngineBusy)
460/// }
461///
462/// assert!(refuse().is_err());
463/// ```
464pub type Result<T> = std::result::Result<T, Error>;
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    // Errors cross threads with the `Needle` that produced them, and end up in `Box<dyn Error>`.
471    #[test]
472    fn test_error_is_send_sync_static() {
473        fn assert_bounds<T: Send + Sync + 'static>() {}
474        assert_bounds::<Error>();
475    }
476
477    /// How many variants [`Error`] has. Bump it with a new arm in [`variant_index`].
478    const VARIANTS: usize = 26;
479
480    /// A distinct number per variant.
481    ///
482    /// The `match` has no `_` arm, so a new variant does not compile until it is listed here,
483    /// and [`every_message_ends_with_a_help_line`] fails until it has a sample.
484    fn variant_index(error: &Error) -> usize {
485        match error {
486            Error::EngineBusy => 0,
487            Error::WeightsAlreadyLoaded => 1,
488            Error::WrongModel { .. } => 2,
489            Error::UnsupportedWeights { .. } => 3,
490            Error::Load => 4,
491            Error::Init => 5,
492            Error::Complete { .. } => 6,
493            Error::Truncated { .. } => 7,
494            Error::Embed => 8,
495            Error::InteriorNul { .. } => 9,
496            Error::SpeechModelNotLoaded => 10,
497            Error::AudioTooLong { .. } => 11,
498            Error::NonFiniteSample { .. } => 12,
499            Error::Transcribe { .. } => 13,
500            Error::EmbedAudio { .. } => 14,
501            Error::UnsupportedLanguage { .. } => 15,
502            Error::InvalidKeyword { .. } => 16,
503            Error::Envelope { .. } => 17,
504            Error::Arguments { .. } => 18,
505            Error::Tools { .. } => 19,
506            Error::Io(_) => 20,
507            Error::Download { .. } => 21,
508            Error::ChecksumMismatch { .. } => 22,
509            Error::LoadFailed { .. } => 23,
510            Error::InitFailed { .. } => 24,
511            Error::EmbedFailed { .. } => 25,
512        }
513    }
514
515    /// One value of every variant.
516    fn samples() -> Vec<Error> {
517        let json = || serde_json::from_str::<serde_json::Value>("{").unwrap_err();
518        vec![
519            Error::EngineBusy,
520            Error::WeightsAlreadyLoaded,
521            Error::WrongModel {
522                expected: Model::Needle,
523            },
524            Error::WrongModel {
525                expected: Model::Whistle,
526            },
527            Error::UnsupportedWeights { tag: 0 },
528            Error::UnsupportedWeights { tag: MAGIC },
529            Error::Load,
530            Error::LoadFailed {
531                model: Model::Whistle,
532                detail: "x".to_owned(),
533            },
534            Error::Init,
535            Error::InitFailed {
536                detail: "x".to_owned(),
537            },
538            Error::Complete {
539                detail: "x".to_owned(),
540            },
541            Error::Truncated { capacity: 1 },
542            Error::Embed,
543            Error::EmbedFailed {
544                detail: "x".to_owned(),
545            },
546            Error::InteriorNul { field: "input" },
547            Error::InteriorNul { field: "keywords" },
548            Error::SpeechModelNotLoaded,
549            Error::AudioTooLong { samples: 480_001 },
550            Error::NonFiniteSample { index: 7 },
551            Error::Transcribe {
552                detail: "x".to_owned(),
553            },
554            Error::EmbedAudio {
555                detail: "x".to_owned(),
556            },
557            Error::UnsupportedLanguage {
558                code: "xx".to_owned(),
559            },
560            Error::InvalidKeyword {
561                keyword: "a\nb".to_owned(),
562            },
563            Error::envelope("{", json()),
564            Error::Arguments { source: json() },
565            Error::Tools { source: json() },
566            Error::Io(std::io::Error::other("x")),
567            Error::Download {
568                url: "u".to_owned(),
569                detail: "d".to_owned(),
570            },
571            Error::ChecksumMismatch {
572                expected: "a".to_owned(),
573                actual: "b".to_owned(),
574            },
575        ]
576    }
577
578    #[test]
579    fn every_message_ends_with_a_help_line() {
580        let samples = samples();
581
582        let mut covered = [false; VARIANTS];
583        for error in &samples {
584            covered[variant_index(error)] = true;
585        }
586        assert!(
587            covered.iter().all(|seen| *seen),
588            "a variant has no sample: {covered:?}"
589        );
590
591        for error in samples {
592            let message = error.to_string();
593            let last = message.lines().last().unwrap_or_default();
594            assert!(last.starts_with("Help: "), "no help line in: {message}");
595            // What happened, then why or what to do: never a bare one-liner.
596            assert!(message.lines().count() >= 2, "one line only: {message}");
597        }
598    }
599
600    #[test]
601    fn detail_is_the_carried_reason_and_nothing_else() {
602        for error in samples() {
603            let carries = matches!(
604                error,
605                Error::Complete { .. }
606                    | Error::Transcribe { .. }
607                    | Error::EmbedAudio { .. }
608                    | Error::LoadFailed { .. }
609                    | Error::InitFailed { .. }
610                    | Error::EmbedFailed { .. }
611                    | Error::Download { .. }
612            );
613            match error.detail() {
614                Some(detail) => {
615                    assert!(carries, "unexpected detail on {error:?}");
616                    assert!(error.to_string().contains(detail), "{error:?}");
617                }
618                None => assert!(!carries, "no detail on {error:?}"),
619            }
620        }
621    }
622
623    #[test]
624    fn the_failed_variants_carry_the_engines_words() {
625        let load = Error::LoadFailed {
626            model: Model::Needle,
627            detail: "bad tensor table".to_owned(),
628        };
629        assert_eq!(load.detail(), Some("bad tensor table"));
630        assert!(load.to_string().starts_with("the engine rejected the "));
631        assert!(load.to_string().contains("bad tensor table"));
632
633        let init = Error::InitFailed {
634            detail: "needle_init returned -1: 40000 tokens".to_owned(),
635        };
636        assert_eq!(init.detail(), Some("needle_init returned -1: 40000 tokens"));
637
638        let embed = Error::EmbedFailed {
639            detail: "engine wrote 12 floats, expected 3072".to_owned(),
640        };
641        assert_eq!(
642            embed.detail(),
643            Some("engine wrote 12 floats, expected 3072")
644        );
645        for error in [Error::Load, Error::Init, Error::Embed] {
646            assert_eq!(error.detail(), None);
647        }
648    }
649
650    #[test]
651    fn wrong_model_names_both_the_model_and_its_archive() {
652        let message = Error::WrongModel {
653            expected: Model::Whistle,
654        }
655        .to_string();
656        assert!(message.starts_with("the weights are not a Whistle archive"));
657        assert!(message.contains("whistle.cact"));
658        assert!(message.contains("whistle::Weights"));
659        assert!(message.contains("it was not loaded"));
660        assert!(!message.contains("stays loaded"));
661    }
662
663    #[test]
664    fn unsupported_weights_tells_a_bad_tag_from_a_bad_directory() {
665        let tag = Error::UnsupportedWeights { tag: 0x05E1_2A83 }.to_string();
666        assert!(tag.contains("0x05E12A83 is not a Needle 3 or Whistle archive"));
667
668        let directory = Error::UnsupportedWeights { tag: MAGIC }.to_string();
669        assert!(directory.contains("tensor directory"));
670        assert!(!directory.contains("is not a Needle 3"));
671    }
672
673    #[test]
674    fn download_help_names_both_variables() {
675        let message = Error::Download {
676            url: "u".to_owned(),
677            detail: "d".to_owned(),
678        }
679        .to_string();
680        assert!(message.contains("CACTUS_NEEDLE_WEIGHTS"));
681        assert!(message.contains("CACTUS_WHISTLE_WEIGHTS"));
682    }
683
684    #[test]
685    fn envelope_excerpt_is_bounded() {
686        let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
687        let err = Error::envelope(&"a".repeat(4096), source);
688        let Error::Envelope { text, .. } = &err else {
689            panic!("expected an envelope error");
690        };
691        assert!(text.len() < 4096);
692    }
693
694    #[test]
695    fn short_envelopes_are_kept_whole() {
696        let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
697        let err = Error::envelope("{\"type\":\"nope\"", source);
698        let Error::Envelope { text, .. } = &err else {
699            panic!("expected an envelope error");
700        };
701        assert_eq!(text, "{\"type\":\"nope\"");
702    }
703}