Skip to main content

artifactize_core/review/
form.rs

1//! Owner fields for one verdict: a form for flat schemas, JSON from `$EDITOR` otherwise.
2
3use serde_json::{Map, Value, json};
4
5/// Annotations allowed on any schema the form accepts.
6const NOTES: [&str; 3] = ["title", "description", "$comment"];
7const NUMERIC: [&str; 6] = [
8    "type",
9    "minimum",
10    "maximum",
11    "exclusiveMinimum",
12    "exclusiveMaximum",
13    "multipleOf",
14];
15
16/// A key as the form edits with it; each interface maps its own key events onto these.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Key {
19    Char(char),
20    Backspace,
21    Delete,
22    Left,
23    Right,
24    Up,
25    Down,
26    Home,
27    End,
28    Enter,
29    Tab,
30    BackTab,
31    /// A key the form does not use.
32    Other,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub enum Input {
37    /// A `const` value: prefilled and fixed.
38    Fixed(Value),
39    /// `None` until chosen.
40    Bool(Option<bool>),
41    /// String `enum` options and the chosen index.
42    Choice(Vec<String>, Option<usize>),
43    Text(String),
44    Integer(String),
45    Number(String),
46}
47
48#[derive(Debug, Clone, PartialEq)]
49pub struct Field {
50    pub name: String,
51    pub required: bool,
52    /// The type and constraints, then any description.
53    pub hint: String,
54    pub input: Input,
55}
56
57impl Field {
58    /// The JSON value to submit; `None` leaves an empty optional field out.
59    fn value(&self) -> Result<Option<Value>, String> {
60        let number = |text: &str, integer: bool| {
61            let value: Value = serde_json::from_str(text.trim()).unwrap_or_default();
62            let valid = if integer {
63                value.is_i64() || value.is_u64()
64            } else {
65                value.is_number()
66            };
67            let kind = if integer { "an integer" } else { "a number" };
68            valid
69                .then_some(value)
70                .ok_or_else(|| format!("{}: enter {kind}.", self.name))
71        };
72        Ok(match &self.input {
73            Input::Fixed(value) => Some(value.clone()),
74            Input::Bool(value) => value.map(Value::Bool),
75            Input::Choice(options, index) => {
76                index.map(|index| Value::String(options[index].clone()))
77            }
78            Input::Text(text) => (self.required || !text.is_empty()).then(|| json!(text)),
79            Input::Integer(text) | Input::Number(text) if text.trim().is_empty() => None,
80            Input::Integer(text) => Some(number(text, true)?),
81            Input::Number(text) => Some(number(text, false)?),
82        })
83    }
84
85    /// What the form shows for the current value.
86    pub fn display(&self) -> String {
87        match &self.input {
88            Input::Fixed(value) => format!("{value} (fixed)"),
89            Input::Bool(None) | Input::Choice(_, None) => "–".into(),
90            Input::Bool(Some(value)) => value.to_string(),
91            Input::Choice(options, Some(index)) => {
92                Value::String(options[*index].clone()).to_string()
93            }
94            Input::Text(text) | Input::Integer(text) | Input::Number(text) => text.clone(),
95        }
96    }
97
98    fn key(&mut self, key: Key) {
99        let required = self.required;
100        match (&mut self.input, key) {
101            (Input::Text(text) | Input::Integer(text) | Input::Number(text), Key::Char(c)) => {
102                text.push(c)
103            }
104            (Input::Text(text) | Input::Integer(text) | Input::Number(text), Key::Backspace) => {
105                text.pop();
106            }
107            (Input::Bool(value), Key::Char('t' | 'y')) => *value = Some(true),
108            (Input::Bool(value), Key::Char('f' | 'n')) => *value = Some(false),
109            (Input::Bool(value), Key::Char(' ') | Key::Left | Key::Right) => {
110                *value = match value {
111                    None => Some(true),
112                    Some(true) => Some(false),
113                    Some(false) if required => Some(true),
114                    Some(false) => None,
115                }
116            }
117            (Input::Choice(options, index), Key::Char(' ') | Key::Right) => {
118                *index = match *index {
119                    None => Some(0),
120                    Some(i) if i + 1 < options.len() => Some(i + 1),
121                    Some(_) if required => Some(0),
122                    Some(_) => None,
123                }
124            }
125            (Input::Choice(options, index), Key::Left) => {
126                *index = match *index {
127                    Some(0) if !required => None,
128                    Some(0) | None => Some(options.len() - 1),
129                    Some(i) => Some(i - 1),
130                }
131            }
132            _ => {}
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq)]
138pub struct Form {
139    pub verdict: crate::runtime::Verdict,
140    pub fields: Vec<Field>,
141    /// Owner fields as JSON text from `$EDITOR`; once set it replaces `fields`.
142    pub json: Option<String>,
143    pub selected: usize,
144    /// UTF-8 byte boundary of the in-terminal JSON cursor; standalone editor is unchanged.
145    pub cursor: usize,
146    /// The last local or `human::submit` error.
147    pub error: Option<String>,
148}
149
150impl Form {
151    /// A form for a flat owner schema (or none); any other schema starts from a JSON template.
152    pub fn new(verdict: crate::runtime::Verdict, schema: Option<&Value>) -> Self {
153        let fields = schema.map_or(Some(Vec::new()), fields);
154        let json = fields.is_none().then(|| {
155            let template = schema.map_or(json!({}), template);
156            serde_json::to_string_pretty(&template).expect("template is JSON")
157        });
158        Self {
159            verdict,
160            fields: fields.unwrap_or_default(),
161            cursor: json.as_ref().map_or(0, String::len),
162            json,
163            selected: 0,
164            error: None,
165        }
166    }
167
168    /// JSON to open in `$EDITOR`: the edited text, or the current form values.
169    pub fn draft(&self) -> String {
170        if let Some(json) = &self.json {
171            return json.clone();
172        }
173        let fields = self.fields.iter().map(|field| {
174            let value = match (&field.input, field.value()) {
175                (Input::Text(text), _) => json!(text),
176                (_, Ok(Some(value))) => value,
177                _ => Value::Null,
178            };
179            (field.name.clone(), value)
180        });
181        serde_json::to_string_pretty(&Value::Object(fields.collect())).expect("draft is JSON")
182    }
183
184    /// The complete result: owner fields plus the chosen verdict.
185    pub fn result(&self) -> Result<Value, String> {
186        let mut object = match &self.json {
187            Some(text) => match serde_json::from_str(text) {
188                Ok(Value::Object(object)) => object,
189                Ok(_) => return Err("Owner fields must be a JSON object.".into()),
190                Err(error) => return Err(format!("Owner fields are not valid JSON: {error}")),
191            },
192            None => {
193                let mut object = Map::new();
194                for field in &self.fields {
195                    if let Some(value) = field.value()? {
196                        object.insert(field.name.clone(), value);
197                    }
198                }
199                object
200            }
201        };
202        if object.contains_key("verdict") {
203            return Err("Choose the verdict in the review, not inside the owner fields.".into());
204        }
205        object.insert("verdict".into(), json!(self.verdict));
206        Ok(Value::Object(object))
207    }
208
209    /// Replace the JSON text (from `$EDITOR` or a flat form's draft); the cursor moves to its end.
210    pub fn set_json(&mut self, text: String) {
211        self.cursor = text.len();
212        self.json = Some(text);
213    }
214
215    /// Bounded paste follows the existing Human result limit, without splitting UTF-8.
216    pub fn paste(&mut self, text: &str) {
217        if let Some(json) = &mut self.json {
218            self.cursor = boundary(json, self.cursor);
219            if json.len().saturating_add(text.len()) <= crate::human::MAX_RESULT_BYTES {
220                json.insert_str(self.cursor, text);
221                self.cursor += text.len();
222            } else {
223                self.error = Some("Human fields exceed 256000 bytes.".into());
224            }
225        } else if let Some(Field {
226            input: Input::Text(value) | Input::Integer(value) | Input::Number(value),
227            ..
228        }) = self.fields.get_mut(self.selected)
229        {
230            if value.len().saturating_add(text.len()) <= crate::human::MAX_RESULT_BYTES {
231                value.push_str(text);
232            } else {
233                self.error = Some("Human fields exceed 256000 bytes.".into());
234            }
235        }
236    }
237
238    /// Multiline editing stays inside monitor. Enter inserts a newline, never submits.
239    pub fn inline_key(&mut self, key: Key, modified: bool) {
240        if self.json.is_none() {
241            let text_field = self.fields.get(self.selected).is_some_and(|field| {
242                matches!(
243                    field.input,
244                    Input::Text(_) | Input::Integer(_) | Input::Number(_)
245                )
246            });
247            match key {
248                Key::Char(character) if text_field && !modified => {
249                    self.paste(&character.to_string())
250                }
251                _ if !modified => self.key(key),
252                _ => {}
253            }
254            return;
255        }
256        let text = self.json.as_ref().expect("JSON mode");
257        self.cursor = boundary(text, self.cursor);
258        match key {
259            Key::Char(character) if !modified => self.paste(&character.to_string()),
260            Key::Enter => self.paste("\n"),
261            Key::Tab => self.paste("  "),
262            Key::Left | Key::Backspace if self.cursor > 0 => {
263                let previous = text[..self.cursor]
264                    .char_indices()
265                    .last()
266                    .map_or(0, |(index, _)| index);
267                if key == Key::Backspace {
268                    self.json
269                        .as_mut()
270                        .expect("JSON mode")
271                        .drain(previous..self.cursor);
272                }
273                self.cursor = previous;
274            }
275            Key::Right | Key::Delete if self.cursor < text.len() => {
276                let next = self.cursor
277                    + text[self.cursor..]
278                        .chars()
279                        .next()
280                        .expect("not at end")
281                        .len_utf8();
282                if key == Key::Delete {
283                    self.json
284                        .as_mut()
285                        .expect("JSON mode")
286                        .drain(self.cursor..next);
287                } else {
288                    self.cursor = next;
289                }
290            }
291            Key::Home => self.cursor = text[..self.cursor].rfind('\n').map_or(0, |index| index + 1),
292            Key::End => {
293                self.cursor += text[self.cursor..]
294                    .find('\n')
295                    .unwrap_or(text.len() - self.cursor)
296            }
297            Key::Up | Key::Down => {
298                let start = text[..self.cursor].rfind('\n').map_or(0, |index| index + 1);
299                let column = text[start..self.cursor].chars().count();
300                let destination = if key == Key::Up {
301                    start
302                        .checked_sub(1)
303                        .map(|end| (text[..end].rfind('\n').map_or(0, |index| index + 1), end))
304                } else {
305                    text[self.cursor..].find('\n').map(|offset| {
306                        let start = self.cursor + offset + 1;
307                        let end = start + text[start..].find('\n').unwrap_or(text.len() - start);
308                        (start, end)
309                    })
310                };
311                if let Some((start, end)) = destination {
312                    self.cursor = start
313                        + text[start..end]
314                            .char_indices()
315                            .nth(column)
316                            .map_or(end - start, |(index, _)| index);
317                }
318            }
319            _ => {}
320        }
321    }
322
323    pub fn key(&mut self, key: Key) {
324        let count = self.fields.len();
325        match key {
326            Key::Up | Key::BackTab if count > 0 => {
327                self.selected = (self.selected + count - 1) % count
328            }
329            Key::Down | Key::Tab if count > 0 => self.selected = (self.selected + 1) % count,
330            _ if self.json.is_none() => {
331                if let Some(field) = self.fields.get_mut(self.selected) {
332                    field.key(key);
333                }
334            }
335            _ => {}
336        }
337    }
338}
339
340/// Fields of a flat object schema, or `None` when a form cannot represent it.
341fn fields(schema: &Value) -> Option<Vec<Field>> {
342    let schema = schema.as_object()?;
343    let top = ["type", "properties", "required", "additionalProperties"];
344    if schema
345        .keys()
346        .any(|key| !top.contains(&key.as_str()) && !NOTES.contains(&key.as_str()))
347    {
348        return None;
349    }
350    let required: Vec<&str> = match schema.get("required") {
351        None => Vec::new(),
352        Some(names) => names
353            .as_array()?
354            .iter()
355            .map(Value::as_str)
356            .collect::<Option<_>>()?,
357    };
358    let empty = Map::new();
359    let properties = match schema.get("properties") {
360        None => &empty,
361        Some(properties) => properties.as_object()?,
362    };
363    properties
364        .iter()
365        .map(|(name, property)| {
366            let (input, hint) = input(property)?;
367            Some(Field {
368                name: name.clone(),
369                required: required.contains(&name.as_str()),
370                hint,
371                input,
372            })
373        })
374        .collect()
375}
376
377fn input(property: &Value) -> Option<(Input, String)> {
378    let property = property.as_object()?;
379    let only = |keys: &[&str]| {
380        property
381            .keys()
382            .all(|key| keys.contains(&key.as_str()) || NOTES.contains(&key.as_str()))
383    };
384    let kind = property.get("type").map(Value::as_str);
385    let input = if let Some(value) = property.get("const") {
386        only(&["const", "type"]).then(|| Input::Fixed(value.clone()))?
387    } else if let Some(options) = property.get("enum") {
388        let options = options.as_array()?;
389        let strings: Option<Vec<String>> = options
390            .iter()
391            .map(|option| option.as_str().map(str::to_owned))
392            .collect();
393        let strings = strings.filter(|strings| !strings.is_empty())?;
394        (kind.is_none_or(|kind| kind == Some("string")) && only(&["enum", "type"]))
395            .then_some(Input::Choice(strings, None))?
396    } else {
397        match kind?? {
398            "boolean" if only(&["type"]) => Input::Bool(None),
399            "string" if only(&["type", "minLength", "maxLength"]) => Input::Text(String::new()),
400            "integer" if only(&NUMERIC) => Input::Integer(String::new()),
401            "number" if only(&NUMERIC) => Input::Number(String::new()),
402            _ => return None,
403        }
404    };
405    let constraints = property
406        .iter()
407        .filter(|(key, _)| !["type", "const", "enum"].contains(&key.as_str()))
408        .filter(|(key, _)| !NOTES.contains(&key.as_str()))
409        .map(|(key, value)| format!("{key} {value}"));
410    let kind = kind.flatten().map(str::to_owned);
411    let mut hint = kind
412        .into_iter()
413        .chain(constraints)
414        .collect::<Vec<_>>()
415        .join(", ");
416    if let Some(description) = property.get("description").and_then(Value::as_str) {
417        hint = [hint.as_str(), description]
418            .into_iter()
419            .filter(|part| !part.is_empty())
420            .collect::<Vec<_>>()
421            .join(" — ");
422    }
423    Some((input, hint))
424}
425
426/// A JSON skeleton of a schema for `$EDITOR`: constants and first choices filled, the rest empty.
427/// `cursor` moved back onto a UTF-8 character boundary of `text`, at most its end.
428pub fn boundary(text: &str, cursor: usize) -> usize {
429    let mut cursor = cursor.min(text.len());
430    while !text.is_char_boundary(cursor) {
431        cursor -= 1;
432    }
433    cursor
434}
435
436pub fn template(schema: &Value) -> Value {
437    if let Some(value) = ["const", "default"].iter().find_map(|key| schema.get(*key)) {
438        return value.clone();
439    }
440    if let Some(value) = schema.get("enum").and_then(|options| options.get(0)) {
441        return value.clone();
442    }
443    let kind = match &schema["type"] {
444        Value::String(kind) => kind.as_str(),
445        Value::Array(kinds) => kinds.first().and_then(Value::as_str).unwrap_or_default(),
446        _ if schema.get("properties").is_some() => "object",
447        _ => "",
448    };
449    match kind {
450        "object" => Value::Object(
451            schema["properties"]
452                .as_object()
453                .into_iter()
454                .flatten()
455                .map(|(name, property)| (name.clone(), template(property)))
456                .collect(),
457        ),
458        "array" => json!([]),
459        "string" => json!(""),
460        "integer" | "number" => json!(0),
461        "boolean" => json!(false),
462        _ => Value::Null,
463    }
464}