Skip to main content

jev_repl/
builder.rs

1//! Builder mode: compose a question in a form instead of a one-line command, with the JSON it
2//! will send rendered beside it as you type.
3
4use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use serde_json::{Map, Value};
6use typesafe::{Choice, Noul, Question, Score};
7
8use crate::session::value;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Kind {
12    Noul,
13    Choice,
14    Score,
15}
16
17impl Kind {
18    pub fn label(self) -> &'static str {
19        match self {
20            Kind::Noul => "noul",
21            Kind::Choice => "choice",
22            Kind::Score => "score",
23        }
24    }
25
26    pub fn about(self) -> &'static str {
27        match self {
28            Kind::Noul => "probability that the statement is true (0–1)",
29            Kind::Choice => "one label out of the set you define",
30            Kind::Score => "a weighted position along ordered levels",
31        }
32    }
33
34    fn next(self) -> Self {
35        match self {
36            Kind::Noul => Kind::Choice,
37            Kind::Choice => Kind::Score,
38            Kind::Score => Kind::Noul,
39        }
40    }
41
42    fn prev(self) -> Self {
43        self.next().next()
44    }
45}
46
47/// Which widget the keyboard is on.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Field {
50    State,
51    Name,
52    Kind,
53    Instructions,
54    /// A noul's `yes:` / `no:` criteria.
55    Yes,
56    No,
57    /// A choice option: row index, and whether the cursor is in the label or the description.
58    OptionLabel(usize),
59    OptionDesc(usize),
60    /// A score level.
61    Level(usize),
62}
63
64/// What the app should do after a key press.
65pub enum Outcome {
66    /// Stay open.
67    Open,
68    /// Close without adding anything.
69    Cancel,
70    /// Add this question (and any edited state) to the session.
71    Commit(String, Box<Question>, String),
72}
73
74pub struct Builder {
75    pub state: String,
76    pub name: String,
77    pub kind: Kind,
78    pub instructions: String,
79    pub yes: String,
80    pub no: String,
81    pub options: Vec<(String, String)>,
82    pub levels: Vec<String>,
83    pub focus: usize,
84    pub cursor: usize,
85    pub message: Option<String>,
86}
87
88impl Builder {
89    /// Opens on the first field that still needs an answer: the state if there is none, the name
90    /// if there is no name, otherwise the type.
91    pub fn new(state: String, name: &str) -> Self {
92        let focus = if !name.is_empty() {
93            2
94        } else if state.trim().is_empty() {
95            0
96        } else {
97            1
98        };
99        Self {
100            state: state.clone(),
101            name: name.to_owned(),
102            kind: Kind::Noul,
103            instructions: String::new(),
104            yes: String::new(),
105            no: String::new(),
106            options: vec![
107                (String::new(), String::new()),
108                (String::new(), String::new()),
109            ],
110            levels: vec![String::new(), String::new(), String::new()],
111            focus,
112            cursor: if focus == 0 {
113                state.chars().count()
114            } else {
115                name.chars().count()
116            },
117            message: None,
118        }
119    }
120
121    /// The focusable fields, in tab order, for the current question type.
122    pub fn fields(&self) -> Vec<Field> {
123        let mut f = vec![Field::State, Field::Name, Field::Kind, Field::Instructions];
124        match self.kind {
125            Kind::Noul => f.extend([Field::Yes, Field::No]),
126            Kind::Choice => {
127                for i in 0..self.options.len() {
128                    f.push(Field::OptionLabel(i));
129                    f.push(Field::OptionDesc(i));
130                }
131            }
132            Kind::Score => f.extend((0..self.levels.len()).map(Field::Level)),
133        }
134        f
135    }
136
137    pub fn focused(&self) -> Field {
138        let fields = self.fields();
139        fields[self.focus.min(fields.len() - 1)]
140    }
141
142    pub fn text(&self, field: Field) -> &str {
143        match field {
144            Field::State => &self.state,
145            Field::Name => &self.name,
146            Field::Instructions => &self.instructions,
147            Field::Yes => &self.yes,
148            Field::No => &self.no,
149            Field::OptionLabel(i) => self.options.get(i).map(|o| o.0.as_str()).unwrap_or(""),
150            Field::OptionDesc(i) => self.options.get(i).map(|o| o.1.as_str()).unwrap_or(""),
151            Field::Level(i) => self.levels.get(i).map(String::as_str).unwrap_or(""),
152            Field::Kind => self.kind.label(),
153        }
154    }
155
156    fn text_mut(&mut self, field: Field) -> Option<&mut String> {
157        match field {
158            Field::State => Some(&mut self.state),
159            Field::Name => Some(&mut self.name),
160            Field::Instructions => Some(&mut self.instructions),
161            Field::Yes => Some(&mut self.yes),
162            Field::No => Some(&mut self.no),
163            Field::OptionLabel(i) => self.options.get_mut(i).map(|o| &mut o.0),
164            Field::OptionDesc(i) => self.options.get_mut(i).map(|o| &mut o.1),
165            Field::Level(i) => self.levels.get_mut(i),
166            Field::Kind => None,
167        }
168    }
169
170    pub fn key(&mut self, key: KeyEvent) -> Outcome {
171        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
172        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
173        self.message = None;
174        match key.code {
175            KeyCode::Esc => return Outcome::Cancel,
176            KeyCode::Char('s') if ctrl => return self.commit(),
177            KeyCode::Char('x') if ctrl => self.delete_row(),
178            KeyCode::Char('o') if ctrl => self.add_row(),
179            KeyCode::Tab => self.move_focus(1),
180            KeyCode::BackTab => self.move_focus(-1),
181            KeyCode::Down => self.move_focus(1),
182            KeyCode::Up => self.move_focus(-1),
183            KeyCode::Enter => {
184                if self.on_last_row() {
185                    self.add_row();
186                }
187                self.move_focus(1);
188            }
189            KeyCode::Left if self.focused() == Field::Kind => self.set_kind(self.kind.prev()),
190            KeyCode::Right if self.focused() == Field::Kind => self.set_kind(self.kind.next()),
191            KeyCode::Char(' ') if self.focused() == Field::Kind => self.set_kind(self.kind.next()),
192            KeyCode::Char(c) if self.focused() == Field::Kind => match c {
193                'n' | 'N' => self.set_kind(Kind::Noul),
194                'c' | 'C' => self.set_kind(Kind::Choice),
195                's' | 'S' => self.set_kind(Kind::Score),
196                _ => {}
197            },
198            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
199            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
200            KeyCode::Home => self.cursor = 0,
201            KeyCode::End => self.cursor = self.len(),
202            KeyCode::Backspace => {
203                if self.cursor > 0 {
204                    let at = self.cursor - 1;
205                    self.cursor = at;
206                    self.remove(at);
207                }
208            }
209            KeyCode::Delete => {
210                let at = self.cursor;
211                self.remove(at);
212            }
213            KeyCode::Char(c) => {
214                let _ = shift;
215                self.insert(c);
216            }
217            _ => {}
218        }
219        Outcome::Open
220    }
221
222    fn set_kind(&mut self, kind: Kind) {
223        self.kind = kind;
224        self.focus = self.focus.min(self.fields().len() - 1);
225    }
226
227    fn move_focus(&mut self, delta: isize) {
228        let len = self.fields().len() as isize;
229        let next = (self.focus as isize + delta).rem_euclid(len) as usize;
230        self.focus = next;
231        self.cursor = self.len();
232    }
233
234    fn len(&self) -> usize {
235        self.text(self.focused()).chars().count()
236    }
237
238    fn insert(&mut self, c: char) {
239        let cursor = self.cursor;
240        let field = self.focused();
241        if let Some(s) = self.text_mut(field) {
242            let at = byte_at(s, cursor);
243            s.insert(at, c);
244            self.cursor = cursor + 1;
245        }
246    }
247
248    fn remove(&mut self, index: usize) {
249        let field = self.focused();
250        if let Some(s) = self.text_mut(field)
251            && index < s.chars().count()
252        {
253            let at = byte_at(s, index);
254            s.remove(at);
255        }
256    }
257
258    fn on_last_row(&self) -> bool {
259        match self.focused() {
260            Field::OptionDesc(i) => i + 1 == self.options.len(),
261            Field::Level(i) => i + 1 == self.levels.len(),
262            _ => false,
263        }
264    }
265
266    /// Ctrl-O, or Enter on the last row: one more option or level.
267    pub fn add_row(&mut self) {
268        match self.kind {
269            Kind::Choice => self.options.push((String::new(), String::new())),
270            Kind::Score => self.levels.push(String::new()),
271            Kind::Noul => self.message = Some("A noul has only `yes` and `no`.".into()),
272        }
273    }
274
275    /// Ctrl-X: drop the row the cursor is on.
276    pub fn delete_row(&mut self) {
277        match self.focused() {
278            Field::OptionLabel(i) | Field::OptionDesc(i) if self.options.len() > 1 => {
279                self.options.remove(i);
280            }
281            Field::Level(i) if self.levels.len() > 1 => {
282                self.levels.remove(i);
283            }
284            _ => self.message = Some("Nothing to remove here.".into()),
285        }
286        self.focus = self.focus.min(self.fields().len() - 1);
287        self.cursor = self.len();
288    }
289
290    /// The question as it would go on the wire right now, incomplete parts included.
291    pub fn preview(&self) -> Value {
292        let mut q = Map::new();
293        q.insert("type".into(), Value::String(self.kind.label().into()));
294        if !self.instructions.trim().is_empty() {
295            q.insert("instructions".into(), value(&self.instructions));
296        }
297        match self.kind {
298            Kind::Noul => {
299                let mut criteria = Map::new();
300                if !self.yes.trim().is_empty() {
301                    criteria.insert("true".into(), value(&self.yes));
302                }
303                if !self.no.trim().is_empty() {
304                    criteria.insert("false".into(), value(&self.no));
305                }
306                if !criteria.is_empty() {
307                    q.insert("criteria".into(), Value::Object(criteria));
308                }
309            }
310            Kind::Choice => {
311                let criteria: Map<String, Value> = self
312                    .options
313                    .iter()
314                    .filter(|(label, _)| !label.trim().is_empty())
315                    .map(|(label, desc)| {
316                        let desc = if desc.trim().is_empty() {
317                            Value::Null
318                        } else {
319                            value(desc)
320                        };
321                        (label.trim().to_owned(), desc)
322                    })
323                    .collect();
324                q.insert("criteria".into(), Value::Object(criteria));
325            }
326            Kind::Score => {
327                let criteria: Vec<Value> = self
328                    .levels
329                    .iter()
330                    .filter(|l| !l.trim().is_empty())
331                    .map(|l| value(l))
332                    .collect();
333                q.insert("criteria".into(), Value::Array(criteria));
334            }
335        }
336        let name = if self.name.trim().is_empty() {
337            "<name>"
338        } else {
339            self.name.trim()
340        };
341        serde_json::json!({ name: Value::Object(q) })
342    }
343
344    /// The equivalent one-line command, so builder mode teaches the fast path.
345    pub fn as_command(&self) -> String {
346        let name = if self.name.trim().is_empty() {
347            "<name>"
348        } else {
349            self.name.trim()
350        };
351        let instructions = self.instructions.trim();
352        match self.kind {
353            Kind::Noul => {
354                let mut s = format!(":noul {name} {instructions}");
355                if !self.yes.trim().is_empty() {
356                    s.push_str(&format!(" | yes: {}", self.yes.trim()));
357                }
358                if !self.no.trim().is_empty() {
359                    s.push_str(&format!(" | no: {}", self.no.trim()));
360                }
361                s
362            }
363            Kind::Choice => {
364                let mut s = format!(":choice {name} {instructions}");
365                for (label, desc) in self.options.iter().filter(|(l, _)| !l.trim().is_empty()) {
366                    s.push_str(&format!(" | {}", label.trim()));
367                    if !desc.trim().is_empty() {
368                        s.push_str(&format!("={}", desc.trim()));
369                    }
370                }
371                s
372            }
373            Kind::Score => {
374                let mut s = format!(":score {name} {instructions}");
375                for level in self.levels.iter().filter(|l| !l.trim().is_empty()) {
376                    s.push_str(&format!(" | {}", level.trim()));
377                }
378                s
379            }
380        }
381    }
382
383    fn commit(&mut self) -> Outcome {
384        let name = self.name.trim().to_owned();
385        if name.is_empty() {
386            self.message = Some("Every question needs a name — answers come back under it.".into());
387            return Outcome::Open;
388        }
389        if name.split_whitespace().count() > 1 {
390            self.message = Some("Names cannot contain spaces.".into());
391            return Outcome::Open;
392        }
393        let instructions = self.instructions.trim();
394        if instructions.is_empty() {
395            self.message = Some("Instructions are what the model actually reads.".into());
396            return Outcome::Open;
397        }
398        let question: Question = match self.kind {
399            Kind::Noul => {
400                let mut q = Noul::new(value(instructions));
401                if !self.yes.trim().is_empty() {
402                    q = q.when_true(value(&self.yes));
403                }
404                if !self.no.trim().is_empty() {
405                    q = q.when_false(value(&self.no));
406                }
407                q.into()
408            }
409            Kind::Choice => {
410                let options: Vec<(String, String)> = self
411                    .options
412                    .iter()
413                    .filter(|(l, _)| !l.trim().is_empty())
414                    .cloned()
415                    .collect();
416                if options.len() < 2 {
417                    self.message = Some("A choice needs at least two options.".into());
418                    return Outcome::Open;
419                }
420                let mut q = Choice::new(value(instructions));
421                for (label, desc) in options {
422                    q = if desc.trim().is_empty() {
423                        q.label(label.trim())
424                    } else {
425                        q.option(label.trim(), value(&desc))
426                    };
427                }
428                q.into()
429            }
430            Kind::Score => {
431                let levels: Vec<Value> = self
432                    .levels
433                    .iter()
434                    .filter(|l| !l.trim().is_empty())
435                    .map(|l| value(l))
436                    .collect();
437                if levels.len() < 2 {
438                    self.message = Some("A score needs at least two ordered levels.".into());
439                    return Outcome::Open;
440                }
441                Score::new(value(instructions), levels).into()
442            }
443        };
444        Outcome::Commit(name, Box::new(question), self.state.clone())
445    }
446}
447
448fn byte_at(s: &str, char_index: usize) -> usize {
449    s.char_indices()
450        .nth(char_index)
451        .map(|(i, _)| i)
452        .unwrap_or(s.len())
453}