Skip to main content

typesafe_sdk/
question.rs

1//! The questions a call asks, and the shapes the API accepts them in.
2//!
3//! A question is a noul (how true is this?), a choice (which of these?) or a
4//! score (how much, on this scale?); a raw question carries a shape this
5//! version of the SDK does not model, so a new question type on the server does
6//! not need a new release here.
7//!
8//! A question set is validated and serialized once, and the bytes are reused
9//! for every call that asks it. That is what keeps the per-call cost to
10//! splicing one prepared fragment into the body instead of walking a structure
11//! that has not changed since the last call.
12//!
13//! ```
14//! use typesafe_sdk::question::{Choice, Noul, Questions, Score};
15//!
16//! let prepared = Questions::new()
17//!     .noul("billing", Noul::new().instructions("Is this about billing?"))
18//!     .choice(
19//!         "tone",
20//!         Choice::new(["calm", "angry"])
21//!             .option("calm", "neutral or polite")
22//!             .instructions("What is the tone?"),
23//!     )
24//!     .score("urgency", Score::new(["can wait", "this week", "today"]))
25//!     .prepare()?;
26//!
27//! assert_eq!(prepared.len(), 3);
28//! assert_eq!(prepared.names().collect::<Vec<_>>(), ["billing", "tone", "urgency"]);
29//! # Ok::<(), typesafe_sdk::Error>(())
30//! ```
31
32use std::{borrow::Cow, fmt, sync::Arc};
33
34use bytes::Bytes;
35use serde::Serialize;
36
37use crate::{
38    client::Client,
39    codec::{self, EncodeError, RawJson},
40    content::Content,
41    de::AnswerSet,
42    error::Error,
43    request::SystemOne,
44    transport::HttpService,
45};
46
47/// A yes/no question: how true is a statement about the state?
48///
49/// Every member is optional. `yes` and `no` describe what counts as each
50/// outcome; they are sent as the `criteria` object's `true` and `false`
51/// members, and that object is left off entirely when neither is set.
52///
53/// ```
54/// use typesafe_sdk::question::Noul;
55///
56/// let spam = Noul::new()
57///     .instructions("Is this message spam?")
58///     .yes("unsolicited advertising")
59///     .no("a legitimate conversation");
60/// # let _ = spam;
61/// ```
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct Noul<'a> {
64    instructions: Option<Content<'a>>,
65    yes: Option<Content<'a>>,
66    no: Option<Content<'a>>,
67}
68
69impl<'a> Noul<'a> {
70    /// A noul with no instructions and no criteria.
71    #[must_use]
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// The question or statement to evaluate, as text or a JSON object or
77    /// array. Setting it again replaces it.
78    #[must_use]
79    pub fn instructions(mut self, instructions: impl Into<Content<'a>>) -> Self {
80        self.instructions = Some(instructions.into());
81        self
82    }
83
84    /// What counts as a yes answer. Setting it again replaces it.
85    #[must_use]
86    pub fn yes(mut self, description: impl Into<Content<'a>>) -> Self {
87        self.yes = Some(description.into());
88        self
89    }
90
91    /// What counts as a no answer. Setting it again replaces it.
92    #[must_use]
93    pub fn no(mut self, description: impl Into<Content<'a>>) -> Self {
94        self.no = Some(description.into());
95        self
96    }
97}
98
99/// A question that picks one of a set of named options.
100///
101/// An option without a description is interpreted by its name alone and is
102/// sent as `null`. Options are sent in the order they were first given; naming
103/// an option again replaces its description but keeps its position, which is
104/// what the upstream SDK's dictionary does.
105///
106/// ```
107/// use typesafe_sdk::question::Choice;
108///
109/// let tone = Choice::new(["calm", "angry"])
110///     .option("calm", "neutral or polite")
111///     .instructions("What is the tone?");
112/// # let _ = tone;
113/// ```
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Choice<'a> {
116    instructions: Option<Content<'a>>,
117    options: Vec<(Cow<'a, str>, Option<Content<'a>>)>,
118}
119
120impl<'a> Choice<'a> {
121    /// A choice between `options`, none of them described yet.
122    ///
123    /// No option count is enforced here: the API documents its limits as
124    /// subject to change, so the server is the one to judge them.
125    #[must_use]
126    pub fn new<I>(options: I) -> Self
127    where
128        I: IntoIterator,
129        I::Item: Into<Cow<'a, str>>,
130    {
131        let options = options.into_iter();
132        let mut choice =
133            Self { instructions: None, options: Vec::with_capacity(options.size_hint().0) };
134        for name in options {
135            upsert(&mut choice.options, name.into(), None);
136        }
137        choice
138    }
139
140    /// Adds the option `name` with a description, or describes it if it is
141    /// already there.
142    #[must_use]
143    pub fn option(
144        mut self,
145        name: impl Into<Cow<'a, str>>,
146        description: impl Into<Content<'a>>,
147    ) -> Self {
148        upsert(&mut self.options, name.into(), Some(description.into()));
149        self
150    }
151
152    /// What the model should decide when choosing. Setting it again replaces
153    /// it.
154    #[must_use]
155    pub fn instructions(mut self, instructions: impl Into<Content<'a>>) -> Self {
156        self.instructions = Some(instructions.into());
157        self
158    }
159}
160
161/// A question that rates the state on an ordered scale.
162///
163/// Each level is described by text or a JSON object or array, and its
164/// position is its score, starting at zero. A score with no levels is rejected
165/// by [`Questions::prepare`].
166///
167/// ```
168/// use typesafe_sdk::question::Score;
169///
170/// let urgency = Score::new(["can wait", "this week", "today"]).instructions("How urgent is it?");
171/// # let _ = urgency;
172/// ```
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct Score<'a> {
175    instructions: Option<Content<'a>>,
176    levels: Vec<Content<'a>>,
177}
178
179impl<'a> Score<'a> {
180    /// A score over `levels`, lowest first.
181    #[must_use]
182    pub fn new<I>(levels: I) -> Self
183    where
184        I: IntoIterator,
185        I::Item: Into<Content<'a>>,
186    {
187        Self { instructions: None, levels: levels.into_iter().map(Into::into).collect() }
188    }
189
190    /// What the model should rate. Setting it again replaces it.
191    #[must_use]
192    pub fn instructions(mut self, instructions: impl Into<Content<'a>>) -> Self {
193        self.instructions = Some(instructions.into());
194        self
195    }
196}
197
198/// A question of a type, or with fields, that this version of the SDK does
199/// not model.
200///
201/// It is a JSON object built field by field: `type` is set by
202/// [`new`](Self::new), and every [`field`](Self::field) is encoded when it is
203/// given and sent unread. Setting a field again replaces its value and keeps
204/// its position, `type` included.
205///
206/// [`Questions::prepare`] applies the checks the API's own shape makes
207/// possible without knowing the type: `type` is a nonempty string, a `choice`
208/// or `score` has `criteria`, and a `score`'s `criteria` is not empty.
209/// Everything else is left to the server.
210///
211/// ```
212/// use typesafe_sdk::question::RawQuestion;
213///
214/// let spam = RawQuestion::new("noul").field("instructions", "Spam?").field("weight", 3);
215/// # let _ = spam;
216/// ```
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct RawQuestion<'a> {
219    fields: Vec<(Cow<'a, str>, RawJson)>,
220    /// The first field that could not be encoded. It is reported by
221    /// [`Questions::prepare`] rather than here, so that a question can still
222    /// be built as one chain of calls.
223    failure: Option<(Cow<'a, str>, EncodeError)>,
224}
225
226impl<'a> RawQuestion<'a> {
227    /// A question whose `type` is `kind`.
228    #[must_use]
229    pub fn new(kind: &str) -> Self {
230        let kind = RawJson::from_value(kind).expect("invariant: a string always encodes as JSON");
231        Self { fields: vec![(Cow::Borrowed("type"), kind)], failure: None }
232    }
233
234    /// Sets the field `name` to the JSON form of `value`.
235    ///
236    /// A value that cannot be encoded - a map whose keys are neither strings,
237    /// booleans nor numbers, or a [`Serialize`] implementation that fails - is
238    /// not stored, and [`Questions::prepare`] reports it.
239    #[must_use]
240    pub fn field(mut self, name: impl Into<Cow<'a, str>>, value: impl Serialize) -> Self {
241        let name = name.into();
242        match RawJson::from_value(&value) {
243            Ok(raw) => upsert(&mut self.fields, name, raw),
244            Err(error) => {
245                if self.failure.is_none() {
246                    self.failure = Some((name, error));
247                }
248            }
249        }
250        self
251    }
252
253    /// The encoded value of the field `name`, if it is set.
254    fn get(&self, name: &str) -> Option<&str> {
255        self.fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
256    }
257}
258
259/// Any one question, for code that builds a question set from data.
260///
261/// [`Questions`] has a method per kind; this is what
262/// [`Questions::question`] takes, and every question type converts into it.
263#[derive(Debug, Clone, PartialEq, Eq)]
264#[non_exhaustive]
265pub enum Question<'a> {
266    /// A yes/no question.
267    Noul(Noul<'a>),
268    /// A question that picks one option.
269    Choice(Choice<'a>),
270    /// A question that rates on a scale.
271    Score(Score<'a>),
272    /// A question this version of the SDK does not model.
273    Raw(RawQuestion<'a>),
274}
275
276impl<'a> From<Noul<'a>> for Question<'a> {
277    fn from(question: Noul<'a>) -> Self {
278        Self::Noul(question)
279    }
280}
281
282impl<'a> From<Choice<'a>> for Question<'a> {
283    fn from(question: Choice<'a>) -> Self {
284        Self::Choice(question)
285    }
286}
287
288impl<'a> From<Score<'a>> for Question<'a> {
289    fn from(question: Score<'a>) -> Self {
290        Self::Score(question)
291    }
292}
293
294impl<'a> From<RawQuestion<'a>> for Question<'a> {
295    fn from(question: RawQuestion<'a>) -> Self {
296        Self::Raw(question)
297    }
298}
299
300/// The questions of one call, keyed by the names their answers come back
301/// under.
302///
303/// The order questions are added in is the order they are sent in. Adding a
304/// name that is already there replaces that question and keeps its position,
305/// as the upstream SDK's dictionary does.
306///
307/// Nothing is checked until [`prepare`](Self::prepare), which validates and
308/// serializes the whole set once.
309#[derive(Debug, Clone, Default, PartialEq, Eq)]
310pub struct Questions<'a> {
311    entries: Vec<(Cow<'a, str>, Question<'a>)>,
312}
313
314impl<'a> Questions<'a> {
315    /// An empty question set.
316    #[must_use]
317    pub fn new() -> Self {
318        Self::default()
319    }
320
321    /// Adds a question of any kind under `name`.
322    #[must_use]
323    pub fn question(
324        mut self,
325        name: impl Into<Cow<'a, str>>,
326        question: impl Into<Question<'a>>,
327    ) -> Self {
328        upsert(&mut self.entries, name.into(), question.into());
329        self
330    }
331
332    /// Adds a yes/no question under `name`.
333    #[must_use]
334    pub fn noul(self, name: impl Into<Cow<'a, str>>, question: Noul<'a>) -> Self {
335        self.question(name, question)
336    }
337
338    /// Adds a choice question under `name`.
339    #[must_use]
340    pub fn choice(self, name: impl Into<Cow<'a, str>>, question: Choice<'a>) -> Self {
341        self.question(name, question)
342    }
343
344    /// Adds a score question under `name`.
345    #[must_use]
346    pub fn score(self, name: impl Into<Cow<'a, str>>, question: Score<'a>) -> Self {
347        self.question(name, question)
348    }
349
350    /// Adds a raw question under `name`.
351    #[must_use]
352    pub fn raw(self, name: impl Into<Cow<'a, str>>, question: RawQuestion<'a>) -> Self {
353        self.question(name, question)
354    }
355
356    /// Validates the set and serializes it into the bytes every call will
357    /// send.
358    ///
359    /// # Errors
360    ///
361    /// Returns an [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest)
362    /// error, with the upstream SDK's message, when the set is empty, when a
363    /// score has no levels, when a raw question's `type` is not a nonempty
364    /// string, when a raw `choice` or `score` has no `criteria`, when a raw
365    /// `score`'s `criteria` is empty, or when a raw question's field could not
366    /// be encoded. The first failing question, in order, is the one reported.
367    pub fn prepare(self) -> Result<PreparedQuestions, Error> {
368        if self.entries.is_empty() {
369            return Err(Error::invalid_request("At least one question is required."));
370        }
371        for (name, question) in &self.entries {
372            validate(name, question)?;
373        }
374
375        // The codec reserves room for the worst-case escaping of every string
376        // before it writes it, so the buffer is sized for that worst case up
377        // front and never grows while it is written; it is cut down to the
378        // bytes actually written once at the end.
379        // Per question: the escaped key, the separator and colon, the question,
380        // and the unescaped copy of the name that follows the JSON.
381        let bound = 2 + self
382            .entries
383            .iter()
384            .map(|(name, question)| string_bound(name.len()) + 2 + bound_of(question) + name.len())
385            .sum::<usize>();
386        let mut buf = Vec::with_capacity(bound);
387        buf.push(b'{');
388        for (index, (name, question)) in self.entries.iter().enumerate() {
389            if index > 0 {
390                buf.push(b',');
391            }
392            codec::write_json_string(&mut buf, name);
393            buf.push(b':');
394            write_question(&mut buf, question);
395        }
396        buf.push(b'}');
397        let json_len = buf.len();
398
399        // The names follow the JSON unescaped, so that they can be handed out
400        // as `&str` without a separate allocation per name.
401        let mut end = json_len;
402        let name_ends = self
403            .entries
404            .iter()
405            .map(|(name, _)| {
406                end += name.len();
407                end
408            })
409            .collect::<Arc<[usize]>>();
410        for (name, _) in &self.entries {
411            buf.extend_from_slice(name.as_bytes());
412        }
413
414        let max_levels = self
415            .entries
416            .iter()
417            .map(|(_, question)| match question {
418                Question::Score(score) => score.levels.len(),
419                _ => 0,
420            })
421            .max()
422            .unwrap_or(0);
423        Ok(PreparedQuestions {
424            buf: Bytes::from(buf.into_boxed_slice()),
425            json_len,
426            name_ends: NameEnds::Shared(name_ends),
427            max_levels,
428        })
429    }
430}
431
432/// A validated question set, serialized once.
433///
434/// Cloning it copies a reference count, not the bytes, and it can be shared
435/// between threads and reused by any number of calls.
436#[derive(Clone)]
437pub struct PreparedQuestions {
438    /// The JSON object sent as `questions`, then every name, unescaped, back
439    /// to back.
440    buf: Bytes,
441    /// Where the JSON object ends and the first name begins.
442    json_len: usize,
443    /// Where each name ends in `buf`; each name starts where the one before
444    /// it ends.
445    name_ends: NameEnds,
446    /// The most levels any score question has, or 0 when not known (a set
447    /// compiled into the program, or scores given as raw questions only). A
448    /// sizing hint for decoding the answers, never sent.
449    max_levels: usize,
450}
451
452/// Two sets are equal when their bytes are, however each was made; the
453/// sizing hint is not part of the set.
454impl PartialEq for PreparedQuestions {
455    fn eq(&self, other: &Self) -> bool {
456        self.buf == other.buf
457            && self.json_len == other.json_len
458            && self.name_ends == other.name_ends
459    }
460}
461
462impl Eq for PreparedQuestions {}
463
464/// The name ends of a prepared set: allocated once by [`Questions::prepare`],
465/// or compiled into the program for a [`QuestionSet`].
466#[derive(Clone)]
467enum NameEnds {
468    Shared(Arc<[usize]>),
469    Static(&'static [usize]),
470}
471
472impl NameEnds {
473    fn as_slice(&self) -> &[usize] {
474        match self {
475            Self::Shared(ends) => ends,
476            Self::Static(ends) => ends,
477        }
478    }
479}
480
481/// Two sets are equal when their bytes are, however each was made.
482impl PartialEq for NameEnds {
483    fn eq(&self, other: &Self) -> bool {
484        self.as_slice() == other.as_slice()
485    }
486}
487
488impl Eq for NameEnds {}
489
490impl PreparedQuestions {
491    /// A set whose bytes were produced at compile time; what the code that
492    /// `#[derive(QuestionSet)]` generates calls. No semver promise.
493    ///
494    /// `buf` holds the JSON object in its first `json_len` bytes and then
495    /// every name, unescaped and back to back; `name_ends` holds where each
496    /// name ends. Nothing is allocated, now or when the set is used. The
497    /// layout is checked here, and since the generated code calls this in a
498    /// `static`, a layout that does not hold stops the build rather than a
499    /// running program.
500    ///
501    /// # Panics
502    ///
503    /// When `name_ends` is empty, when the JSON or a name would end past the
504    /// end of `buf`, when a name would end before it starts, when the last
505    /// name does not end where `buf` does, or when a boundary falls inside a
506    /// UTF-8 character.
507    #[doc(hidden)]
508    #[must_use]
509    pub const fn from_static(
510        buf: &'static str,
511        json_len: usize,
512        name_ends: &'static [usize],
513    ) -> Self {
514        assert!(!name_ends.is_empty(), "a question set has at least one question");
515        assert!(json_len <= buf.len(), "the JSON must end within the buffer");
516        assert!(buf.is_char_boundary(json_len), "the JSON must end on a character boundary");
517        let mut start = json_len;
518        // `for` loops and iterators are not available in a `const fn`.
519        let mut index = 0;
520        while index < name_ends.len() {
521            let end = name_ends[index];
522            assert!(start <= end, "a name must not end before it starts");
523            assert!(end <= buf.len(), "a name must end within the buffer");
524            assert!(buf.is_char_boundary(end), "a name must end on a character boundary");
525            start = end;
526            index += 1;
527        }
528        assert!(start == buf.len(), "the last name must end where the buffer does");
529        Self {
530            buf: Bytes::from_static(buf.as_bytes()),
531            json_len,
532            name_ends: NameEnds::Static(name_ends),
533            max_levels: 0,
534        }
535    }
536
537    /// The number of questions in the set. It is never zero.
538    #[must_use]
539    pub fn len(&self) -> usize {
540        self.name_ends.as_slice().len()
541    }
542
543    /// Always `false`: an empty set is rejected by [`Questions::prepare`].
544    #[must_use]
545    pub fn is_empty(&self) -> bool {
546        self.name_ends.as_slice().is_empty()
547    }
548
549    /// The question names, in the order they are sent.
550    pub fn names(&self) -> impl ExactSizeIterator<Item = &str> + DoubleEndedIterator + '_ {
551        let ends = self.name_ends.as_slice();
552        (0..ends.len()).map(|index| {
553            let start = index.checked_sub(1).map_or(self.json_len, |previous| ends[previous]);
554            std::str::from_utf8(&self.buf[start..ends[index]])
555                .expect("invariant: the names were copied from `str`s")
556        })
557    }
558
559    /// The most levels any score question of the set has, or 0 when not
560    /// known.
561    pub(crate) fn max_levels(&self) -> usize {
562        self.max_levels
563    }
564
565    /// The JSON object that goes after `"questions":` in a request body.
566    pub(crate) fn as_bytes(&self) -> &[u8] {
567        &self.buf[..self.json_len]
568    }
569
570    /// The JSON object as text.
571    fn json(&self) -> &str {
572        std::str::from_utf8(&self.buf[..self.json_len]).expect("invariant: the codec emits UTF-8")
573    }
574}
575
576impl fmt::Debug for PreparedQuestions {
577    /// Prints the JSON the set is sent as: questions are not secrets, and the
578    /// wire form is the one thing worth seeing.
579    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580        formatter.debug_struct("PreparedQuestions").field("json", &self.json()).finish()
581    }
582}
583
584/// A question set declared as a type: it knows the questions it asks, and its
585/// answers decode into it.
586///
587/// `#[derive(QuestionSet)]` implements it for a struct with one field per
588/// question, serializing the questions when the program is compiled; see the
589/// derive for the attributes it reads. [`Client::ask`] sends one:
590///
591/// ```
592/// # #[cfg(feature = "macros")]
593/// # fn main() -> Result<(), typesafe_sdk::Error> {
594/// use typesafe_sdk::{ChoiceAnswer, Choice, NoulAnswer, Noul, Questions, QuestionSet};
595///
596/// #[derive(QuestionSet)]
597/// struct Ticket {
598///     #[noul(instructions = "Is this about billing?")]
599///     billing: NoulAnswer,
600///     #[choice(options("calm", "angry"))]
601///     tone: ChoiceAnswer,
602/// }
603///
604/// // The same questions, built at run time, are the same bytes.
605/// let built = Questions::new()
606///     .noul("billing", Noul::new().instructions("Is this about billing?"))
607///     .choice("tone", Choice::new(["calm", "angry"]))
608///     .prepare()?;
609/// assert_eq!(Ticket::prepared(), &built);
610/// # Ok(())
611/// # }
612/// # #[cfg(not(feature = "macros"))]
613/// # fn main() {}
614/// ```
615///
616/// Implementing it by hand takes a `'static` set and an [`AnswerSet`]
617/// implementation that follows that trait's contract.
618#[diagnostic::on_unimplemented(
619    message = "`{Self}` is not a question set",
620    label = "no questions are declared for this type",
621    note = "derive it: `#[derive(typesafe_sdk::QuestionSet)]` on a struct with one \
622            `NoulAnswer`, `ChoiceAnswer` or `ScoreAnswer` field per question"
623)]
624pub trait QuestionSet: AnswerSet {
625    /// The questions, validated and serialized once for the whole program.
626    fn prepared() -> &'static PreparedQuestions;
627}
628
629/// Asking a [`QuestionSet`].
630impl<S> Client<S>
631where
632    S: HttpService,
633{
634    /// A System One request that asks the questions of `Q` about `state` and
635    /// decodes the answers into a `Q`.
636    ///
637    /// It is [`system_one`](Client::system_one) with `Q`'s prepared questions,
638    /// made [`typed`](SystemOne::typed) as `Q`: the same builder, configured
639    /// and sent the same way.
640    ///
641    /// The state's type is not a type parameter of this method, so that
642    /// `ask::<Ticket>(&state)` names only the question set. The price is that
643    /// the state's type is opaque in the returned builder's type: a function
644    /// that takes the builder takes it as a generic. Where the type must be
645    /// named,
646    /// `client.system_one(&state, Ticket::prepared()).typed::<Ticket>()` is
647    /// the same request with the state's own type.
648    ///
649    /// ```
650    /// # #[cfg(feature = "macros")]
651    /// # fn main() -> Result<(), typesafe_sdk::Error> {
652    /// use std::time::Duration;
653    ///
654    /// use typesafe_sdk::{Client, NoulAnswer, QuestionSet};
655    ///
656    /// #[derive(QuestionSet)]
657    /// struct Spam {
658    ///     #[noul(instructions = "Is this message spam?")]
659    ///     spam: NoulAnswer,
660    /// }
661    ///
662    /// let client = Client::builder().api_key("your-api-key").build()?;
663    /// let request = client.ask::<Spam>("Buy now!").timeout(Duration::from_secs(2));
664    /// // `request.send().await?` needs a Tokio runtime and returns a
665    /// // `SystemOneResponse<Spam>`, whose `answers().spam` is the answer.
666    /// drop(request);
667    /// # Ok(())
668    /// # }
669    /// # #[cfg(not(feature = "macros"))]
670    /// # fn main() {}
671    /// ```
672    pub fn ask<'a, Q>(
673        &'a self,
674        state: &'a (impl Serialize + ?Sized),
675    ) -> SystemOne<'a, S, impl Serialize + ?Sized, Q>
676    where
677        Q: QuestionSet,
678    {
679        self.system_one(state, Q::prepared()).typed::<Q>()
680    }
681}
682
683/// Inserts `value` under `name`, or replaces the value already there without
684/// moving it: the semantics of a Python `dict`, which is what the upstream SDK
685/// holds questions, options and raw fields in.
686///
687/// The lookup is linear. These lists are the options, questions, members or
688/// headers of one request, a handful to a few hundred entries, where a scan of
689/// a `Vec` is cheaper than hashing and keeps the insertion order for free.
690pub(crate) fn upsert<K: PartialEq, V>(entries: &mut Vec<(K, V)>, name: K, value: V) {
691    match entries.iter_mut().find(|(key, _)| *key == name) {
692        Some((_, slot)) => *slot = value,
693        None => entries.push((name, value)),
694    }
695}
696
697/// Applies the upstream SDK's checks (`_core/questions.py`) to one question.
698fn validate(name: &str, question: &Question<'_>) -> Result<(), Error> {
699    match question {
700        Question::Noul(_) | Question::Choice(_) => Ok(()),
701        Question::Score(score) if score.levels.is_empty() => Err(no_criteria(name)),
702        Question::Score(_) => Ok(()),
703        Question::Raw(raw) => {
704            if let Some((field, error)) = &raw.failure {
705                return Err(Error::invalid_request(format!(
706                    "Question \"{name}\" field \"{field}\": {error}"
707                )));
708            }
709            let Some(kind) = raw.get("type").and_then(string_value).filter(|kind| !kind.is_empty())
710            else {
711                return Err(Error::invalid_request(format!(
712                    "Question \"{name}\" must be a question object or a dictionary with a nonempty string \"type\"."
713                )));
714            };
715            if kind != "choice" && kind != "score" {
716                return Ok(());
717            }
718            let Some(criteria) = raw.get("criteria") else {
719                return Err(Error::invalid_request(format!(
720                    "Question \"{name}\" requires \"criteria\"."
721                )));
722            };
723            if kind == "score" && is_falsy(criteria) {
724                return Err(no_criteria(name));
725            }
726            Ok(())
727        }
728    }
729}
730
731fn no_criteria(name: &str) -> Error {
732    Error::invalid_request(format!(
733        "Score question \"{name}\" has no criteria; at least one score is required."
734    ))
735}
736
737/// The value of an encoded JSON string, or `None` when the fragment is not a
738/// string.
739///
740/// A fragment without a backslash is borrowed. One with an escape is decoded,
741/// so that a `type` spelled `"score"` by a spliced [`RawJson`] is still
742/// recognized as `score`, as it would be once the server has parsed it.
743fn string_value(fragment: &str) -> Option<Cow<'_, str>> {
744    let text = fragment.trim_ascii();
745    let inner = text.strip_prefix('"')?.strip_suffix('"')?;
746    if inner.contains('\\') {
747        codec::decode::<String>(text.as_bytes()).ok().map(Cow::Owned)
748    } else {
749        Some(Cow::Borrowed(inner))
750    }
751}
752
753/// Whether an encoded JSON value is one Python treats as false: `null`,
754/// `false`, a zero, `""`, `[]` or `{}`.
755///
756/// Upstream rejects a raw score whose `criteria` is any of these (`if not
757/// criteria`). The check reads the first and last bytes, and for a number its
758/// mantissa digits; it never parses the value.
759fn is_falsy(fragment: &str) -> bool {
760    let text = fragment.trim_ascii().as_bytes();
761    match text {
762        b"null" | b"false" | b"\"\"" => true,
763        [b'[', inner @ .., b']'] | [b'{', inner @ .., b'}'] => inner.trim_ascii().is_empty(),
764        [b'-' | b'0'..=b'9', ..] => text
765            .iter()
766            .take_while(|byte| !matches!(byte, b'e' | b'E'))
767            .all(|byte| matches!(byte, b'-' | b'0' | b'.')),
768        _ => false,
769    }
770}
771
772/// An upper bound on the bytes [`write_question`] needs, counting the room
773/// the codec reserves before each string it writes (`6 * len + 35`).
774fn bound_of(question: &Question<'_>) -> usize {
775    // Quotes, colon and comma around a member, and its longest fixed name.
776    const MEMBER: usize = 18;
777    let string = string_bound;
778    let content = |content: &Content<'_>| {
779        MEMBER
780            + match content.as_text() {
781                Some(text) => string(text.len()),
782                None => content.as_json().map_or(0, |raw| raw.as_str().len()),
783            }
784    };
785    let optional = |value: &Option<Content<'_>>| value.as_ref().map_or(0, content);
786    let fixed = 64; // braces, the type member and the criteria member
787    fixed
788        + match question {
789            Question::Noul(noul) => {
790                optional(&noul.instructions) + optional(&noul.yes) + optional(&noul.no)
791            }
792            Question::Choice(choice) => {
793                optional(&choice.instructions)
794                    + choice
795                        .options
796                        .iter()
797                        .map(|(name, description)| {
798                            string(name.len()) + description.as_ref().map_or(4 + MEMBER, content)
799                        })
800                        .sum::<usize>()
801            }
802            Question::Score(score) => {
803                optional(&score.instructions) + score.levels.iter().map(content).sum::<usize>()
804            }
805            Question::Raw(raw) => raw
806                .fields
807                .iter()
808                .map(|(name, value)| string(name.len()) + value.as_str().len() + MEMBER)
809                .sum(),
810        }
811}
812
813/// The room the codec reserves before writing a string of `len` bytes: its
814/// worst-case escaping (`\u00XX`, six bytes per input byte) plus a margin.
815fn string_bound(len: usize) -> usize {
816    6 * len + 35
817}
818
819/// Writes one question object.
820///
821/// Members are written in the order of the upstream wire models: `type`,
822/// `instructions`, `criteria`. Optional members that are unset are left out
823/// rather than written as `null`, as upstream does.
824fn write_question(buf: &mut Vec<u8>, question: &Question<'_>) {
825    match question {
826        Question::Noul(noul) => {
827            buf.extend_from_slice(br#"{"type":"noul""#);
828            write_instructions(buf, noul.instructions.as_ref());
829            if noul.yes.is_some() || noul.no.is_some() {
830                buf.extend_from_slice(br#","criteria":{"#);
831                let mut first = true;
832                for (key, value) in
833                    [(&br#""true":"#[..], &noul.yes), (&br#""false":"#[..], &noul.no)]
834                {
835                    if let Some(value) = value {
836                        if !first {
837                            buf.push(b',');
838                        }
839                        first = false;
840                        buf.extend_from_slice(key);
841                        write_content(buf, value);
842                    }
843                }
844                buf.push(b'}');
845            }
846            buf.push(b'}');
847        }
848        Question::Choice(choice) => {
849            buf.extend_from_slice(br#"{"type":"choice""#);
850            write_instructions(buf, choice.instructions.as_ref());
851            buf.extend_from_slice(br#","criteria":{"#);
852            for (index, (name, description)) in choice.options.iter().enumerate() {
853                if index > 0 {
854                    buf.push(b',');
855                }
856                codec::write_json_string(buf, name);
857                buf.push(b':');
858                match description {
859                    Some(description) => write_content(buf, description),
860                    None => buf.extend_from_slice(b"null"),
861                }
862            }
863            buf.extend_from_slice(b"}}");
864        }
865        Question::Score(score) => {
866            buf.extend_from_slice(br#"{"type":"score""#);
867            write_instructions(buf, score.instructions.as_ref());
868            buf.extend_from_slice(br#","criteria":["#);
869            for (index, level) in score.levels.iter().enumerate() {
870                if index > 0 {
871                    buf.push(b',');
872                }
873                write_content(buf, level);
874            }
875            buf.extend_from_slice(b"]}");
876        }
877        Question::Raw(raw) => {
878            buf.push(b'{');
879            for (index, (name, value)) in raw.fields.iter().enumerate() {
880                if index > 0 {
881                    buf.push(b',');
882                }
883                codec::write_json_string(buf, name);
884                buf.push(b':');
885                buf.extend_from_slice(value.as_str().as_bytes());
886            }
887            buf.push(b'}');
888        }
889    }
890}
891
892fn write_instructions(buf: &mut Vec<u8>, instructions: Option<&Content<'_>>) {
893    if let Some(instructions) = instructions {
894        buf.extend_from_slice(br#","instructions":"#);
895        write_content(buf, instructions);
896    }
897}
898
899/// Writes text as a JSON string and raw JSON as the text it holds.
900///
901/// Raw JSON is copied rather than serialized: it is already one valid JSON
902/// value, and copying it skips the codec entirely.
903fn write_content(buf: &mut Vec<u8>, content: &Content<'_>) {
904    match content.as_text() {
905        Some(text) => codec::write_json_string(buf, text),
906        None => {
907            let raw = content.as_json().expect("invariant: content that is not text is raw JSON");
908            buf.extend_from_slice(raw.as_str().as_bytes());
909        }
910    }
911}
912
913#[cfg(test)]
914#[path = "question_tests.rs"]
915mod tests;