use std::collections::{HashMap, HashSet};
use eframe::egui::text::LayoutJob;
use eframe::egui::{self, Color32, FontId, RichText, TextFormat};
use crate::hurl::{FormField, FormFieldKind, HurlEntry};
#[cfg(test)]
use crate::hurl::KvRow;
use crate::i18n::Strings;
use crate::request::{SubstInfo, SubstKind, apply_request_json, build_request_json};
use super::app::{EditorSection, GuiApp};
use super::theme::GuiTheme;
use super::widgets;
const SHADOW_ICON: &str = "!";
#[derive(Default)]
struct SubstSeen {
loaded: bool,
literal: bool,
pending: bool,
failed: bool,
shadowed: bool,
}
impl SubstSeen {
fn mark(&mut self, kind: SubstKind) {
match kind {
SubstKind::Loaded => self.loaded = true,
SubstKind::Literal => self.literal = true,
SubstKind::Pending => self.pending = true,
SubstKind::Failed => self.failed = true,
}
}
fn any(&self) -> bool {
self.loaded || self.literal || self.pending || self.failed
}
}
fn subst_color(kind: SubstKind, th: &GuiTheme) -> Color32 {
match kind {
SubstKind::Literal => th.subst,
SubstKind::Loaded => th.ok,
SubstKind::Pending => th.pending,
SubstKind::Failed => th.err,
}
}
fn subst_legend(ui: &mut egui::Ui, seen: &SubstSeen, th: &GuiTheme, s: &Strings) {
if !seen.any() {
return;
}
ui.horizontal_wrapped(|ui| {
for (present, word, color) in [
(seen.loaded, s.subst_hint_loaded, th.ok),
(seen.literal, s.subst_hint_literal, th.subst),
(seen.pending, s.subst_hint_loading, th.pending),
(seen.failed, s.subst_hint_missing, th.err),
] {
if present {
ui.colored_label(color, format!("\u{25cf} {word}"));
}
}
if seen.shadowed {
ui.colored_label(
th.pending,
format!("{SHADOW_ICON} {}", s.subst_hint_shadowed),
);
}
});
}
fn highlight_code_editable(
text: &str,
vars: &HashMap<String, SubstInfo>,
shadowed: &HashSet<String>,
th: &GuiTheme,
font: FontId,
seen: &mut SubstSeen,
) -> LayoutJob {
let fmt = |color: Color32| TextFormat::simple(font.clone(), color);
let mut job = LayoutJob::default();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let Some(close_rel) = rest[open + 2..].find("}}") else {
break;
};
let close = open + 2 + close_rel;
let end = close + 2;
let inner = rest[open + 2..close].trim();
if open > 0 {
job.append(&rest[..open], 0.0, fmt(th.text));
}
let token = &rest[open..end];
match vars.get(inner) {
Some(info) => {
seen.mark(info.kind);
if shadowed.contains(inner) {
seen.shadowed = true;
}
job.append(token, 0.0, fmt(subst_color(info.kind, th)));
}
None => job.append(token, 0.0, fmt(th.text)),
}
rest = &rest[end..];
}
if !rest.is_empty() {
job.append(rest, 0.0, fmt(th.text));
}
job
}
fn apply_code_edit(
session: &mut crate::session::Session,
code_edit: &mut super::app::CodeEdit,
strings: &Strings,
ci: usize,
sel: usize,
show_hurl: bool,
text: &str,
) -> bool {
if show_hurl {
let entries = crate::hurl::parse_hurl(text);
if entries.len() == 1 {
let mut parsed = entries.into_iter().next().unwrap();
let entry = &mut session.collections[ci].entries[sel];
parsed.user_added = entry.user_added;
*entry = parsed;
code_edit.error = None;
true
} else {
code_edit.error = Some(
crate::hurl::parse_hurl_error(text)
.unwrap_or_else(|| strings.gui_code_parse_error.to_string()),
);
false
}
} else {
let base = session.collections[ci].entries[sel].clone();
match apply_request_json(&base, text) {
Ok(parsed) => {
session.collections[ci].entries[sel] = parsed;
code_edit.error = None;
true
}
Err(e) => {
code_edit.error = Some(e);
false
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_code_section(
app: &mut GuiApp,
ui: &mut egui::Ui,
theme: &GuiTheme,
ci: usize,
sel: usize,
code_show_hurl: &mut bool,
subst_vars: &HashMap<String, SubstInfo>,
shadowed: &HashSet<String>,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
if widgets::selectable(ui, !*code_show_hurl, "JSON").clicked() {
*code_show_hurl = false;
app.code_edit.key = None;
}
if widgets::selectable(ui, *code_show_hurl, "Hurl").clicked() {
*code_show_hurl = true;
app.code_edit.key = None;
}
});
ui.add_space(4.0);
let key = (ci, sel, *code_show_hurl);
if app.code_edit.key != Some(key) {
let entry = &app.session.collections[ci].entries[sel];
app.code_edit.buf = if *code_show_hurl {
entry.to_hurl()
} else {
build_request_json(entry)
};
app.code_edit.key = Some(key);
app.code_edit.error = None;
}
let mut seen = SubstSeen::default();
let _ = highlight_code_editable(
&app.code_edit.buf,
subst_vars,
shadowed,
theme,
FontId::monospace(12.0),
&mut seen,
);
let row_h = ui.text_style_height(&egui::TextStyle::Monospace);
let reserved = 44.0
+ if app.code_edit.error.is_some() {
24.0
} else {
0.0
};
let editor_h = (ui.available_height() - reserved).max(row_h * 6.0);
let rows = (editor_h / row_h).floor().max(6.0) as usize;
let subst_vars_l = subst_vars;
let shadowed_l = shadowed;
let theme_l = theme;
let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap: f32| {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let mut s = SubstSeen::default();
let mut job = highlight_code_editable(
buf.as_str(),
subst_vars_l,
shadowed_l,
theme_l,
font,
&mut s,
);
job.wrap.max_width = wrap;
ui.fonts_mut(|f| f.layout_job(job))
};
let resp = egui::ScrollArea::vertical()
.max_height(editor_h)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut app.code_edit.buf)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(rows)
.layouter(&mut layouter),
)
});
if resp.inner.changed() {
let text = app.code_edit.buf.clone();
if apply_code_edit(
&mut app.session,
&mut app.code_edit,
&app.strings,
ci,
sel,
*code_show_hurl,
&text,
) {
changed = true;
}
}
ui.add_space(4.0);
if let Some(err) = &app.code_edit.error {
ui.colored_label(theme.err, format!("\u{26a0} {err}"));
}
subst_legend(ui, &seen, theme, &app.strings);
changed
}
pub fn ui(app: &mut GuiApp, ui: &mut egui::Ui) {
let ci = app.active_ci();
let theme = app.theme;
if app.session.collections[ci].entries.is_empty() {
let no_requests = app.strings.gui_no_requests_editor;
let new_request_btn = format!("{} {}", super::icons::PLUS, app.strings.gui_new_request_btn);
let new_request_title = app.strings.gui_new_request;
ui.vertical_centered(|ui| {
ui.add_space(30.0);
ui.colored_label(theme.dim, no_requests);
if ui.button(new_request_btn).clicked() {
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = app.session.vars.base_url.clone();
e.title = new_request_title.into();
e.user_added = true;
let col = &mut app.session.collections[ci];
col.entries.push(e);
col.selected_entry = 0;
col.invalidate_request_json();
}
});
return;
}
let sel = app.session.collections[ci]
.selected_entry
.min(app.session.collections[ci].entries.len() - 1);
let mut changed = false;
let mut send = false;
let section = app.editor_section;
let mut code_show_hurl = app.show_hurl;
let send_label = format!("{} {}", app.strings.gui_send, super::icons::PLAY);
{
let entry = &mut app.session.collections[ci].entries[sel];
let name_label = app.strings.gui_name;
ui.horizontal(|ui| {
ui.label(RichText::new(name_label).color(theme.dim));
let name = ui.add(
egui::TextEdit::singleline(&mut entry.title)
.desired_width(f32::INFINITY)
.hint_text(name_label),
);
if name.changed() {
changed = true;
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
if widgets::method_combo(ui, "method", &mut entry.method) {
changed = true;
}
let send_w = 92.0;
let url = ui.add_sized(
[ui.available_width() - send_w, 24.0],
egui::TextEdit::singleline(&mut entry.url)
.hint_text("https://api.example.com/path")
.font(egui::TextStyle::Monospace),
);
if url.changed() {
changed = true;
}
let btn = ui.add_sized(
[80.0, 24.0],
egui::Button::new(RichText::new(send_label).strong().color(theme.select_fg))
.fill(theme.accent),
);
if btn.clicked() {
send = true;
}
});
}
ui.add_space(4.0);
{
let entry = &app.session.collections[ci].entries[sel];
let params_n = entry.queries.len();
let headers_n = entry.headers.len();
let cookies_n = entry.cookies.len();
let options_n = entry.options.len();
let asserts_n = entry.asserts.len();
let captures_n = entry.captures.len();
let has_body = entry.body.as_ref().map(|b| !b.is_empty()).unwrap_or(false)
|| !entry.form_fields.is_empty();
let has_auth = entry.basic_auth.is_some();
let mut cur = app.editor_section;
let st = &app.strings;
let tabs = [
(EditorSection::All, st.tab_all.to_string()),
(
EditorSection::Params,
format!("{}{}", st.gui_sec_params, widgets::count_suffix(params_n)),
),
(
EditorSection::Headers,
format!("{}{}", st.gui_sec_headers, widgets::count_suffix(headers_n)),
),
(
EditorSection::Body,
format!("{}{}", st.gui_sec_body, if has_body { " •" } else { "" }),
),
(
EditorSection::Auth,
format!("{}{}", st.gui_sec_auth, if has_auth { " •" } else { "" }),
),
(
EditorSection::Cookies,
format!("{}{}", st.gui_sec_cookies, widgets::count_suffix(cookies_n)),
),
(
EditorSection::Options,
format!("{}{}", st.gui_sec_options, widgets::count_suffix(options_n)),
),
(
EditorSection::Asserts,
format!("{}{}", st.gui_sec_asserts, widgets::count_suffix(asserts_n)),
),
(
EditorSection::Captures,
format!(
"{}{}",
st.gui_sec_captures,
widgets::count_suffix(captures_n)
),
),
(EditorSection::Code, st.gui_sec_code.to_string()),
];
ui.horizontal_wrapped(|ui| {
for (value, label) in &tabs {
let selected = cur == *value;
let mut text = RichText::new(label);
text = if selected {
text.strong().color(theme.text)
} else {
text.color(theme.dim)
};
if super::widgets::selectable(ui, selected, text).clicked() {
cur = *value;
}
}
});
app.editor_section = cur;
}
ui.separator();
let (subst_vars, shadowed) = if section == EditorSection::Code {
let env = app.session.effective_env(ci);
(
crate::request::subst_map(&app.session.collections[ci], env.as_ref()),
app.session.shadowed_env_keys(ci),
)
} else {
(HashMap::new(), HashSet::new())
};
if section != EditorSection::Code {
app.code_edit.key = None;
}
if section == EditorSection::Code {
if draw_code_section(
app,
ui,
&theme,
ci,
sel,
&mut code_show_hurl,
&subst_vars,
&shadowed,
) {
changed = true;
}
} else {
let browse_fallback = app
.session
.picker_dir(crate::session::PickerKind::Other)
.map(|p| p.to_path_buf());
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
let entry = &mut app.session.collections[ci].entries[sel];
let st = &app.strings;
match section {
EditorSection::All => {
const STACK: [EditorSection; 8] = [
EditorSection::Params,
EditorSection::Headers,
EditorSection::Body,
EditorSection::Auth,
EditorSection::Cookies,
EditorSection::Options,
EditorSection::Asserts,
EditorSection::Captures,
];
for (i, sec) in STACK.iter().enumerate() {
if i > 0 {
ui.add_space(8.0);
ui.separator();
}
ui.label(
RichText::new(section_title(*sec, st))
.strong()
.color(theme.text),
);
if draw_section(*sec, ui, &theme, st, entry, browse_fallback.as_deref())
{
changed = true;
}
}
}
other => {
if draw_section(other, ui, &theme, st, entry, browse_fallback.as_deref()) {
changed = true;
}
}
}
});
}
app.show_hurl = code_show_hurl;
if changed {
let col = &mut app.session.collections[ci];
col.entries[sel].modified = true;
col.invalidate_request_json();
}
if send {
app.session.collections[ci].selected_entry = sel;
app.run_active();
}
}
fn section_title(section: EditorSection, s: &Strings) -> &'static str {
match section {
EditorSection::All => s.tab_all,
EditorSection::Params => s.gui_sec_params,
EditorSection::Headers => s.gui_sec_headers,
EditorSection::Body => s.gui_sec_body,
EditorSection::Auth => s.gui_sec_auth,
EditorSection::Cookies => s.gui_sec_cookies,
EditorSection::Options => s.gui_sec_options,
EditorSection::Asserts => s.gui_sec_asserts,
EditorSection::Captures => s.gui_sec_captures,
EditorSection::Code => s.gui_sec_code,
}
}
fn draw_section(
section: EditorSection,
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
st: &Strings,
entry: &mut HurlEntry,
browse_fallback: Option<&std::path::Path>,
) -> bool {
let mut changed = false;
match section {
EditorSection::All | EditorSection::Code => {}
EditorSection::Params => {
ui.label(RichText::new(st.gui_query_parameters).color(theme.dim));
if widgets::kv_editor(
ui,
theme,
st,
"params",
&mut entry.queries,
st.gui_hint_key,
st.gui_hint_value,
st.hdr_key,
st.hdr_value,
) {
changed = true;
}
}
EditorSection::Headers => {
if widgets::kv_editor(
ui,
theme,
st,
"headers",
&mut entry.headers,
st.gui_hint_header,
st.gui_hint_value,
st.gui_hint_header,
st.hdr_value,
) {
changed = true;
}
}
EditorSection::Body => {
if !entry.form_fields.is_empty() {
ui.colored_label(theme.pending, st.gui_form_mutually_exclusive);
}
let mut body = entry.body.take().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::multiline(&mut body)
.code_editor()
.desired_rows(10)
.desired_width(f32::INFINITY)
.hint_text(st.gui_raw_body_hint),
);
if resp.changed() {
changed = true;
}
entry.body = if body.is_empty() { None } else { Some(body) };
ui.add_space(8.0);
ui.separator();
ui.label(RichText::new(st.gui_form_fields).color(theme.dim));
if form_editor(ui, theme, st, &mut entry.form_fields, browse_fallback) {
changed = true;
}
}
EditorSection::Auth => {
let mut enabled = entry.basic_auth.is_some();
if ui.checkbox(&mut enabled, st.gui_basic_auth).changed() {
entry.basic_auth = if enabled {
Some((String::new(), String::new()))
} else {
None
};
changed = true;
}
if let Some((user, pass)) = entry.basic_auth.as_mut() {
egui::Grid::new("auth").num_columns(2).show(ui, |ui| {
ui.label(st.gui_username);
if ui.text_edit_singleline(user).changed() {
changed = true;
}
ui.end_row();
ui.label(st.gui_password);
if ui
.add(egui::TextEdit::singleline(pass).password(true))
.changed()
{
changed = true;
}
ui.end_row();
});
}
}
EditorSection::Cookies => {
if widgets::kv_editor(
ui,
theme,
st,
"cookies",
&mut entry.cookies,
st.gui_hint_name,
st.gui_hint_value,
st.hdr_name,
st.hdr_value,
) {
changed = true;
}
}
EditorSection::Options => {
ui.label(RichText::new(st.gui_per_request_options).color(theme.dim));
if widgets::kv_editor(
ui,
theme,
st,
"options",
&mut entry.options,
st.gui_hint_option,
st.gui_hint_value,
st.hdr_option,
st.hdr_value,
) {
changed = true;
}
}
EditorSection::Asserts => {
ui.label(RichText::new(st.gui_response_assertions).color(theme.dim));
if assert_editor(ui, theme, st, &mut entry.asserts) {
changed = true;
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(st.gui_expected_status);
let mut s = entry
.expected_status
.map(|v| v.to_string())
.unwrap_or_default();
if ui
.add(egui::TextEdit::singleline(&mut s).desired_width(60.0))
.changed()
{
entry.expected_status = s.trim().parse::<u16>().ok();
changed = true;
}
});
}
EditorSection::Captures => {
ui.label(RichText::new(st.gui_captures_help).color(theme.dim));
if widgets::pair_editor(
ui,
theme,
st,
"captures",
&mut entry.captures,
st.gui_hint_name,
st.gui_hint_query,
st.hdr_name,
st.hdr_query,
) {
changed = true;
}
}
}
changed
}
fn assert_editor(
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
s: &Strings,
asserts: &mut Vec<String>,
) -> bool {
let mut changed = false;
let mut remove = None;
for i in 0..asserts.len() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(RichText::new(super::icons::CLOSE).color(theme.err))
.clicked()
{
remove = Some(i);
}
let r = ui.add(
egui::TextEdit::singleline(&mut asserts[i])
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace)
.hint_text("jsonpath \"$.status\" == \"ok\""),
);
if r.changed() {
changed = true;
}
});
}
if let Some(i) = remove {
asserts.remove(i);
changed = true;
}
if ui.button(s.gui_add_assert).clicked() {
asserts.push(String::new());
changed = true;
}
changed
}
fn form_editor(
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
s: &Strings,
fields: &mut Vec<FormField>,
browse_fallback: Option<&std::path::Path>,
) -> bool {
let mut changed = false;
let mut remove = None;
let key_w = super::widgets::split_key_width(ui, 160.0);
egui::Grid::new("form_fields")
.num_columns(4)
.spacing([8.0, 4.0])
.striped(true)
.min_col_width(0.0)
.show(ui, |ui| {
for i in 0..fields.len() {
if ui.checkbox(&mut fields[i].enabled, "").changed() {
changed = true;
}
let row_color = if fields[i].enabled {
theme.text
} else {
theme.dim
};
if super::widgets::sized_key(
ui,
key_w,
&mut fields[i].key,
s.gui_hint_field,
row_color,
)
.changed()
{
changed = true;
}
let mut kind = fields[i].kind;
egui::ComboBox::from_id_salt(("formkind", i))
.selected_text(match kind {
FormFieldKind::Text => s.gui_kind_text,
FormFieldKind::File => s.gui_kind_file,
FormFieldKind::Base64File => s.gui_kind_base64,
})
.width(80.0)
.show_ui(ui, |ui| {
for (k, label) in [
(FormFieldKind::Text, s.gui_kind_text),
(FormFieldKind::File, s.gui_kind_file),
(FormFieldKind::Base64File, s.gui_kind_base64),
] {
if super::widgets::selectable(ui, kind == k, label).clicked() {
kind = k;
changed = true;
}
}
});
fields[i].kind = kind;
let hint = match kind {
FormFieldKind::Text => s.gui_hint_value,
_ => "/path/to/file",
};
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(RichText::new(super::icons::CLOSE).color(theme.err))
.clicked()
{
remove = Some(i);
}
if matches!(kind, FormFieldKind::File | FormFieldKind::Base64File)
&& ui.button(s.gui_browse).clicked()
{
if let Some(p) = super::filepick::pick_file(
s.gui_browse,
super::filepick::seed_dir(&fields[i].value)
.as_deref()
.or(browse_fallback),
&[],
) {
fields[i].value = p.to_string_lossy().into_owned();
changed = true;
}
}
if ui
.add(
egui::TextEdit::singleline(&mut fields[i].value)
.desired_width(f32::INFINITY)
.text_color(row_color)
.hint_text(hint),
)
.changed()
{
changed = true;
}
});
ui.end_row();
if fields[i].kind == FormFieldKind::Base64File {
ui.label(""); ui.label(RichText::new(s.gui_base64_prefix).color(theme.dim).small());
ui.label(""); let mut prefix = fields[i].base64_prefix.clone().unwrap_or_default();
if ui
.add(egui::TextEdit::singleline(&mut prefix).desired_width(f32::INFINITY))
.changed()
{
fields[i].base64_prefix = if prefix.is_empty() {
None
} else {
Some(prefix)
};
changed = true;
}
ui.end_row();
}
}
});
if let Some(i) = remove {
fields.remove(i);
changed = true;
}
if ui.button(s.gui_add_field).clicked() {
fields.push(FormField {
enabled: true,
..Default::default()
});
changed = true;
}
changed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Language;
use crate::session::Session;
use eframe::egui::FontId;
fn session_with_entry() -> Session {
let mut s = Session::default();
let mut e = HurlEntry::default();
e.method = "GET".into();
e.url = "https://example.com/api".into();
e.title = "Demo".into();
s.collections[0].entries = vec![e];
s.collections[0].selected_entry = 0;
s
}
#[test]
fn editable_highlighter_preserves_the_buffer_text_verbatim() {
let th = GuiTheme::from_spec(&Session::default().active_theme_spec());
let vars = HashMap::new();
let shadowed = HashSet::new();
let mut seen = SubstSeen::default();
for text in [
"GET https://x/{{ host }}/api\nAuthorization: {{ token }}",
"no placeholders here",
"trailing {{ unclosed",
"{{a}}{{b}} back to back",
] {
let job = highlight_code_editable(
text,
&vars,
&shadowed,
&th,
FontId::monospace(12.0),
&mut seen,
);
assert_eq!(job.text, text, "layouter must not alter the buffer text");
}
}
#[test]
fn editing_the_hurl_buffer_roundtrips_a_new_header_into_the_entry() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let mut code = super::super::app::CodeEdit::default();
let mut edited = session.collections[0].entries[0].clone();
edited.headers.push(KvRow::toggled("X-Test", "hello", true));
let text = edited.to_hurl();
let changed = apply_code_edit(&mut session, &mut code, &strings, 0, 0, true, &text);
assert!(changed, "a valid edit should report a change");
assert!(code.error.is_none(), "a valid edit clears the error");
let hdrs = &session.collections[0].entries[0].headers;
assert!(
hdrs.iter().any(|r| r.key == "X-Test" && r.value == "hello"),
"expected the new header to be applied, got {hdrs:?}"
);
}
#[test]
fn invalid_hurl_keeps_the_entry_and_records_an_error() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let before = session.collections[0].entries[0].clone();
let mut code = super::super::app::CodeEdit::default();
let changed = apply_code_edit(
&mut session,
&mut code,
&strings,
0,
0,
true,
"not a request",
);
assert!(!changed, "an unparseable edit must not report a change");
assert!(code.error.is_some(), "an unparseable edit records an error");
let entry = &session.collections[0].entries[0];
assert_eq!(entry.method, before.method);
assert_eq!(entry.url, before.url);
assert_eq!(entry.headers, before.headers);
}
#[test]
fn editing_the_json_buffer_roundtrips_the_method_into_the_entry() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let mut code = super::super::app::CodeEdit::default();
let mut edited = session.collections[0].entries[0].clone();
edited.method = "POST".into();
let text = build_request_json(&edited);
let changed = apply_code_edit(&mut session, &mut code, &strings, 0, 0, false, &text);
assert!(changed, "a valid JSON edit should report a change");
assert!(code.error.is_none());
assert_eq!(session.collections[0].entries[0].method, "POST");
}
#[test]
fn invalid_json_keeps_the_entry_and_records_an_error() {
let strings = Strings::for_language(&Language::English);
let mut session = session_with_entry();
let before_method = session.collections[0].entries[0].method.clone();
let mut code = super::super::app::CodeEdit::default();
let changed = apply_code_edit(&mut session, &mut code, &strings, 0, 0, false, "{ not json");
assert!(!changed, "malformed JSON must not report a change");
assert!(code.error.is_some(), "malformed JSON records an error");
assert_eq!(session.collections[0].entries[0].method, before_method);
}
}