git-ents.gitmain
⌘K
foforge
form.rs346 lines · 12.7 KB · rusthistorycomment on this file
1//! Action-shape-derived entity forms: the same `#[derive(Facet)]` action
2//! enums the CLI parses (`ents_forge::issue::IssueAction`,
3//! `ents_forge::review::ReviewAction`) drive a web form's controls and its
4//! parse — one field list, declared on the action variant, obeyed by both
5//! frontends (`lens.parity`) instead of a hand-declared form struct per
6//! route. [`action_form`] renders one control per variant field
7//! (`Vec` → comma-separated input, `bool` → checkbox, an `ents::compose`
8//! field → textarea, anything else → text input), with per-field
9//! overrides for the controls a page legitimately customizes (a state
10//! picker, a verdict picker); [`parse_action`] reads the posted pairs
11//! back into the action variant itself. CSRF stays
12//! [`crate::pages::csrf_input`]'s hidden field and the handler's
13//! `require_csrf` check; the PRG redirect stays the handler's own.
14//!
15//! Path-bound values (an `args::positional` id, a review's target) never
16//! render as controls — the caller appends them to the posted pairs
17//! before parsing. `PathBuf`-shaped fields (`--key`, a local signing-key
18//! path no browser can supply) are skipped by both directions.
19
20use std::path::PathBuf;
21
22use facet::{Facet, Field, Type, UserType};
23use facet_reflect::Partial;
24use maud::{Markup, html};
25
26use crate::error::{Error, Result};
27use crate::session::Session;
28
29/// The web-varying parts of one derived form: where it posts, what its
30/// submit control says, and the per-field prefills and overrides.
31pub struct Spec<'a> {
32 /// The form's POST target.
33 pub action: &'a str,
34 /// The submit button's label.
35 pub submit: &'a str,
36 /// A cancel link's href, rendered beside the submit button.
37 pub cancel: Option<&'a str>,
38 /// Prefill values by field name (a `Vec` field prefills comma-joined).
39 pub values: &'a [(&'a str, String)],
40 /// Custom controls by field name; an empty override omits the field.
41 pub overrides: &'a [(&'a str, Markup)],
42}
43
44/// Render the form `T`'s variant `variant` declares: one derived control
45/// per web-suppliable field in declaration order, `spec.overrides`
46/// slotted in place, the session's CSRF hidden field leading.
47// @relation(lens.forms, scope=function)
48#[must_use]
49pub fn action_form<T: Facet<'static>>(variant: &str, session: &Session, spec: &Spec<'_>) -> Markup {
50 let fields = variant_fields::<T>(variant).unwrap_or_default();
51 html! {
52 form method="post" action=(spec.action) {
53 (crate::pages::csrf_input(session))
54 @for field in fields {
55 @if let Some((_, markup)) = spec.overrides.iter().find(|(name, _)| *name == field.name) {
56 (markup)
57 } @else if let Some(markup) = control(field, value_of(spec, field.name)) {
58 (markup)
59 }
60 }
61 @if let Some(cancel) = spec.cancel {
62 div.composer-buttons {
63 a.composer-cancel href=(cancel) { "Cancel" }
64 button type="submit" { (spec.submit) }
65 }
66 } @else {
67 button type="submit" { (spec.submit) }
68 }
69 }
70 }
71}
72
73/// Parse posted `pairs` (path-bound values appended by the caller) into
74/// `T`'s variant `variant`, by the same shape-to-control mapping
75/// [`action_form`] renders: a `Vec` splits on commas/whitespace across
76/// every posted occurrence, an empty `Option<String>` is `None`, a `bool`
77/// is its checkbox's presence, and an unposted field takes its declared
78/// default. Unknown pairs (the CSRF token, a `return_to`) are ignored.
79///
80/// # Errors
81///
82/// [`Error::InvalidArgument`] if `T` has no such variant, a field's shape
83/// is not one this mapping speaks, or the value cannot be set.
84// @relation(lens.forms, scope=function)
85pub fn parse_action<T: Facet<'static>>(variant: &str, pairs: &[(String, String)]) -> Result<T> {
86 let malformed = |source: &dyn std::fmt::Display| {
87 Error::InvalidArgument(format!("malformed {variant} form: {source}"))
88 };
89 let fields = variant_fields::<T>(variant)?;
90 let mut partial = Partial::alloc::<T>()
91 .map_err(|source| malformed(&source))?
92 .select_variant_named(variant)
93 .map_err(|source| malformed(&source))?;
94 for (index, field) in fields.iter().enumerate() {
95 let posted: Vec<&str> = pairs
96 .iter()
97 .filter(|(name, _)| name == field.name)
98 .map(|(_, value)| value.as_str())
99 .collect();
100 let shape = field.shape();
101 partial = if shape.is_type::<Vec<String>>() {
102 partial.set_field(field.name, split_list(&posted))
103 } else if let Some(first) = posted.first() {
104 if shape.is_type::<String>() {
105 partial.set_field(field.name, (*first).to_owned())
106 } else if shape.is_type::<Option<String>>() {
107 let value = posted.iter().find(|value| !value.trim().is_empty());
108 partial.set_field(field.name, value.map(|value| (*value).to_owned()))
109 } else if shape.is_type::<bool>() {
110 partial.set_field(field.name, matches!(*first, "true" | "on" | "1"))
111 } else {
112 return Err(Error::InvalidArgument(format!(
113 "unsupported form field: {}",
114 field.name
115 )));
116 }
117 } else {
118 partial.set_nth_field_to_default(index)
119 }
120 .map_err(|source| malformed(&source))?;
121 }
122 partial
123 .build()
124 .map_err(|source| malformed(&source))?
125 .materialize::<T>()
126 .map_err(|source| malformed(&source))
127}
128
129/// The posted CSRF token, or empty when the field is absent — feeding
130/// `require_csrf`, which then refuses the empty token like any other
131/// mismatch.
132#[must_use]
133pub fn posted_csrf(pairs: &[(String, String)]) -> &str {
134 pairs
135 .iter()
136 .find(|(name, _)| name == crate::session::CSRF_FIELD)
137 .map_or("", |(_, value)| value.as_str())
138}
139
140/// `variant`'s field list on the action enum `T`.
141fn variant_fields<T: Facet<'static>>(variant: &str) -> Result<&'static [Field]> {
142 let Type::User(UserType::Enum(shape)) = T::SHAPE.ty else {
143 return Err(Error::InvalidArgument(format!(
144 "{} is not an action enum",
145 T::SHAPE
146 )));
147 };
148 shape
149 .variants
150 .iter()
151 .find(|candidate| candidate.name == variant)
152 .map(|found| found.data.fields)
153 .ok_or_else(|| Error::InvalidArgument(format!("no such action: {variant}")))
154}
155
156/// `field`'s derived control, or `None` for a field the web never
157/// renders: an `args::positional` value (bound into the route's own
158/// path) or a `PathBuf` (a local file path no browser form supplies).
159fn control(field: &Field, value: Option<&str>) -> Option<Markup> {
160 if field.has_attr(Some("args"), "positional") {
161 return None;
162 }
163 let shape = field.shape();
164 if shape.is_type::<PathBuf>() || shape.is_type::<Option<PathBuf>>() {
165 return None;
166 }
167 let name = field.name;
168 let label = title_case(name);
169 Some(if shape.is_type::<bool>() {
170 html! {
171 label {
172 input type="checkbox" name=(name) checked[value == Some("true")];
173 " " (label)
174 }
175 }
176 } else if shape.is_type::<Vec<String>>() {
177 html! {
178 label { (label) input type="text" name=(name) value=[value] placeholder="a, b"; }
179 }
180 } else if field.has_attr(Some("ents"), "compose") {
181 html! {
182 label { (label) textarea name=(name) { @if let Some(value) = value { (value) } } }
183 }
184 } else {
185 html! {
186 label { (label) input type="text" name=(name) value=[value]; }
187 }
188 })
189}
190
191/// `spec.values`'s prefill for `name`, if any.
192fn value_of<'a>(spec: &'a Spec<'_>, name: &str) -> Option<&'a str> {
193 spec.values
194 .iter()
195 .find(|(field, _)| *field == name)
196 .map(|(_, value)| value.as_str())
197}
198
199/// Every posted occurrence split on commas and whitespace, trimmed,
200/// empties dropped — one text input carries a whole `Vec` field.
201fn split_list(posted: &[&str]) -> Vec<String> {
202 posted
203 .iter()
204 .flat_map(|value| value.split([',', ' ', '\t', '\n']))
205 .map(str::trim)
206 .filter(|segment| !segment.is_empty())
207 .map(str::to_owned)
208 .collect()
209}
210
211/// A field name as its control's label: first letter upper-cased.
212fn title_case(name: &str) -> String {
213 let mut chars = name.chars();
214 chars.next().map_or_else(String::new, |first| {
215 first.to_uppercase().chain(chars).collect()
216 })
217}
218
219#[cfg(test)]
220mod tests {
221 #![allow(clippy::expect_used, clippy::panic, reason = "unit test")]
222
223 use ents_forge::issue::IssueAction;
224 use ents_forge::review::ReviewAction;
225 use rstest::rstest;
226
227 use super::*;
228
229 fn session() -> Session {
230 Session {
231 csrf: "tok".to_owned(),
232 member: None,
233 }
234 }
235
236 fn pairs(entries: &[(&str, &str)]) -> Vec<(String, String)> {
237 entries
238 .iter()
239 .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
240 .collect()
241 }
242
243 /// The action variant's own shape decides the controls: a compose
244 /// field is a textarea, a `Vec` a comma input, a `PathBuf` (`--key`)
245 /// and a positional id never render, and an override slots in place.
246 #[rstest]
247 // @relation(lens.forms, lens.parity, scope=function, role=Verifies)
248 fn action_form_derives_controls_from_the_variant_shape() {
249 let markup = action_form::<IssueAction>(
250 "New",
251 &session(),
252 &Spec {
253 action: "/issues",
254 submit: "Open Issue",
255 cancel: None,
256 values: &[],
257 overrides: &[("state", html! { span.custom-state {} })],
258 },
259 )
260 .into_string();
261 assert!(markup.contains("<textarea name=\"body\">"));
262 assert!(markup.contains("name=\"label\"") && markup.contains("name=\"assignee\""));
263 assert!(markup.contains("custom-state") && !markup.contains("name=\"state\""));
264 assert!(!markup.contains("name=\"key\""), "{markup}");
265 assert!(markup.contains("name=\"csrf\" value=\"tok\""));
266
267 let edit = action_form::<IssueAction>(
268 "Edit",
269 &session(),
270 &Spec {
271 action: "",
272 submit: "Save",
273 cancel: None,
274 values: &[("label", "bug, gate".to_owned())],
275 overrides: &[],
276 },
277 )
278 .into_string();
279 assert!(!edit.contains("name=\"id\""), "positional ids are path-bound");
280 assert!(edit.contains("value=\"bug, gate\""));
281 }
282
283 /// The same shape parses the post back: `Vec` fields split on commas
284 /// and whitespace, an empty optional is `None`, the unposted `--key`
285 /// defaults, and unknown pairs (csrf) are ignored.
286 #[rstest]
287 // @relation(lens.forms, lens.parity, scope=function, role=Verifies)
288 fn parse_action_reads_the_posted_pairs_into_the_variant() {
289 let action: IssueAction = parse_action(
290 "New",
291 &pairs(&[
292 ("title", "gate rejects a valid signature"),
293 ("body", ""),
294 ("state", "open"),
295 ("label", "bug, gate"),
296 ("assignee", "jdc alice"),
297 ("csrf", "tok"),
298 ]),
299 )
300 .expect("parses");
301 let IssueAction::New {
302 title,
303 body,
304 state,
305 label,
306 assignee,
307 key,
308 } = action
309 else {
310 panic!("wrong variant");
311 };
312 assert_eq!(title.as_deref(), Some("gate rejects a valid signature"));
313 assert_eq!(body, None);
314 assert_eq!(state, "open");
315 assert_eq!(label, vec!["bug".to_owned(), "gate".to_owned()]);
316 assert_eq!(assignee, vec!["jdc".to_owned(), "alice".to_owned()]);
317 assert_eq!(key, None);
318 }
319
320 /// An unposted field takes the declaration's own default — the same
321 /// `default = "open"` the CLI applies to an omitted flag.
322 #[rstest]
323 // @relation(lens.forms, lens.parity, scope=function, role=Verifies)
324 fn parse_action_defaults_an_unposted_field_from_the_declaration() {
325 let action: ReviewAction =
326 parse_action("New", &pairs(&[("verdict", "approve")])).expect("parses");
327 let ReviewAction::New {
328 target,
329 verdict,
330 body,
331 key,
332 } = action
333 else {
334 panic!("wrong variant");
335 };
336 assert_eq!(target, "HEAD");
337 assert_eq!(verdict, "approve");
338 assert_eq!(body, None);
339 assert_eq!(key, None);
340 }
341
342 #[rstest]
343 fn parse_action_refuses_an_unknown_variant() {
344 assert!(parse_action::<IssueAction>("Explode", &[]).is_err());
345 }
346}