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;