1use 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 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 #[serde(default)]
51 pub options: String,
52 #[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 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 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
107pub 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#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ParsedLevels {
122 Count(u32),
123 Labels(Vec<String>),
124}
125
126pub fn parse_levels_raw(raw: &str) -> ParsedLevels {
128 let trimmed = raw.trim();
129
130 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 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 #[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 #[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 #[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 #[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}