Skip to main content

jev/
spec.rs

1//! Questions written on the command line: `ID=INSTRUCTIONS`, then `|`-separated criteria.
2//!
3//! - `--noul 'is_urgent=Does this message convey urgency?'`, or with what a yes and a no mean:
4//!   `--noul 'is_urgent=Is it urgent?|Explicitly time-sensitive|No urgency expressed'`
5//! - `--choice 'department=Which team should handle this?|billing:Payments, refunds|technical:Bugs|sales'`
6//!   (each option is `NAME` or `NAME:DESCRIPTION`)
7//! - `--score 'frustration=How frustrated is the customer?|Calm|Frustrated|Very angry'` (lowest first)
8//!
9//! Text with a `|` in it, or structured criteria, go in a `--questions` file instead.
10
11use serde::de::{Error as _, MapAccess};
12use serde::{Deserialize, Deserializer};
13use serde_json::Value;
14
15use crate::Question;
16
17/// Which flag a question came from.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Kind {
20    Noul,
21    Choice,
22    Score,
23}
24
25impl Kind {
26    /// The flag's name, which is also clap's id for it.
27    pub fn flag(self) -> &'static str {
28        match self {
29            Kind::Noul => "noul",
30            Kind::Choice => "choice",
31            Kind::Score => "score",
32        }
33    }
34}
35
36/// The question `spec` writes, under its id. The error says what's wrong with it.
37pub fn parse(kind: Kind, spec: &str) -> Result<(String, Question), String> {
38    let wrong = |why: &str| format!("--{} `{spec}`: {why}", kind.flag());
39    let (id, rest) = spec.split_once('=').ok_or_else(|| wrong("expected ID=INSTRUCTIONS"))?;
40    let id = id.trim();
41    if id.is_empty() || id.contains(char::is_whitespace) {
42        return Err(wrong("the id before `=` must be one word"));
43    }
44    let mut parts = rest.split('|').map(str::trim);
45    let instructions = parts.next().unwrap_or_default();
46    if instructions.is_empty() {
47        return Err(wrong("no instructions after `=`"));
48    }
49    let criteria: Vec<&str> = parts.collect();
50    if criteria.iter().any(|part| part.is_empty()) {
51        return Err(wrong("an empty criterion between `|`s"));
52    }
53
54    let question = match kind {
55        Kind::Noul => match criteria[..] {
56            [] => Question::noul(instructions),
57            [yes, no] => Question::noul_with_criteria(instructions, yes, no),
58            _ => return Err(wrong("a noul takes no criteria, or two: |WHAT YES MEANS|WHAT NO MEANS")),
59        },
60        Kind::Choice => {
61            if criteria.len() < 2 {
62                return Err(wrong("a choice needs at least two options: |NAME[:DESCRIPTION]|…"));
63            }
64            let mut options: Vec<(String, Value)> = Vec::new();
65            for option in criteria {
66                let (name, description) = match option.split_once(':') {
67                    Some((name, description)) => (name.trim(), Value::from(description.trim())),
68                    None => (option, Value::Null),
69                };
70                if name.is_empty() {
71                    return Err(wrong(&format!("option `{option}` has no name before `:`")));
72                }
73                if options.iter().any(|(other, _)| other == name) {
74                    return Err(wrong(&format!("option `{name}` is there twice")));
75                }
76                options.push((name.to_owned(), description));
77            }
78            Question::choice(instructions, options)
79        }
80        Kind::Score => {
81            if !(2..=10).contains(&criteria.len()) {
82                return Err(wrong("a score takes two to ten levels, lowest first: |LEVEL|LEVEL|…"));
83            }
84            Question::score(instructions, criteria)
85        }
86    };
87    Ok((id.to_owned(), question))
88}
89
90#[cfg(test)]
91mod tests {
92    use serde_json::json;
93
94    use super::*;
95
96    fn question(kind: Kind, spec: &str) -> Value {
97        let (id, question) = parse(kind, spec).unwrap();
98        json!({ id: question })
99    }
100
101    #[test]
102    fn reads_a_noul() {
103        assert_eq!(
104            question(Kind::Noul, "is_urgent=Does this message convey urgency?"),
105            json!({"is_urgent": {"type": "noul", "instructions": "Does this message convey urgency?"}})
106        );
107        assert_eq!(
108            question(Kind::Noul, "is_urgent = Is it urgent? | Explicitly time-sensitive | No urgency expressed"),
109            json!({"is_urgent": {
110                "type": "noul",
111                "instructions": "Is it urgent?",
112                "criteria": {"true": "Explicitly time-sensitive", "false": "No urgency expressed"}
113            }})
114        );
115    }
116
117    #[test]
118    fn reads_a_choice_with_and_without_descriptions() {
119        assert_eq!(
120            serde_json::to_string(&parse(Kind::Choice, "department=Which team?|sales|billing:Payments: cards, refunds").unwrap().1)
121                .unwrap(),
122            r#"{"type":"choice","instructions":"Which team?","criteria":{"sales":null,"billing":"Payments: cards, refunds"}}"#
123        );
124    }
125
126    #[test]
127    fn reads_a_score() {
128        assert_eq!(
129            question(Kind::Score, "frustration=How frustrated?|Calm|Frustrated|Very angry"),
130            json!({"frustration": {"type": "score", "instructions": "How frustrated?", "criteria": ["Calm", "Frustrated", "Very angry"]}})
131        );
132    }
133
134    #[test]
135    fn says_what_is_wrong() {
136        let error = |kind, spec| parse(kind, spec).unwrap_err();
137        assert!(error(Kind::Noul, "Is it urgent?").contains("expected ID=INSTRUCTIONS"));
138        assert!(error(Kind::Noul, "is urgent=Is it?").contains("one word"));
139        assert!(error(Kind::Noul, "a=").contains("no instructions"));
140        assert!(error(Kind::Noul, "a=Is it?|yes").contains("two"));
141        assert!(error(Kind::Choice, "a=Which?|only").contains("at least two options"));
142        assert!(error(Kind::Choice, "a=Which?|x|x:again").contains("twice"));
143        assert!(error(Kind::Choice, "a=Which?|x|:no name").contains("no name"));
144        assert!(error(Kind::Score, "a=How much?|low").contains("two to ten"));
145        assert!(error(Kind::Score, "a=How much?|low||high").contains("empty criterion"));
146    }
147}
148
149/// A `-q` file's questions, in the order the file has them. The text is the file's; where it came
150/// from — the terminal's disk or the shell's files — is the caller's business.
151pub fn questions_file(text: &str) -> Result<Vec<(String, Question)>, String> {
152    let Ordered(questions) =
153        serde_json::from_str(text).map_err(|error| format!("expected a JSON object of question ids to questions: {error}"))?;
154    // The limits `Client::request` checks, so that a file's question that can't be answered fails
155    // here, before a call, as a flag's does, and `--dry-run` finds it too.
156    for (id, question) in &questions {
157        question.check().map_err(|why| format!("question `{id}`: {why}"))?;
158    }
159    Ok(questions)
160}
161
162/// A JSON object's entries in the order it has them. Read straight into `Question`s rather than
163/// through a `serde_json::Value`, whose objects sort their keys, so each Choice keeps its options'
164/// order too.
165struct Ordered(Vec<(String, Question)>);
166
167impl<'de> Deserialize<'de> for Ordered {
168    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Ordered, D::Error> {
169        struct Visitor;
170        impl<'de> serde::de::Visitor<'de> for Visitor {
171            type Value = Ordered;
172
173            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
174                f.write_str("an object of question ids to questions")
175            }
176
177            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Ordered, A::Error> {
178                let mut questions = Vec::new();
179                while let Some(id) = map.next_key::<String>()? {
180                    let question = map.next_value().map_err(|e| A::Error::custom(format!("question `{id}`: {e}")))?;
181                    questions.push((id, question));
182                }
183                Ok(Ordered(questions))
184            }
185        }
186        deserializer.deserialize_map(Visitor)
187    }
188}
189
190#[cfg(test)]
191mod file_tests {
192    use serde_json::json;
193
194    use super::questions_file;
195    use serde_json::Value;
196
197    use crate::Question;
198
199    #[test]
200    fn reads_a_questions_file_in_order() {
201        let questions = questions_file(
202            r#"{
203                "zeta": {"type": "noul", "instructions": "Is it?", "criteria": {"true": {"what": "yes"}, "false": "no"}},
204                "alpha": {"type": "choice", "instructions": "Which?", "criteria": {"b": null, "a": "first"}}
205            }"#,
206        )
207        .unwrap();
208        assert_eq!(questions[0].0, "zeta");
209        assert_eq!(questions[0].1, Question::noul_with_criteria("Is it?", json!({"what": "yes"}), "no"));
210        assert_eq!(questions[1].1, Question::choice("Which?", [("b", Value::Null), ("a", Value::from("first"))]));
211    }
212
213    #[test]
214    fn says_what_is_wrong_with_a_questions_file() {
215        assert!(questions_file("[]").unwrap_err().contains("expected a JSON object"));
216        let error = questions_file(r#"{"q": {"type": "ranking", "instructions": "?"}}"#).unwrap_err();
217        assert!(error.contains("question `q`"), "{error}");
218        // What the endpoint can't answer is refused here, as a flag's question is.
219        let empty = questions_file(r#"{"s": {"type": "score", "instructions": "?", "criteria": []}}"#).unwrap_err();
220        assert!(empty.contains("question `s`: a score needs levels"), "{empty}");
221        let eleven =
222            questions_file(&format!(r#"{{"s": {{"type": "score", "instructions": "?", "criteria": {:?}}}}}"#, ["l"; 11])).unwrap_err();
223        assert!(eleven.contains("up to 10 levels"), "{eleven}");
224        let twice = questions_file(r#"{"c": {"type": "choice", "instructions": "?", "criteria": {"a": "", "a": "again"}}}"#).unwrap_err();
225        assert!(twice.contains("option `a` is there twice"), "{twice}");
226    }
227}