Skip to main content

cerno_tui/
draft.rs

1//! A question as it is being typed, before it becomes a request.
2//!
3//! A draft keeps the text of *every* field, not only the ones its current kind uses. Switching a
4//! question from choice to score and back has to leave the options you typed where they were —
5//! losing them because a radio button moved is the kind of small betrayal that makes a form
6//! annoying to use.
7
8use cerno_sdk::{Levels, SystemOne};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Kind {
14    Noul,
15    Choice,
16    Score,
17}
18
19impl Kind {
20    pub const ALL: [Kind; 3] = [Kind::Noul, Kind::Choice, Kind::Score];
21
22    pub fn label(self) -> &'static str {
23        match self {
24            Kind::Noul => "noul",
25            Kind::Choice => "choice",
26            Kind::Score => "score",
27        }
28    }
29
30    /// The next kind, wrapping. Used by the left/right keys in the editor.
31    pub fn next(self) -> Self {
32        match self {
33            Kind::Noul => Kind::Choice,
34            Kind::Choice => Kind::Score,
35            Kind::Score => Kind::Noul,
36        }
37    }
38
39    pub fn previous(self) -> Self {
40        self.next().next()
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct QuestionDraft {
46    pub id: String,
47    pub kind: Kind,
48    pub question: String,
49    /// Raw text of the options field, comma separated.
50    #[serde(default)]
51    pub options: String,
52    /// Raw text of the levels field: a count, or comma-separated level texts.
53    #[serde(default)]
54    pub levels: String,
55}
56
57impl Default for QuestionDraft {
58    fn default() -> Self {
59        Self {
60            id: String::new(),
61            kind: Kind::Noul,
62            question: String::new(),
63            options: String::new(),
64            levels: "5".to_string(),
65        }
66    }
67}
68
69impl QuestionDraft {
70    /// Add this draft to a request being built.
71    ///
72    /// Nothing is validated here. The service decides what it can answer, and its refusals are
73    /// already written to be acted on; a second copy of those rules in the client would be a
74    /// copy that eventually disagrees.
75    pub fn apply<'a>(&self, builder: SystemOne<'a>) -> SystemOne<'a> {
76        match self.kind {
77            Kind::Noul => builder.noul(&self.id, &self.question),
78
79            Kind::Choice => {
80                let options = split_list(&self.options);
81                if self.question.trim().is_empty() {
82                    builder.choice_of(&self.id, options)
83                } else {
84                    builder.choice(&self.id, self.question.trim(), options)
85                }
86            }
87
88            Kind::Score => {
89                builder.score(&self.id, self.question.trim(), parse_levels(&self.levels))
90            }
91        }
92    }
93
94    /// One line describing the draft for the question list.
95    pub fn summary(&self) -> String {
96        match self.kind {
97            Kind::Noul => self.question.trim().to_string(),
98            Kind::Choice => split_list(&self.options).join(" | "),
99            Kind::Score => match parse_levels_raw(&self.levels) {
100                ParsedLevels::Count(n) => format!("{n} levels"),
101                ParsedLevels::Labels(labels) => labels.join(" | "),
102            },
103        }
104    }
105}
106
107/// Split a comma-separated field into trimmed, non-empty entries.
108///
109/// Empty entries are dropped rather than preserved: a trailing comma is how a list looks
110/// mid-typing, and it should not turn into an empty option the service then rejects.
111pub fn split_list(raw: &str) -> Vec<String> {
112    raw.split(',')
113        .map(str::trim)
114        .filter(|entry| !entry.is_empty())
115        .map(str::to_string)
116        .collect()
117}
118
119/// What the levels field means, before it becomes an SDK type.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ParsedLevels {
122    Count(u32),
123    Labels(Vec<String>),
124}
125
126/// Read the levels field: a bare number is a count, anything else is a list of level texts.
127pub fn parse_levels_raw(raw: &str) -> ParsedLevels {
128    let trimmed = raw.trim();
129
130    // Wider than any rubric, so "300" stays a count the service refuses by its bounds instead of
131    // turning into a one-level list of the text "300".
132    if let Ok(count) = trimmed.parse::<u32>() {
133        return ParsedLevels::Count(count);
134    }
135
136    let labels = split_list(raw);
137    if labels.is_empty() {
138        // An empty field is still a score question; let the service say what is wrong with it
139        // rather than inventing a rubric here.
140        return ParsedLevels::Count(0);
141    }
142    ParsedLevels::Labels(labels)
143}
144
145pub fn parse_levels(raw: &str) -> Levels {
146    match parse_levels_raw(raw) {
147        ParsedLevels::Count(n) => Levels::count(n),
148        ParsedLevels::Labels(labels) => Levels::labels(labels),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn a_list_field_is_split_and_trimmed() {
158        assert_eq!(split_list("IT, Facility, HR"), vec!["IT", "Facility", "HR"]);
159        assert_eq!(split_list("IT,Facility"), vec!["IT", "Facility"]);
160        assert_eq!(split_list("  spaced  ,  out  "), vec!["spaced", "out"]);
161    }
162
163    /// A trailing comma is what a list looks like mid-typing.
164    #[test]
165    fn empty_entries_are_dropped_rather_than_sent() {
166        assert_eq!(split_list("IT, Facility,"), vec!["IT", "Facility"]);
167        assert_eq!(split_list("IT,,Facility"), vec!["IT", "Facility"]);
168        assert!(split_list("").is_empty());
169        assert!(split_list("   ,  , ").is_empty());
170    }
171
172    #[test]
173    fn a_bare_number_is_a_level_count() {
174        assert_eq!(parse_levels_raw("5"), ParsedLevels::Count(5));
175        assert_eq!(parse_levels_raw("  10 "), ParsedLevels::Count(10));
176    }
177
178    #[test]
179    fn anything_else_is_a_list_of_level_texts() {
180        assert_eq!(
181            parse_levels_raw("gering, mittel, hoch"),
182            ParsedLevels::Labels(vec!["gering".into(), "mittel".into(), "hoch".into()])
183        );
184    }
185
186    #[test]
187    fn a_number_too_large_for_a_rubric_is_still_a_count() {
188        assert_eq!(parse_levels_raw("300"), ParsedLevels::Count(300));
189    }
190
191    /// "3 stars" is not the number 3, and guessing it is would silently drop the word.
192    #[test]
193    fn a_number_with_text_is_a_label_not_a_count() {
194        assert_eq!(
195            parse_levels_raw("3 stars"),
196            ParsedLevels::Labels(vec!["3 stars".into()])
197        );
198    }
199
200    /// An empty rubric is the service's business to reject, not something to paper over.
201    #[test]
202    fn an_empty_levels_field_becomes_a_count_the_service_will_refuse() {
203        assert_eq!(parse_levels_raw(""), ParsedLevels::Count(0));
204        assert_eq!(parse_levels_raw("  ,  "), ParsedLevels::Count(0));
205    }
206
207    /// Switching kinds must not discard what was typed under the other kind.
208    #[test]
209    fn a_draft_keeps_every_field_across_a_kind_change() {
210        let mut draft = QuestionDraft {
211            id: "q".into(),
212            kind: Kind::Choice,
213            question: "Which team?".into(),
214            options: "IT, Facility".into(),
215            levels: "gering, hoch".into(),
216        };
217
218        draft.kind = Kind::Score;
219        assert_eq!(draft.options, "IT, Facility");
220
221        draft.kind = Kind::Choice;
222        assert_eq!(draft.levels, "gering, hoch");
223        assert_eq!(draft.summary(), "IT | Facility");
224    }
225
226    #[test]
227    fn kinds_cycle_in_both_directions() {
228        assert_eq!(Kind::Noul.next(), Kind::Choice);
229        assert_eq!(Kind::Score.next(), Kind::Noul);
230        assert_eq!(Kind::Noul.previous(), Kind::Score);
231
232        for kind in Kind::ALL {
233            assert_eq!(kind.next().previous(), kind);
234        }
235    }
236
237    #[test]
238    fn summaries_describe_each_kind() {
239        let noul = QuestionDraft {
240            id: "u".into(),
241            question: "Is this urgent?".into(),
242            ..Default::default()
243        };
244        assert_eq!(noul.summary(), "Is this urgent?");
245
246        let score = QuestionDraft {
247            kind: Kind::Score,
248            levels: "5".into(),
249            ..Default::default()
250        };
251        assert_eq!(score.summary(), "5 levels");
252    }
253}