use std::{borrow::Cow, fmt, sync::Arc};
use bytes::Bytes;
use serde::Serialize;
use crate::{
client::Client,
codec::{self, EncodeError, RawJson},
content::Content,
de::AnswerSet,
error::Error,
request::SystemOne,
transport::HttpService,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Noul<'a> {
instructions: Option<Content<'a>>,
yes: Option<Content<'a>>,
no: Option<Content<'a>>,
}
impl<'a> Noul<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn instructions(mut self, instructions: impl Into<Content<'a>>) -> Self {
self.instructions = Some(instructions.into());
self
}
#[must_use]
pub fn yes(mut self, description: impl Into<Content<'a>>) -> Self {
self.yes = Some(description.into());
self
}
#[must_use]
pub fn no(mut self, description: impl Into<Content<'a>>) -> Self {
self.no = Some(description.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice<'a> {
instructions: Option<Content<'a>>,
options: Vec<(Cow<'a, str>, Option<Content<'a>>)>,
}
impl<'a> Choice<'a> {
#[must_use]
pub fn new<I>(options: I) -> Self
where
I: IntoIterator,
I::Item: Into<Cow<'a, str>>,
{
let options = options.into_iter();
let mut choice =
Self { instructions: None, options: Vec::with_capacity(options.size_hint().0) };
for name in options {
upsert(&mut choice.options, name.into(), None);
}
choice
}
#[must_use]
pub fn option(
mut self,
name: impl Into<Cow<'a, str>>,
description: impl Into<Content<'a>>,
) -> Self {
upsert(&mut self.options, name.into(), Some(description.into()));
self
}
#[must_use]
pub fn instructions(mut self, instructions: impl Into<Content<'a>>) -> Self {
self.instructions = Some(instructions.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Score<'a> {
instructions: Option<Content<'a>>,
levels: Vec<Content<'a>>,
}
impl<'a> Score<'a> {
#[must_use]
pub fn new<I>(levels: I) -> Self
where
I: IntoIterator,
I::Item: Into<Content<'a>>,
{
Self { instructions: None, levels: levels.into_iter().map(Into::into).collect() }
}
#[must_use]
pub fn instructions(mut self, instructions: impl Into<Content<'a>>) -> Self {
self.instructions = Some(instructions.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawQuestion<'a> {
fields: Vec<(Cow<'a, str>, RawJson)>,
failure: Option<(Cow<'a, str>, EncodeError)>,
}
impl<'a> RawQuestion<'a> {
#[must_use]
pub fn new(kind: &str) -> Self {
let kind = RawJson::from_value(kind).expect("invariant: a string always encodes as JSON");
Self { fields: vec![(Cow::Borrowed("type"), kind)], failure: None }
}
#[must_use]
pub fn field(mut self, name: impl Into<Cow<'a, str>>, value: impl Serialize) -> Self {
let name = name.into();
match RawJson::from_value(&value) {
Ok(raw) => upsert(&mut self.fields, name, raw),
Err(error) => {
if self.failure.is_none() {
self.failure = Some((name, error));
}
}
}
self
}
fn get(&self, name: &str) -> Option<&str> {
self.fields.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Question<'a> {
Noul(Noul<'a>),
Choice(Choice<'a>),
Score(Score<'a>),
Raw(RawQuestion<'a>),
}
impl<'a> From<Noul<'a>> for Question<'a> {
fn from(question: Noul<'a>) -> Self {
Self::Noul(question)
}
}
impl<'a> From<Choice<'a>> for Question<'a> {
fn from(question: Choice<'a>) -> Self {
Self::Choice(question)
}
}
impl<'a> From<Score<'a>> for Question<'a> {
fn from(question: Score<'a>) -> Self {
Self::Score(question)
}
}
impl<'a> From<RawQuestion<'a>> for Question<'a> {
fn from(question: RawQuestion<'a>) -> Self {
Self::Raw(question)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Questions<'a> {
entries: Vec<(Cow<'a, str>, Question<'a>)>,
}
impl<'a> Questions<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn question(
mut self,
name: impl Into<Cow<'a, str>>,
question: impl Into<Question<'a>>,
) -> Self {
upsert(&mut self.entries, name.into(), question.into());
self
}
#[must_use]
pub fn noul(self, name: impl Into<Cow<'a, str>>, question: Noul<'a>) -> Self {
self.question(name, question)
}
#[must_use]
pub fn choice(self, name: impl Into<Cow<'a, str>>, question: Choice<'a>) -> Self {
self.question(name, question)
}
#[must_use]
pub fn score(self, name: impl Into<Cow<'a, str>>, question: Score<'a>) -> Self {
self.question(name, question)
}
#[must_use]
pub fn raw(self, name: impl Into<Cow<'a, str>>, question: RawQuestion<'a>) -> Self {
self.question(name, question)
}
pub fn prepare(self) -> Result<PreparedQuestions, Error> {
if self.entries.is_empty() {
return Err(Error::invalid_request("At least one question is required."));
}
for (name, question) in &self.entries {
validate(name, question)?;
}
let bound = 2 + self
.entries
.iter()
.map(|(name, question)| string_bound(name.len()) + 2 + bound_of(question) + name.len())
.sum::<usize>();
let mut buf = Vec::with_capacity(bound);
buf.push(b'{');
for (index, (name, question)) in self.entries.iter().enumerate() {
if index > 0 {
buf.push(b',');
}
codec::write_json_string(&mut buf, name);
buf.push(b':');
write_question(&mut buf, question);
}
buf.push(b'}');
let json_len = buf.len();
let mut end = json_len;
let name_ends = self
.entries
.iter()
.map(|(name, _)| {
end += name.len();
end
})
.collect::<Arc<[usize]>>();
for (name, _) in &self.entries {
buf.extend_from_slice(name.as_bytes());
}
let max_levels = self
.entries
.iter()
.map(|(_, question)| match question {
Question::Score(score) => score.levels.len(),
_ => 0,
})
.max()
.unwrap_or(0);
Ok(PreparedQuestions {
buf: Bytes::from(buf.into_boxed_slice()),
json_len,
name_ends: NameEnds::Shared(name_ends),
max_levels,
})
}
}
#[derive(Clone)]
pub struct PreparedQuestions {
buf: Bytes,
json_len: usize,
name_ends: NameEnds,
max_levels: usize,
}
impl PartialEq for PreparedQuestions {
fn eq(&self, other: &Self) -> bool {
self.buf == other.buf
&& self.json_len == other.json_len
&& self.name_ends == other.name_ends
}
}
impl Eq for PreparedQuestions {}
#[derive(Clone)]
enum NameEnds {
Shared(Arc<[usize]>),
Static(&'static [usize]),
}
impl NameEnds {
fn as_slice(&self) -> &[usize] {
match self {
Self::Shared(ends) => ends,
Self::Static(ends) => ends,
}
}
}
impl PartialEq for NameEnds {
fn eq(&self, other: &Self) -> bool {
self.as_slice() == other.as_slice()
}
}
impl Eq for NameEnds {}
impl PreparedQuestions {
#[doc(hidden)]
#[must_use]
pub const fn from_static(
buf: &'static str,
json_len: usize,
name_ends: &'static [usize],
) -> Self {
assert!(!name_ends.is_empty(), "a question set has at least one question");
assert!(json_len <= buf.len(), "the JSON must end within the buffer");
assert!(buf.is_char_boundary(json_len), "the JSON must end on a character boundary");
let mut start = json_len;
let mut index = 0;
while index < name_ends.len() {
let end = name_ends[index];
assert!(start <= end, "a name must not end before it starts");
assert!(end <= buf.len(), "a name must end within the buffer");
assert!(buf.is_char_boundary(end), "a name must end on a character boundary");
start = end;
index += 1;
}
assert!(start == buf.len(), "the last name must end where the buffer does");
Self {
buf: Bytes::from_static(buf.as_bytes()),
json_len,
name_ends: NameEnds::Static(name_ends),
max_levels: 0,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.name_ends.as_slice().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.name_ends.as_slice().is_empty()
}
pub fn names(&self) -> impl ExactSizeIterator<Item = &str> + DoubleEndedIterator + '_ {
let ends = self.name_ends.as_slice();
(0..ends.len()).map(|index| {
let start = index.checked_sub(1).map_or(self.json_len, |previous| ends[previous]);
std::str::from_utf8(&self.buf[start..ends[index]])
.expect("invariant: the names were copied from `str`s")
})
}
pub(crate) fn max_levels(&self) -> usize {
self.max_levels
}
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.buf[..self.json_len]
}
fn json(&self) -> &str {
std::str::from_utf8(&self.buf[..self.json_len]).expect("invariant: the codec emits UTF-8")
}
}
impl fmt::Debug for PreparedQuestions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_struct("PreparedQuestions").field("json", &self.json()).finish()
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a question set",
label = "no questions are declared for this type",
note = "derive it: `#[derive(typesafe_sdk::QuestionSet)]` on a struct with one \
`NoulAnswer`, `ChoiceAnswer` or `ScoreAnswer` field per question"
)]
pub trait QuestionSet: AnswerSet {
fn prepared() -> &'static PreparedQuestions;
}
impl<S> Client<S>
where
S: HttpService,
{
pub fn ask<'a, Q>(
&'a self,
state: &'a (impl Serialize + ?Sized),
) -> SystemOne<'a, S, impl Serialize + ?Sized, Q>
where
Q: QuestionSet,
{
self.system_one(state, Q::prepared()).typed::<Q>()
}
}
pub(crate) fn upsert<K: PartialEq, V>(entries: &mut Vec<(K, V)>, name: K, value: V) {
match entries.iter_mut().find(|(key, _)| *key == name) {
Some((_, slot)) => *slot = value,
None => entries.push((name, value)),
}
}
fn validate(name: &str, question: &Question<'_>) -> Result<(), Error> {
match question {
Question::Noul(_) | Question::Choice(_) => Ok(()),
Question::Score(score) if score.levels.is_empty() => Err(no_criteria(name)),
Question::Score(_) => Ok(()),
Question::Raw(raw) => {
if let Some((field, error)) = &raw.failure {
return Err(Error::invalid_request(format!(
"Question \"{name}\" field \"{field}\": {error}"
)));
}
let Some(kind) = raw.get("type").and_then(string_value).filter(|kind| !kind.is_empty())
else {
return Err(Error::invalid_request(format!(
"Question \"{name}\" must be a question object or a dictionary with a nonempty string \"type\"."
)));
};
if kind != "choice" && kind != "score" {
return Ok(());
}
let Some(criteria) = raw.get("criteria") else {
return Err(Error::invalid_request(format!(
"Question \"{name}\" requires \"criteria\"."
)));
};
if kind == "score" && is_falsy(criteria) {
return Err(no_criteria(name));
}
Ok(())
}
}
}
fn no_criteria(name: &str) -> Error {
Error::invalid_request(format!(
"Score question \"{name}\" has no criteria; at least one score is required."
))
}
fn string_value(fragment: &str) -> Option<Cow<'_, str>> {
let text = fragment.trim_ascii();
let inner = text.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains('\\') {
codec::decode::<String>(text.as_bytes()).ok().map(Cow::Owned)
} else {
Some(Cow::Borrowed(inner))
}
}
fn is_falsy(fragment: &str) -> bool {
let text = fragment.trim_ascii().as_bytes();
match text {
b"null" | b"false" | b"\"\"" => true,
[b'[', inner @ .., b']'] | [b'{', inner @ .., b'}'] => inner.trim_ascii().is_empty(),
[b'-' | b'0'..=b'9', ..] => text
.iter()
.take_while(|byte| !matches!(byte, b'e' | b'E'))
.all(|byte| matches!(byte, b'-' | b'0' | b'.')),
_ => false,
}
}
fn bound_of(question: &Question<'_>) -> usize {
const MEMBER: usize = 18;
let string = string_bound;
let content = |content: &Content<'_>| {
MEMBER
+ match content.as_text() {
Some(text) => string(text.len()),
None => content.as_json().map_or(0, |raw| raw.as_str().len()),
}
};
let optional = |value: &Option<Content<'_>>| value.as_ref().map_or(0, content);
let fixed = 64; fixed
+ match question {
Question::Noul(noul) => {
optional(&noul.instructions) + optional(&noul.yes) + optional(&noul.no)
}
Question::Choice(choice) => {
optional(&choice.instructions)
+ choice
.options
.iter()
.map(|(name, description)| {
string(name.len()) + description.as_ref().map_or(4 + MEMBER, content)
})
.sum::<usize>()
}
Question::Score(score) => {
optional(&score.instructions) + score.levels.iter().map(content).sum::<usize>()
}
Question::Raw(raw) => raw
.fields
.iter()
.map(|(name, value)| string(name.len()) + value.as_str().len() + MEMBER)
.sum(),
}
}
fn string_bound(len: usize) -> usize {
6 * len + 35
}
fn write_question(buf: &mut Vec<u8>, question: &Question<'_>) {
match question {
Question::Noul(noul) => {
buf.extend_from_slice(br#"{"type":"noul""#);
write_instructions(buf, noul.instructions.as_ref());
if noul.yes.is_some() || noul.no.is_some() {
buf.extend_from_slice(br#","criteria":{"#);
let mut first = true;
for (key, value) in
[(&br#""true":"#[..], &noul.yes), (&br#""false":"#[..], &noul.no)]
{
if let Some(value) = value {
if !first {
buf.push(b',');
}
first = false;
buf.extend_from_slice(key);
write_content(buf, value);
}
}
buf.push(b'}');
}
buf.push(b'}');
}
Question::Choice(choice) => {
buf.extend_from_slice(br#"{"type":"choice""#);
write_instructions(buf, choice.instructions.as_ref());
buf.extend_from_slice(br#","criteria":{"#);
for (index, (name, description)) in choice.options.iter().enumerate() {
if index > 0 {
buf.push(b',');
}
codec::write_json_string(buf, name);
buf.push(b':');
match description {
Some(description) => write_content(buf, description),
None => buf.extend_from_slice(b"null"),
}
}
buf.extend_from_slice(b"}}");
}
Question::Score(score) => {
buf.extend_from_slice(br#"{"type":"score""#);
write_instructions(buf, score.instructions.as_ref());
buf.extend_from_slice(br#","criteria":["#);
for (index, level) in score.levels.iter().enumerate() {
if index > 0 {
buf.push(b',');
}
write_content(buf, level);
}
buf.extend_from_slice(b"]}");
}
Question::Raw(raw) => {
buf.push(b'{');
for (index, (name, value)) in raw.fields.iter().enumerate() {
if index > 0 {
buf.push(b',');
}
codec::write_json_string(buf, name);
buf.push(b':');
buf.extend_from_slice(value.as_str().as_bytes());
}
buf.push(b'}');
}
}
}
fn write_instructions(buf: &mut Vec<u8>, instructions: Option<&Content<'_>>) {
if let Some(instructions) = instructions {
buf.extend_from_slice(br#","instructions":"#);
write_content(buf, instructions);
}
}
fn write_content(buf: &mut Vec<u8>, content: &Content<'_>) {
match content.as_text() {
Some(text) => codec::write_json_string(buf, text),
None => {
let raw = content.as_json().expect("invariant: content that is not text is raw JSON");
buf.extend_from_slice(raw.as_str().as_bytes());
}
}
}
#[cfg(test)]
#[path = "question_tests.rs"]
mod tests;