use eframe::egui::{self, RichText};
use crate::report::context;
use crate::report::edit::node_at;
use crate::report::flow::{
EnvClause, FlowNode, ParallelSpec, Pattern, Producer, ReportStmt, ResponseFmt, RoleRef,
ShowField, UsingItem, WithItem,
};
use crate::report::model::StatKind;
use super::app::GuiApp;
use super::report_editor::ReportEditor;
pub enum Wizard {
Request(RequestForm),
Envs(EnvsForm),
Files(FilesForm),
Assign(AssignForm),
List(ListForm),
Folders(FoldersForm),
Vars(VarsForm),
Computed(ComputedForm),
Raw(RawForm),
WithField(WithFieldForm),
}
pub struct VarsForm {
path: Vec<usize>,
vars: Vec<(String, bool)>,
other: String,
alias: String,
stats: Vec<(StatKind, bool)>,
clauses: crate::report::edit::ClauseForm,
}
impl VarsForm {
fn chosen(&self) -> Vec<String> {
let mut v: Vec<String> = self
.vars
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| n.clone())
.collect();
let other = self.other.trim();
if !other.is_empty() && !v.iter().any(|n| n == other) {
v.push(other.to_string());
}
v
}
fn node(&self) -> Option<FlowNode> {
let chosen = self.chosen();
let (first, rest) = chosen.split_first()?;
let alias = self.alias.trim();
let image = self.clauses.image();
let truth = self.clauses.truth();
if rest.is_empty()
&& (!alias.is_empty() || image.is_some() || truth.is_some() || self.clauses.detail)
{
return Some(FlowNode::Report(ReportStmt::VarAs {
var: first.clone(),
name: if alias.is_empty() {
first.clone()
} else {
alias.to_string()
},
stats: self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
image,
truth,
detail: self.clauses.detail,
}));
}
Some(FlowNode::Report(ReportStmt::Vars(chosen)))
}
}
pub struct ComputedForm {
path: Vec<usize>,
template: String,
alias: String,
stats: Vec<(StatKind, bool)>,
clauses: crate::report::edit::ClauseForm,
}
pub struct AssignForm {
path: Vec<usize>,
key: String,
value: String,
}
pub struct ListForm {
path: Vec<usize>,
name: String,
values: String,
}
pub struct FoldersForm {
path: Vec<usize>,
var: String,
dir: String,
parallel: bool,
degree: String,
}
pub struct RawForm {
path: Vec<usize>,
text: String,
is_loop: bool,
}
pub struct WithFieldForm {
path: Vec<usize>,
index: Option<usize>,
name: String,
query: String,
stats: Vec<(StatKind, bool)>,
clauses: crate::report::edit::ClauseForm,
}
impl WithFieldForm {
fn stats(&self) -> Vec<StatKind> {
self.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect()
}
}
pub struct RequestForm {
path: Vec<usize>,
name: String,
titles: Vec<String>,
report: bool,
response: Option<ResponseFmt>,
alias: String,
fields: Vec<(String, bool)>,
show_stats: std::collections::HashMap<String, Vec<StatKind>>,
hide_fields: Vec<(String, bool)>,
with: Vec<WithItem>,
using: Vec<UsingItem>,
overrides: Vec<crate::report::edit::OverrideRow>,
params: Vec<crate::report::edit::ParamRow>,
params_by_request: std::collections::HashMap<String, Vec<(String, String)>>,
}
impl RequestForm {
fn show(&self) -> Vec<ShowField> {
if self
.fields
.iter()
.all(|(n, on)| *on != crate::report::run::OPT_IN_INTRINSIC_FIELDS.contains(&n.as_str()))
{
return Vec::new();
}
self.fields
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| ShowField {
field: n.clone(),
stats: self.show_stats.get(n).cloned().unwrap_or_default(),
})
.collect()
}
fn hide(&self) -> Vec<String> {
self.hide_fields
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| n.clone())
.collect()
}
fn alias_opt(&self) -> Option<String> {
let a = self.alias.trim();
(!a.is_empty()).then(|| a.to_string())
}
fn using_clause(&self) -> Vec<UsingItem> {
crate::report::edit::using_items(
&self.params,
&crate::report::edit::override_items(&self.overrides),
)
}
fn refresh_params(&mut self) {
let using = crate::report::edit::using_items(&self.params, &self.using);
let declared = self
.params_by_request
.get(self.name.trim())
.map(Vec::as_slice)
.unwrap_or(&[]);
self.params = crate::report::edit::param_rows(declared, &using);
}
fn to_node(&self) -> FlowNode {
if self.report {
FlowNode::Report(ReportStmt::Request {
name: self.name.trim().to_string(),
alias: self.alias_opt(),
using: self.using_clause(),
response_fmt: self.response,
show: self.show(),
hide: self.hide(),
with: self.with.clone(),
})
} else {
FlowNode::Request {
name: self.name.trim().to_string(),
using: self.using_clause(),
}
}
}
}
pub struct EnvEntry {
name: String,
baseline: bool,
file: bool,
}
pub struct EnvsForm {
path: Vec<usize>,
var: String,
compare: bool,
parallel: bool,
degree: String,
entries: Vec<EnvEntry>,
choices: Vec<String>,
snapshots: Vec<String>,
baseline_show_fields: Vec<(String, bool)>,
show_stats: std::collections::HashMap<String, Vec<StatKind>>,
}
impl EnvsForm {
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"TARGET".to_string()
} else {
v.to_string()
}
}
fn selected_baseline_show(&self) -> Vec<ShowField> {
self.baseline_show_fields
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| ShowField {
field: n.clone(),
stats: self.show_stats.get(n).cloned().unwrap_or_default(),
})
.collect()
}
fn clause(&self) -> Option<EnvClause> {
if self.compare {
let refs = |want_baseline: bool| -> Vec<RoleRef> {
self.entries
.iter()
.filter(|e| e.baseline == want_baseline && !e.name.trim().is_empty())
.map(|e| {
let name = e.name.trim().to_string();
if e.file {
RoleRef::File(name)
} else {
RoleRef::Env(name)
}
})
.collect()
};
let baseline = refs(true);
let comparisons = refs(false);
if baseline.is_empty() && comparisons.is_empty() {
return None;
}
Some(EnvClause::Roles {
baseline,
comparisons,
baseline_show: self.selected_baseline_show(),
})
} else {
let names: Vec<String> = self
.entries
.iter()
.map(|e| e.name.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
(!names.is_empty()).then_some(EnvClause::Plain(names))
}
}
}
pub struct FilesForm {
path: Vec<usize>,
var: String,
dir: String,
glob: String,
parallel: bool,
degree: String,
}
impl FilesForm {
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"FILE".to_string()
} else {
v.to_string()
}
}
fn glob_opt(&self) -> Option<String> {
let g = self.glob.trim();
(!g.is_empty()).then(|| g.to_string())
}
}
fn build_vars(
app: &GuiApp,
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
path: &[usize],
node: &FlowNode,
) -> VarsForm {
let (chosen, alias, stats, image, truth, detail) = match node {
FlowNode::Report(ReportStmt::Vars(vars)) => {
(vars.clone(), String::new(), Vec::new(), None, None, false)
}
FlowNode::Report(ReportStmt::VarAs {
var,
name,
stats,
image,
truth,
detail,
}) => (
vec![var.clone()],
name.clone(),
stats.clone(),
*image,
truth.clone(),
*detail,
),
_ => (Vec::new(), String::new(), Vec::new(), None, None, false),
};
let entries =
context::bound_entries(&app.session.collections, flow, report_path).unwrap_or_default();
let in_scope = crate::report::edit::vars_in_scope(flow, path, &entries);
let mut vars: Vec<(String, bool)> = in_scope
.iter()
.map(|n| (n.clone(), chosen.contains(n)))
.collect();
for c in &chosen {
if !vars.iter().any(|(n, _)| n == c) {
vars.push((c.clone(), true));
}
}
VarsForm {
path: path.to_vec(),
vars,
other: String::new(),
alias,
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
clauses: crate::report::edit::ClauseForm::of(image, truth.as_deref(), detail),
}
}
pub fn open(ed: &mut ReportEditor, app: &GuiApp, path: &[usize]) {
let Some(flow) = ed.flow.clone() else {
return;
};
let Some(node) = node_at(&flow, path).cloned() else {
return;
};
let report_path = ed.report.path.clone();
let wiz = match &node {
FlowNode::Request { .. } | FlowNode::Report(ReportStmt::Request { .. }) => Wizard::Request(
build_request(app, &flow, report_path.as_deref(), path.to_vec(), &node),
),
FlowNode::ForEnvs { .. } => Wizard::Envs(build_envs(
app,
&flow,
report_path.as_deref(),
path.to_vec(),
&node,
)),
FlowNode::ForEach {
pattern,
producer: Producer::Files { .. },
..
} if single_named_binder(pattern).is_some() => {
Wizard::Files(build_files(path.to_vec(), &node))
}
FlowNode::ForEach {
pattern,
producer: Producer::Folders { .. },
..
} if single_named_binder(pattern).is_some() => {
Wizard::Folders(build_folders(path.to_vec(), &node))
}
FlowNode::Assign { key, value } => Wizard::Assign(AssignForm {
path: path.to_vec(),
key: key.clone(),
value: value.clone(),
}),
FlowNode::ListDecl {
name,
producer: Producer::List(elems),
} if elems
.iter()
.all(|e| matches!(e, crate::report::flow::Element::Scalar(_))) =>
{
Wizard::List(ListForm {
path: path.to_vec(),
name: name.clone(),
values: list_values_text(elems),
})
}
FlowNode::Report(ReportStmt::Vars(_)) | FlowNode::Report(ReportStmt::VarAs { .. }) => {
Wizard::Vars(build_vars(app, &flow, report_path.as_deref(), path, &node))
}
FlowNode::Report(ReportStmt::Computed {
template,
name,
stats,
image,
truth,
detail,
}) => Wizard::Computed(ComputedForm {
path: path.to_vec(),
template: template.clone(),
alias: name.clone(),
clauses: crate::report::edit::ClauseForm::of(*image, truth.as_deref(), *detail),
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
}),
_ => Wizard::Raw(RawForm {
path: path.to_vec(),
text: node.header_line(),
is_loop: node.is_loop(),
}),
};
ed.wizard = Some(wiz);
}
pub fn open_with_field(ed: &mut ReportEditor, path: &[usize], index: Option<usize>) {
let existing = index.and_then(|i| {
ed.flow
.as_ref()
.and_then(|f| node_at(f, path))
.and_then(|n| match n {
FlowNode::Report(ReportStmt::Request { with, .. }) => with.get(i).cloned(),
_ => None,
})
});
let (index, name, query, stats, clauses) = match existing {
Some(WithItem::Field {
name,
query,
stats,
image,
truth,
detail,
}) => (
index,
name,
query,
stats,
crate::report::edit::ClauseForm::of(image, truth.as_deref(), detail),
),
_ => (
None,
String::new(),
String::new(),
Vec::new(),
crate::report::edit::ClauseForm::default(),
),
};
ed.wizard = Some(Wizard::WithField(WithFieldForm {
path: path.to_vec(),
index,
name,
query,
clauses,
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
}));
}
fn single_named_binder(pattern: &Pattern) -> Option<&str> {
if pattern.is_single() {
pattern.named().next()
} else {
None
}
}
fn list_values_text(elems: &[crate::report::flow::Element]) -> String {
use crate::report::flow::Element;
elems
.iter()
.map(|e| match e {
Element::Scalar(s) => s.clone(),
Element::Tuple(parts) => parts.join(", "),
})
.collect::<Vec<_>>()
.join("\n")
}
fn parse_list_values(text: &str) -> Vec<crate::report::flow::Element> {
use crate::report::flow::Element;
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Element::Scalar(l.to_string()))
.collect()
}
fn parallel_row(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
parallel: &mut bool,
degree: &mut String,
) {
ui.horizontal(|ui| {
ui.checkbox(
&mut *parallel,
RichText::new(s.report_node_parallel_label).color(th.text),
);
ui.add_enabled_ui(*parallel, |ui| {
ui.add(
egui::TextEdit::singleline(degree)
.hint_text(s.node_form_parallel_degree)
.desired_width(48.0),
);
ui.label(RichText::new(s.node_form_parallel_degree_label).color(th.dim));
});
});
}
fn degree_text(parallel: &Option<ParallelSpec>) -> String {
parallel
.as_ref()
.and_then(|p| p.degree)
.map(|n| n.to_string())
.unwrap_or_default()
}
fn parallel_spec(on: bool, degree: &str) -> Option<ParallelSpec> {
on.then(|| ParallelSpec {
degree: degree.trim().parse::<u32>().ok().filter(|n| *n > 0),
})
}
fn build_folders(path: Vec<usize>, node: &FlowNode) -> FoldersForm {
let (var, dir, parallel) = match node {
FlowNode::ForEach {
pattern,
producer: Producer::Folders { dir, .. },
parallel,
..
} => (
pattern.named().next().unwrap_or("FOLDER").to_string(),
dir.clone(),
*parallel,
),
_ => unreachable!("build_folders called on a non-FOLDERS node"),
};
FoldersForm {
path,
var,
dir,
parallel: parallel.is_some(),
degree: degree_text(¶llel),
}
}
fn build_request(
app: &GuiApp,
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
path: Vec<usize>,
node: &FlowNode,
) -> RequestForm {
let (name, report, alias, response, show, hide, with, using) = match node {
FlowNode::Request { name, using } => (
name.clone(),
false,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
using.clone(),
),
FlowNode::Report(ReportStmt::Request {
name,
alias,
using,
response_fmt,
show,
hide,
with,
}) => (
name.clone(),
true,
alias.clone(),
*response_fmt,
show.clone(),
hide.clone(),
with.clone(),
using.clone(),
),
_ => unreachable!("build_request called on a non-request node"),
};
let bound = context::bound_entries(&app.session.collections, flow, report_path);
let titles: Vec<String> = bound
.as_deref()
.map(|es| es.iter().map(|e| e.title.clone()).collect())
.unwrap_or_default();
let params_by_request: std::collections::HashMap<String, Vec<(String, String)>> = bound
.as_deref()
.map(|es| {
es.iter()
.map(|e| (e.title.clone(), e.variable_defaults()))
.filter(|(_, p)| !p.is_empty())
.collect()
})
.unwrap_or_default();
let params = crate::report::edit::param_rows(
params_by_request
.get(&name)
.map(Vec::as_slice)
.unwrap_or(&[]),
&using,
);
let report_fields = bound
.as_deref()
.and_then(|es| {
crate::report::run::resolve_title(es, &name)
.map(|e| e.reports.iter().map(|(f, _)| f.clone()).collect::<Vec<_>>())
})
.unwrap_or_default();
let mut names: Vec<String> = Vec::new();
let push = |n: &str, names: &mut Vec<String>| {
if !names.iter().any(|x| x == n) {
names.push(n.to_string());
}
};
for f in crate::report::run::INTRINSIC_FIELDS {
push(f, &mut names);
}
for f in &report_fields {
push(f, &mut names);
}
for w in &with {
if let WithItem::Field { name, .. } = w {
push(name, &mut names);
}
}
for f in &show {
push(f.name(), &mut names);
}
for f in &hide {
push(f, &mut names);
}
let all = show.is_empty();
let fields = names
.iter()
.map(|n| {
let on = (all && !crate::report::run::OPT_IN_INTRINSIC_FIELDS.contains(&n.as_str()))
|| show.iter().any(|s| s.name() == n);
(n.clone(), on)
})
.collect();
let hide_fields = names
.iter()
.map(|n| (n.clone(), hide.iter().any(|h| h == n)))
.collect();
RequestForm {
path,
name,
titles,
report,
response,
alias: alias.unwrap_or_default(),
fields,
show_stats: show
.iter()
.filter(|f| !f.stats.is_empty())
.map(|f| (f.field.clone(), f.stats.clone()))
.collect(),
hide_fields,
with,
overrides: crate::report::edit::override_rows(&using),
using,
params,
params_by_request,
}
}
fn build_envs(
app: &GuiApp,
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
path: Vec<usize>,
node: &FlowNode,
) -> EnvsForm {
let (var, clause, parallel, body) = match node {
FlowNode::ForEnvs {
var,
clause,
parallel,
body,
} => (var.clone(), clause.clone(), *parallel, body.as_slice()),
_ => unreachable!("build_envs called on a non-ENVS node"),
};
let (compare, mut entries, baseline_show) = match &clause {
EnvClause::Plain(names) => (
false,
names
.iter()
.map(|n| EnvEntry {
name: n.clone(),
baseline: false,
file: false,
})
.collect::<Vec<_>>(),
Vec::new(),
),
EnvClause::Roles {
baseline,
comparisons,
baseline_show,
} => {
let entry = |r: &RoleRef, is_baseline: bool| EnvEntry {
name: r.target().to_string(),
baseline: is_baseline,
file: matches!(r, RoleRef::File(_)),
};
let mut es: Vec<EnvEntry> = baseline.iter().map(|r| entry(r, true)).collect();
es.extend(comparisons.iter().map(|r| entry(r, false)));
(true, es, baseline_show.clone())
}
};
let choices: Vec<String> = app
.session
.global_envs
.iter()
.map(|e| e.name.clone())
.collect();
let mut snapshots = discover_snapshots(flow, report_path);
for e in &entries {
if e.file && !e.name.trim().is_empty() && !snapshots.iter().any(|s| s == &e.name) {
snapshots.push(e.name.clone());
}
}
if entries.is_empty() {
entries.push(EnvEntry {
name: choices.first().cloned().unwrap_or_default(),
baseline: compare,
file: false,
});
}
let bound =
context::bound_entries(&app.session.collections, flow, report_path).unwrap_or_default();
let baseline_show_fields =
crate::report::edit::baseline_show_choices(&bound, body, &baseline_show);
EnvsForm {
path,
var,
show_stats: baseline_show
.iter()
.filter(|f| !f.stats.is_empty())
.map(|f| (f.field.clone(), f.stats.clone()))
.collect(),
compare,
parallel: parallel.is_some(),
degree: degree_text(¶llel),
entries,
choices,
snapshots,
baseline_show_fields,
}
}
fn build_files(path: Vec<usize>, node: &FlowNode) -> FilesForm {
let (var, dir, glob, parallel) = match node {
FlowNode::ForEach {
pattern,
producer: Producer::Files { dir, glob },
parallel,
..
} => (
pattern.named().next().unwrap_or("FILE").to_string(),
dir.clone(),
glob.clone().unwrap_or_default(),
*parallel,
),
_ => unreachable!("build_files called on a non-FILES node"),
};
FilesForm {
path,
var,
dir,
glob,
parallel: parallel.is_some(),
degree: degree_text(¶llel),
}
}
fn discover_snapshots(
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
) -> Vec<String> {
let (root, _) = context::report_base_dir(flow, report_path);
let mut out: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "baseline")
&& let Some(name) = p.file_name().and_then(|n| n.to_str())
{
out.push(name.to_string());
}
}
}
out.sort();
out
}
enum Outcome {
None,
Apply,
Cancel,
}
pub fn show(ed: &mut ReportEditor, app: &mut GuiApp, ctx: &egui::Context) {
if ed.wizard.is_none() {
return;
}
let th = app.theme;
let fallback = app
.session
.picker_dir(crate::session::PickerKind::Other)
.map(|p| p.to_path_buf());
let s = &app.strings;
let mut outcome = Outcome::None;
let mut browse = false;
let modal = egui::Modal::new(egui::Id::new("pt_node_wizard")).show(ctx, |ui| {
ui.set_min_width(360.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
if ui
.button(RichText::new(super::icons::CLOSE).color(th.dim))
.clicked()
{
outcome = Outcome::Cancel;
}
});
let wiz = ed.wizard.as_mut().expect("wizard is Some");
match wiz {
Wizard::Request(f) => request_ui(ui, &th, s, f),
Wizard::Envs(f) => envs_ui(ui, &th, s, f),
Wizard::Files(f) => files_ui(ui, &th, s, f, &mut browse),
Wizard::Assign(f) => assign_ui(ui, &th, s, f),
Wizard::List(f) => list_ui(ui, &th, s, f),
Wizard::Folders(f) => folders_ui(ui, &th, s, f, &mut browse),
Wizard::Vars(f) => vars_ui(ui, &th, s, f),
Wizard::Computed(f) => computed_ui(ui, &th, s, f),
Wizard::Raw(f) => raw_ui(ui, &th, s, f),
Wizard::WithField(f) => with_field_ui(ui, &th, s, f),
}
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
if ui.button(RichText::new(s.gui_ok).color(th.text)).clicked() {
outcome = Outcome::Apply;
}
if ui
.button(RichText::new(s.gui_cancel).color(th.text))
.clicked()
{
outcome = Outcome::Cancel;
}
});
});
if modal.should_close() {
outcome = Outcome::Cancel;
}
if browse {
let current = match ed.wizard.as_ref() {
Some(Wizard::Files(f)) => f.dir.clone(),
Some(Wizard::Folders(f)) => f.dir.clone(),
_ => String::new(),
};
app.request_pick(
super::filepick::PickKind::Folder,
app.strings.report_node_files_folder_label,
super::filepick::seed_dir(¤t)
.as_deref()
.or(fallback.as_deref()),
super::menu::PickAction::ReportWizardDir,
);
}
match outcome {
Outcome::None => {}
Outcome::Cancel => ed.wizard = None,
Outcome::Apply => {
apply(ed, app);
ed.wizard = None;
}
}
}
fn request_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut RequestForm,
) {
ui.heading(RichText::new(s.node_request_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_req_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.node_form_name).color(th.dim));
let before = f.name.clone();
ui.horizontal_top(|ui| {
let pad = ui.spacing().button_padding.y as i8;
ui.add(
egui::TextEdit::singleline(&mut f.name)
.desired_width(180.0)
.margin(egui::Margin::symmetric(4, pad)),
);
if !f.titles.is_empty() {
egui::ComboBox::from_id_salt("pt_req_name_pick")
.selected_text(RichText::new("…").color(th.dim))
.show_ui(ui, |ui| {
for t in f.titles.clone() {
if ui.selectable_label(f.name == t, &t).clicked() {
f.name = t;
}
}
});
}
});
if f.name != before {
f.refresh_params();
}
ui.end_row();
ui.label(RichText::new(s.node_form_report).color(th.dim));
ui.checkbox(
&mut f.report,
RichText::new(s.node_form_report_hint).color(th.text),
);
ui.end_row();
if f.report {
ui.label(RichText::new(s.node_form_response).color(th.dim));
ui.horizontal(|ui| {
ui.selectable_value(&mut f.response, None, s.node_form_response_default);
ui.selectable_value(&mut f.response, Some(ResponseFmt::Raw), "RAW");
ui.selectable_value(&mut f.response, Some(ResponseFmt::Pretty), "PRETTY");
});
ui.end_row();
ui.label(RichText::new(s.node_form_alias).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.alias).desired_width(180.0));
ui.end_row();
}
});
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_using).color(th.dim));
ui.label(RichText::new(s.node_form_using_hint).color(th.dim));
if f.params.is_empty() {
ui.label(RichText::new(s.node_form_using_none).color(th.dim));
}
for p in &mut f.params {
ui.horizontal(|ui| {
ui.checkbox(
&mut p.required,
RichText::new(p.name.as_str()).color(th.text),
);
match &p.default {
Some(v) => {
ui.label(RichText::new(format!("= {v}")).color(th.dim));
}
None => {
ui.label(RichText::new(s.report_node_param_undeclared).color(th.err));
}
}
});
}
ui.add_space(4.0);
ui.label(RichText::new(s.node_form_override_hint).color(th.dim));
let mut drop_override: Option<usize> = None;
for (i, row) in f.overrides.iter_mut().enumerate() {
ui.horizontal(|ui| {
let ok = crate::report::edit::override_target_valid(&row.target);
let target = egui::TextEdit::singleline(&mut row.target)
.desired_width(150.0)
.hint_text(s.node_form_override_target_hint)
.text_color(if ok { th.text } else { th.err });
ui.add(target);
ui.label(RichText::new("=").color(th.dim));
ui.add(
egui::TextEdit::singleline(&mut row.value)
.desired_width(180.0)
.hint_text(s.node_form_override_value_hint),
);
if ui
.button(RichText::new(super::icons::CLOSE).color(th.err))
.clicked()
{
drop_override = Some(i);
}
});
if !crate::report::edit::override_target_valid(&row.target) {
ui.label(RichText::new(s.node_form_override_bad_target).color(th.err));
}
}
if let Some(i) = drop_override {
f.overrides.remove(i);
}
if ui.button(s.node_form_override_add).clicked() {
f.overrides
.push(crate::report::edit::OverrideRow::default());
}
if f.report && !f.fields.is_empty() {
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_show).color(th.dim));
ui.label(RichText::new(s.node_form_show_hint).color(th.dim));
egui::ScrollArea::vertical()
.id_salt("pt_req_fields")
.max_height(160.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (name, on) in &mut f.fields {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
});
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_hide).color(th.dim));
ui.label(RichText::new(s.node_form_hide_hint).color(th.dim));
egui::ScrollArea::vertical()
.id_salt("pt_req_hide_fields")
.max_height(120.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (name, on) in &mut f.hide_fields {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
});
}
}
fn envs_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut EnvsForm,
) {
ui.heading(RichText::new(s.report_node_envs_title).color(th.text));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_envs_var_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.var).desired_width(160.0));
});
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_envs_mode_label).color(th.dim));
let mut compare = f.compare;
ui.selectable_value(&mut compare, false, s.report_node_envs_mode_plain);
ui.selectable_value(&mut compare, true, s.report_node_envs_mode_roles);
if compare != f.compare {
f.compare = compare;
if f.compare
&& !f.entries.iter().any(|e| e.baseline)
&& let Some(first) = f.entries.first_mut()
{
first.baseline = true;
}
}
});
parallel_row(ui, th, s, &mut f.parallel, &mut f.degree);
ui.add_space(6.0);
ui.label(RichText::new(s.node_envs_environments).color(th.dim));
let mut remove: Option<usize> = None;
let mut make_baseline: Option<usize> = None;
let mut toggled_file: Option<usize> = None;
let compare = f.compare;
let count = f.entries.len();
let choices = f.choices.clone();
let snapshots = f.snapshots.clone();
for i in 0..count {
ui.horizontal(|ui| {
let list = if f.entries[i].file {
&snapshots
} else {
&choices
};
let selected = f.entries[i].name.clone();
egui::ComboBox::from_id_salt(("pt_env_pick", i))
.selected_text(if selected.is_empty() {
RichText::new("—").color(th.dim)
} else {
RichText::new(selected.as_str()).color(th.text)
})
.show_ui(ui, |ui| {
for c in list.clone() {
if ui.selectable_label(f.entries[i].name == c, &c).clicked() {
f.entries[i].name = c;
}
}
});
if compare {
if ui
.radio(f.entries[i].baseline, s.report_node_envs_baseline)
.clicked()
{
make_baseline = Some(i);
}
if ui
.checkbox(&mut f.entries[i].file, s.report_node_envs_file)
.changed()
{
toggled_file = Some(i);
}
}
if count > 1 && ui.button(RichText::new("×").color(th.err)).clicked() {
remove = Some(i);
}
});
if compare && f.entries[i].baseline {
baseline_show_ui(ui, th, s, &mut f.baseline_show_fields);
}
}
if let Some(i) = make_baseline {
for (j, e) in f.entries.iter_mut().enumerate() {
e.baseline = j == i;
}
}
if let Some(i) = toggled_file {
let list = if f.entries[i].file {
&f.snapshots
} else {
&f.choices
};
if !list.iter().any(|c| c == &f.entries[i].name)
&& let Some(first) = list.first().cloned()
{
f.entries[i].name = first;
}
}
if let Some(i) = remove {
f.entries.remove(i);
}
if ui
.button(RichText::new(format!("{} {}", super::icons::PLUS, s.node_envs_add)).color(th.text))
.clicked()
{
f.entries.push(EnvEntry {
name: f.choices.first().cloned().unwrap_or_default(),
baseline: false,
file: false,
});
}
}
fn files_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut FilesForm,
browse: &mut bool,
) {
ui.heading(RichText::new(s.report_node_files_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_files_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.report_node_files_var_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.var).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.report_node_files_folder_label).color(th.dim));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut f.dir).desired_width(220.0));
if ui.button(s.gui_browse).clicked() {
*browse = true;
}
});
ui.end_row();
ui.label(RichText::new(s.report_node_files_match_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.glob).desired_width(220.0));
ui.end_row();
});
parallel_row(ui, th, s, &mut f.parallel, &mut f.degree);
}
fn assign_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut AssignForm,
) {
ui.heading(RichText::new(s.node_assign_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_assign_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.node_form_var).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.key).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.node_form_value).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(220.0));
ui.end_row();
});
}
fn list_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut ListForm,
) {
ui.heading(RichText::new(s.node_list_title).color(th.text));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.node_form_list_name).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(200.0));
});
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_list_values).color(th.dim));
ui.add(
egui::TextEdit::multiline(&mut f.values)
.desired_width(f32::INFINITY)
.desired_rows(5)
.font(egui::TextStyle::Monospace),
);
}
fn folders_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut FoldersForm,
browse: &mut bool,
) {
ui.heading(RichText::new(s.node_folders_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_folders_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.report_node_files_var_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.var).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.report_node_files_folder_label).color(th.dim));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut f.dir).desired_width(220.0));
if ui.button(s.gui_browse).clicked() {
*browse = true;
}
});
ui.end_row();
});
parallel_row(ui, th, s, &mut f.parallel, &mut f.degree);
}
fn raw_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut RawForm,
) {
ui.heading(RichText::new(s.node_raw_title).color(th.text));
ui.add_space(4.0);
ui.label(RichText::new(s.node_form_raw).color(th.dim));
ui.add(
egui::TextEdit::multiline(&mut f.text)
.desired_width(f32::INFINITY)
.desired_rows(2)
.font(egui::TextStyle::Monospace),
);
}
fn stats_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
stats: &mut [(StatKind, bool)],
) {
ui.label(RichText::new(s.node_form_statistics).color(th.dim));
ui.label(RichText::new(s.node_form_statistics_hint).color(th.dim));
ui.horizontal_top(|ui| {
for chunk in stats.chunks_mut(5) {
ui.vertical(|ui| {
for (kind, on) in chunk {
ui.checkbox(on, RichText::new(kind.label()).color(th.text));
}
});
}
});
}
fn clauses_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
c: &mut crate::report::edit::ClauseForm,
) {
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_clause_truth_label).color(th.dim));
ui.add(
egui::TextEdit::singleline(&mut c.truth)
.hint_text(s.report_node_clause_truth_none)
.desired_width(220.0)
.font(egui::TextStyle::Monospace),
);
});
ui.checkbox(
&mut c.detail,
RichText::new(s.report_node_clause_detail_toggle).color(th.text),
);
let mut image_on = c.image_on;
if ui
.checkbox(
&mut image_on,
RichText::new(s.report_node_clause_image_toggle).color(th.text),
)
.changed()
{
c.toggle_image();
}
if !c.image_on {
return;
}
ui.indent("pt_clause_image", |ui| {
let mut fit = c.fit;
if ui
.checkbox(
&mut fit,
RichText::new(s.report_node_clause_fit).color(th.text),
)
.changed()
{
c.toggle_fit();
}
if c.fit {
return;
}
for (label, value) in [
(s.report_node_clause_height, &mut c.height),
(s.report_node_clause_width, &mut c.width),
] {
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(th.dim));
if ui
.add(
egui::TextEdit::singleline(value)
.hint_text(s.report_node_clause_size_auto)
.desired_width(80.0),
)
.changed()
{
value.retain(|ch| ch.is_ascii_digit());
}
});
}
});
}
fn vars_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut VarsForm,
) {
ui.heading(RichText::new(s.report_node_vars_title).color(th.text));
ui.add_space(4.0);
if f.vars.is_empty() {
ui.label(RichText::new(s.report_node_vars_none).color(th.dim));
}
for (name, on) in &mut f.vars {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_vars_other_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.other).desired_width(200.0));
});
if f.chosen().len() == 1 {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_alias_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.alias).desired_width(200.0));
});
if !f.alias.trim().is_empty() {
ui.add_space(6.0);
stats_ui(ui, th, s, &mut f.stats);
}
ui.add_space(6.0);
clauses_ui(ui, th, s, &mut f.clauses);
}
}
fn computed_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut ComputedForm,
) {
ui.heading(RichText::new(s.report_node_computed_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_computed_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.report_node_computed_template_label).color(th.dim));
ui.add(
egui::TextEdit::singleline(&mut f.template)
.hint_text(s.report_node_computed_template_hint)
.desired_width(240.0)
.font(egui::TextStyle::Monospace),
);
ui.end_row();
ui.label(RichText::new(s.report_node_computed_name_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.alias).desired_width(240.0));
ui.end_row();
});
ui.add_space(6.0);
stats_ui(ui, th, s, &mut f.stats);
ui.add_space(6.0);
clauses_ui(ui, th, s, &mut f.clauses);
}
fn with_field_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut WithFieldForm,
) {
ui.heading(RichText::new(s.node_with_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_with_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.node_with_name).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.node_with_query).color(th.dim));
ui.add(
egui::TextEdit::singleline(&mut f.query)
.hint_text(s.node_with_query_hint)
.desired_width(220.0)
.font(egui::TextStyle::Monospace),
);
ui.end_row();
});
ui.add_space(6.0);
stats_ui(ui, th, s, &mut f.stats);
ui.add_space(6.0);
clauses_ui(ui, th, s, &mut f.clauses);
}
fn apply(ed: &mut ReportEditor, app: &mut GuiApp) {
let Some(wiz) = ed.wizard.as_ref() else {
return;
};
match wiz {
Wizard::Request(f) => {
let path = f.path.clone();
let node = f.to_node();
ed.wizard_apply(app, &path, node);
}
Wizard::Envs(f) => {
let Some(clause) = f.clause() else {
return;
};
let path = f.path.clone();
let body = match ed.flow.as_ref().and_then(|fl| node_at(fl, &path)) {
Some(FlowNode::ForEnvs { body, .. }) => body.clone(),
_ => return,
};
let parallel = parallel_spec(f.parallel, &f.degree);
let node = FlowNode::ForEnvs {
var: f.var_or_default(),
clause,
body,
parallel,
};
ed.wizard_apply(app, &path, node);
}
Wizard::Files(f) => {
let path = f.path.clone();
let body = match ed.flow.as_ref().and_then(|fl| node_at(fl, &path)) {
Some(FlowNode::ForEach { body, .. }) => body.clone(),
_ => return,
};
let parallel = parallel_spec(f.parallel, &f.degree);
let node = FlowNode::ForEach {
pattern: Pattern::single(f.var_or_default()),
producer: Producer::Files {
dir: f.dir.clone(),
glob: f.glob_opt(),
},
body,
parallel,
};
ed.wizard_apply(app, &path, node);
}
Wizard::Assign(f) => {
let path = f.path.clone();
let key = f.key.trim();
if key.is_empty() {
return;
}
let node = FlowNode::Assign {
key: key.to_string(),
value: f.value.clone(),
};
ed.wizard_apply(app, &path, node);
}
Wizard::List(f) => {
let path = f.path.clone();
let name = f.name.trim();
if name.is_empty() {
return;
}
let node = FlowNode::ListDecl {
name: name.to_string(),
producer: Producer::List(parse_list_values(&f.values)),
};
ed.wizard_apply(app, &path, node);
}
Wizard::Folders(f) => {
let path = f.path.clone();
let (body, glob, roles) = match ed.flow.as_ref().and_then(|fl| node_at(fl, &path)) {
Some(FlowNode::ForEach {
body,
producer: Producer::Folders { glob, roles, .. },
..
}) => (body.clone(), glob.clone(), roles.clone()),
_ => return,
};
let parallel = parallel_spec(f.parallel, &f.degree);
let node = FlowNode::ForEach {
pattern: Pattern::single(if f.var.trim().is_empty() {
"FOLDER".to_string()
} else {
f.var.trim().to_string()
}),
producer: Producer::Folders {
dir: f.dir.clone(),
glob,
roles,
},
body,
parallel,
};
ed.wizard_apply(app, &path, node);
}
Wizard::Vars(f) => {
let path = f.path.clone();
let Some(node) = f.node() else { return };
ed.wizard_apply(app, &path, node);
}
Wizard::Computed(f) => {
let path = f.path.clone();
let (template, alias) = (f.template.trim(), f.alias.trim());
if template.is_empty() || alias.is_empty() {
return;
}
let node = FlowNode::Report(ReportStmt::Computed {
template: template.to_string(),
name: alias.to_string(),
stats: f
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
image: f.clauses.image(),
truth: f.clauses.truth(),
detail: f.clauses.detail,
});
ed.wizard_apply(app, &path, node);
}
Wizard::Raw(f) => {
let path = f.path.clone();
if let Some(node) = crate::report::edit::parse_one_node(&f.text, f.is_loop) {
ed.wizard_apply(app, &path, node);
}
}
Wizard::WithField(f) => {
let path = f.path.clone();
let name = f.name.trim().to_string();
let query = f.query.trim().to_string();
if name.is_empty() || query.is_empty() {
return;
}
let index = f.index;
let stats = f.stats();
let clauses = f.clauses.clone();
ed.commit_edit(app, |flow| match index {
Some(i) => {
crate::report::edit::set_with_field(
flow, &path, i, &name, &query, stats, &clauses,
);
}
None => {
if let Some(i) = crate::report::edit::add_with_field(
flow,
&path,
&name,
&query,
stats.clone(),
) {
crate::report::edit::set_with_field(
flow, &path, i, &name, &query, stats, &clauses,
);
}
}
});
ed.selection = path;
}
}
}
fn baseline_show_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
fields: &mut [(String, bool)],
) {
if fields.is_empty() {
return;
}
ui.horizontal(|ui| {
ui.add_space(24.0);
egui::Frame::new()
.fill(th.panel)
.stroke(egui::Stroke::new(1.0, th.dim))
.corner_radius(4.0)
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(s.node_envs_baseline_show).color(th.accent));
ui.label(
RichText::new(format!("({})", s.node_envs_baseline_show_applies))
.italics()
.color(th.dim),
);
});
ui.label(RichText::new(s.node_envs_baseline_show_hint).color(th.dim));
egui::ScrollArea::vertical()
.id_salt("pt_envs_baseline_show")
.max_height(140.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (name, on) in fields.iter_mut() {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
});
});
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::flow::{EnvClause, RoleRef};
fn compare_form(fields: Vec<(String, bool)>) -> EnvsForm {
EnvsForm {
path: vec![0],
var: "TARGET".into(),
show_stats: Default::default(),
compare: true,
parallel: false,
degree: String::new(),
entries: vec![
EnvEntry {
name: "prod".into(),
baseline: true,
file: false,
},
EnvEntry {
name: "staging".into(),
baseline: false,
file: false,
},
],
choices: vec!["prod".into(), "staging".into()],
snapshots: Vec::new(),
baseline_show_fields: fields,
}
}
#[test]
fn a_named_single_variable_becomes_an_alias_and_a_pair_stays_plain() {
let mut form = VarsForm {
path: vec![0],
vars: vec![("TIER".into(), true), ("REGION".into(), false)],
other: String::new(),
alias: "Plan".into(),
stats: StatKind::CHOOSABLE.iter().map(|k| (*k, false)).collect(),
clauses: crate::report::edit::ClauseForm::default(),
};
assert!(
matches!(
form.node(),
Some(FlowNode::Report(ReportStmt::VarAs { ref var, ref name, .. }))
if var == "TIER" && name == "Plan"
),
"a single ticked variable with a name is reported AS that name"
);
form.vars[1].1 = true;
assert!(
matches!(form.node(), Some(FlowNode::Report(ReportStmt::Vars(ref v))) if v == &["TIER".to_string(), "REGION".to_string()]),
"two ticked variables report both columns and ignore the name"
);
}
#[test]
fn a_clause_alone_promotes_a_variable_to_the_named_form() {
let mut form = VarsForm {
path: vec![0],
vars: vec![("FILE".into(), true)],
other: String::new(),
alias: String::new(),
stats: Vec::new(),
clauses: crate::report::edit::ClauseForm::default(),
};
assert!(
matches!(form.node(), Some(FlowNode::Report(ReportStmt::Vars(_)))),
"with no name and no clauses it is still a plain column"
);
form.clauses.truth = "{{ want }}".into();
let Some(FlowNode::Report(ReportStmt::VarAs { name, truth, .. })) = form.node() else {
panic!("a ground truth needs the named form")
};
assert_eq!(name, "FILE", "the column falls back to the variable name");
assert_eq!(truth.as_deref(), Some("{{ want }}"));
}
#[test]
fn fit_replaces_a_typed_size_rather_than_joining_it() {
let mut c = crate::report::edit::ClauseForm::default();
c.toggle_image();
c.height = "96".into();
let spec = c.image().expect("an image clause");
assert_eq!((spec.height, spec.width, spec.fit), (Some(96), None, false));
c.toggle_fit();
let spec = c.image().expect("still an image clause");
assert_eq!((spec.height, spec.width, spec.fit), (None, None, true));
assert!(c.height.is_empty(), "the hidden size is cleared, not kept");
c.toggle_image();
assert_eq!(c.image(), None);
}
#[test]
fn a_typed_variable_is_reported_even_when_nothing_is_ticked() {
let form = VarsForm {
path: vec![0],
vars: vec![("TIER".into(), false)],
other: " RUNTIME_ONLY ".into(),
alias: String::new(),
stats: Vec::new(),
clauses: crate::report::edit::ClauseForm::default(),
};
assert!(
matches!(form.node(), Some(FlowNode::Report(ReportStmt::Vars(ref v))) if v == &["RUNTIME_ONLY".to_string()]),
"the typed name is trimmed and reported"
);
}
#[test]
fn ticking_a_baseline_field_writes_a_show_clause_on_the_baseline_role() {
let form = compare_form(vec![
("Time".into(), true),
("Status".into(), false),
("Response".into(), true),
]);
let Some(EnvClause::Roles {
baseline,
comparisons,
baseline_show,
}) = form.clause()
else {
panic!("a compare form describes a Roles clause")
};
assert_eq!(
baseline,
vec![RoleRef::Env("prod".into())],
"the baseline role is unaffected by the SHOW"
);
assert_eq!(
comparisons,
vec![RoleRef::Env("staging".into())],
"and so are the comparisons"
);
assert_eq!(
baseline_show,
vec!["Time".to_string(), "Response".to_string()],
"the ticked fields become the SHOW list, in the checklist's own order"
);
}
#[test]
fn editing_the_baseline_checklist_keeps_a_show_field_s_statistics_clause() {
let mut form = compare_form(vec![("Time".into(), true), ("Status".into(), true)]);
form.show_stats.insert("Time".into(), vec![StatKind::Mean]);
let Some(EnvClause::Roles { baseline_show, .. }) = form.clause() else {
panic!("a compare form describes a Roles clause")
};
let time = baseline_show
.iter()
.find(|f| f.field == "Time")
.expect("Time stays in the SHOW list");
assert_eq!(
time.stats,
vec![StatKind::Mean],
"its STATISTICS clause survives the round trip: {baseline_show:?}"
);
}
#[test]
fn ticking_nothing_leaves_the_baseline_with_no_show_clause_at_all() {
let form = compare_form(vec![("Time".into(), false), ("Status".into(), false)]);
let Some(EnvClause::Roles { baseline_show, .. }) = form.clause() else {
panic!("a compare form describes a Roles clause")
};
assert!(
baseline_show.is_empty(),
"nothing ticked writes no SHOW: {baseline_show:?}"
);
}
#[test]
fn the_request_name_field_and_its_picker_are_the_same_box() {
use eframe::egui;
let ctx = egui::Context::default();
let spec = crate::theme::builtin_presets()[0].clone();
let th = super::super::theme::GuiTheme::from_spec(&spec);
th.apply(&ctx);
let s = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let mut f = param_form("upload", Vec::new());
f.titles = vec!["upload".into(), "warmup".into()];
fn rects_filled(shape: &egui::Shape, fill: egui::Color32, out: &mut Vec<egui::Rect>) {
match shape {
egui::Shape::Rect(r) if r.fill == fill => out.push(r.rect),
egui::Shape::Vec(v) => {
for sh in v {
rects_filled(sh, fill, out);
}
}
_ => {}
}
}
let mut out = egui::FullOutput::default();
for _ in 0..4 {
out = ctx.run_ui(Default::default(), |ui| {
request_ui(ui, &th, &s, &mut f);
});
}
let mut fields = Vec::new();
let mut buttons = Vec::new();
let button_fill = ctx
.style_of(egui::Theme::Dark)
.visuals
.widgets
.inactive
.bg_fill;
for cs in &out.shapes {
rects_filled(&cs.shape, th.field(), &mut fields);
rects_filled(&cs.shape, button_fill, &mut buttons);
}
let topmost = |v: &[egui::Rect]| -> egui::Rect {
v.iter()
.copied()
.min_by(|a, b| a.top().total_cmp(&b.top()))
.expect("painted")
};
let field = topmost(&fields);
let picker = topmost(&buttons);
assert_eq!(
(field.top(), field.bottom()),
(picker.top(), picker.bottom()),
"field {field:?} and picker {picker:?} must occupy the same band"
);
}
fn param_form(name: &str, using: Vec<UsingItem>) -> RequestForm {
let params_by_request: std::collections::HashMap<String, Vec<(String, String)>> = [(
"upload".to_string(),
vec![("FILE".to_string(), "./x".to_string())],
)]
.into_iter()
.collect();
let params = crate::report::edit::param_rows(
params_by_request
.get(name)
.map(Vec::as_slice)
.unwrap_or(&[]),
&using,
);
RequestForm {
path: vec![0],
name: name.to_string(),
titles: vec!["upload".into(), "warmup".into()],
report: true,
response: None,
alias: String::new(),
fields: Vec::new(),
show_stats: Default::default(),
hide_fields: Vec::new(),
with: Vec::new(),
overrides: crate::report::edit::override_rows(&using),
using,
params,
params_by_request,
}
}
#[test]
fn the_gui_request_form_writes_ticked_parameters_and_keeps_overrides() {
let carried = vec![UsingItem::Override {
target: crate::report::flow::OverrideTarget::parse("header.X-Run").unwrap(),
value: "1".into(),
}];
let mut f = param_form("upload", carried);
assert_eq!(f.params.len(), 1, "the declared parameter is offered");
assert!(!f.params[0].required, "and starts un-ticked");
f.params[0].required = true;
let FlowNode::Report(ReportStmt::Request { using, .. }) = f.to_node() else {
panic!("a reported request node");
};
assert_eq!(
using.iter().map(UsingItem::text).collect::<Vec<_>>(),
vec!["FILE".to_string(), "header.X-Run = \"1\"".to_string()]
);
}
#[test]
fn the_using_section_is_offered_even_when_the_request_declares_nothing() {
let mut f = param_form("warmup", Vec::new());
assert!(f.params.is_empty(), "the precondition: nothing declared");
let ctx = egui::Context::default();
let th = super::super::theme::GuiTheme::from_spec(&crate::theme::default_preset());
let s = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let mut painted = Vec::new();
let full = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(700.0, 700.0),
)),
..Default::default()
},
|ui| request_ui(ui, &th, &s, &mut f),
);
fn texts(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
for cs in &full.shapes {
texts(&cs.shape, &mut painted);
}
let all = painted.join("\n");
assert!(
all.contains(s.node_form_using),
"the heading is there: {all}"
);
assert!(
all.contains(s.node_form_using_none),
"and it says why the list is empty, and where to fix it: {all}"
);
assert!(
all.contains(s.node_form_override_add),
"and an override can be added without leaving the dialog: {all}"
);
}
#[test]
fn an_override_edited_in_the_form_is_written_to_the_node() {
let mut f = param_form("warmup", Vec::new());
f.overrides.push(crate::report::edit::OverrideRow {
target: "multipart.document".into(),
value: "{{FILE}}".into(),
});
let FlowNode::Report(ReportStmt::Request { using, .. }) = f.to_node() else {
panic!("a reported request node");
};
assert_eq!(
using.iter().map(UsingItem::text).collect::<Vec<_>>(),
vec!["multipart.document = \"{{FILE}}\"".to_string()]
);
}
#[test]
fn renaming_the_request_re_derives_the_parameter_checklist() {
let mut f = param_form("upload", vec![UsingItem::Require("FILE".into())]);
assert!(f.params[0].required && f.params[0].default.is_some());
f.name = "warmup".into();
f.refresh_params();
assert_eq!(f.params.len(), 1);
assert_eq!(f.params[0].name, "FILE");
assert!(
f.params[0].default.is_none(),
"warmup declares nothing, so the requirement now reads as undeclared"
);
f.params[0].required = false;
let FlowNode::Report(ReportStmt::Request { using, .. }) = f.to_node() else {
panic!("a reported request node");
};
assert!(using.is_empty(), "un-ticking clears the clause");
}
#[test]
fn a_show_field_no_request_offers_is_still_listed_so_editing_cannot_drop_it() {
let flow = crate::report::parser::parse_flow(
"# collection: api.hurl\nFOR TARGET IN ENVS BASELINE(\"p\") SHOW(Handwritten), COMPARISON(\"s\")\n REPORT REQUEST login\nEND\n",
)
.expect("the fixture flow parses");
let FlowNode::ForEnvs { clause, body, .. } = &flow.nodes[0] else {
panic!("the fixture's first node is the ENVS loop")
};
let EnvClause::Roles { baseline_show, .. } = clause else {
panic!("the fixture's clause has roles")
};
let fields = crate::report::edit::baseline_show_choices(&[], body, baseline_show);
assert!(
fields.iter().any(|(n, on)| n == "Handwritten" && *on),
"the unrecognised field is offered, and offered already ticked: {fields:?}"
);
assert!(
fields.iter().any(|(n, _)| n == "Time"),
"alongside the intrinsics every request has: {fields:?}"
);
}
#[test]
fn a_baseline_show_checklist_is_only_offered_for_reported_requests() {
let nodes = vec![
FlowNode::Request {
name: "warmup".into(),
using: Vec::new(),
},
FlowNode::Report(crate::report::flow::ReportStmt::Request {
name: "login".into(),
alias: None,
using: Vec::new(),
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
}),
];
assert_eq!(
crate::report::edit::reported_requests(&nodes),
vec!["login".to_string()],
"only the reported request is a source of fields"
);
}
}
pub(super) fn apply_picked_dir(app: &mut GuiApp, picked: Option<std::path::PathBuf>) {
let Some(dir) = picked else {
return; };
let dir = dir.to_string_lossy().into_owned();
match app.report_editor.as_mut().and_then(|e| e.wizard.as_mut()) {
Some(Wizard::Files(f)) => f.dir = dir,
Some(Wizard::Folders(f)) => f.dir = dir,
_ => {}
}
}