git-ents.gitmain
⌘K
foforge
present.rs460 lines · 15.3 KB · rusthistorycomment on this file
1//! Schema-driven entity output: one reflection walk over any
2//! `#[derive(Facet)]` entity's [`facet::Shape`], presentation policy read
3//! from the `ents` attributes declared on the entity's own fields
4//! ([`ents_attrs::Attr`]) — never from a branch on the concrete entity
5//! type. The CLI derives its `show` lines, `list` columns, and porcelain
6//! records here; a surface that genuinely needs a domain-specific line (a
7//! comment's projected anchor, a review's thread) appends it beside this
8//! module's output rather than reaching into the walk.
9
10// @relation(model.presentation, scope=file)
11use facet::{Facet, Field, Peek};
12
13/// One rendered field: its declared name and display value.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FieldLine {
16 /// The field's declared name, exactly as the struct spells it.
17 pub name: &'static str,
18 /// The field's rendered value.
19 pub value: String,
20}
21
22/// An entity's `show` view: `field: value` lines in declaration order,
23/// with the `ents::body`-marked field split out so a caller can interleave
24/// domain-specific lines before it. Its `Display` renders lines then body.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct View {
27 /// Every non-skipped, non-body field's line, in declaration order.
28 pub lines: Vec<FieldLine>,
29 /// The `ents::body` field's line, rendered last.
30 pub body: Option<FieldLine>,
31}
32
33impl std::fmt::Display for View {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 for line in self.lines.iter().chain(&self.body) {
36 writeln!(f, "{}: {}", line.name, line.value)?;
37 }
38 Ok(())
39 }
40}
41
42/// Reflect `value` into its human `show` view: one `field: value` line per
43/// non-skipped field, empties omitted where `ents::skip_empty` says so,
44/// id-valued fields abbreviated, the `ents::body` field split out last.
45///
46/// # Examples
47///
48/// ```
49/// let issue = ents_forge::Issue {
50/// title: "gate rejects a valid signature".to_owned(),
51/// body: "steps to reproduce...".to_owned(),
52/// state: "open".to_owned(),
53/// assignees: vec![],
54/// labels: vec!["bug".to_owned(), "gate".to_owned()],
55/// };
56/// let rendered = ents_forge::present::view(&issue).to_string();
57/// assert_eq!(
58/// rendered,
59/// "title: gate rejects a valid signature\nstate: open\nlabels: bug, gate\nbody: steps to reproduce...\n"
60/// );
61/// ```
62#[must_use]
63pub fn view<T: Facet<'static>>(value: &T) -> View {
64 let mut lines = Vec::new();
65 let mut body = None;
66 for row in rows(value, Audience::Human) {
67 if row.policy.skip_empty && row.empty {
68 continue;
69 }
70 let line = FieldLine {
71 name: row.name,
72 value: row.value,
73 };
74 if row.policy.body {
75 body = Some(line);
76 } else {
77 lines.push(line);
78 }
79 }
80 View { lines, body }
81}
82
83/// Reflect `value` into its human `list` columns: `ents::head` fields
84/// first, then `ents::col` fields, each set in declaration order,
85/// id-valued fields abbreviated. The caller prepends the row's own id
86/// column(s) and joins with tabs.
87///
88/// # Examples
89///
90/// ```
91/// let issue = ents_forge::Issue {
92/// title: "gate rejects a valid signature".to_owned(),
93/// body: String::new(),
94/// state: "open".to_owned(),
95/// assignees: vec![],
96/// labels: vec![],
97/// };
98/// assert_eq!(
99/// ents_forge::present::columns(&issue),
100/// vec!["open".to_owned(), "gate rejects a valid signature".to_owned()]
101/// );
102/// ```
103#[must_use]
104pub fn columns<T: Facet<'static>>(value: &T) -> Vec<String> {
105 let rows = rows(value, Audience::Human);
106 let heads = rows.iter().filter(|row| row.policy.head);
107 let cols = rows.iter().filter(|row| row.policy.col && !row.policy.head);
108 heads.chain(cols).map(|row| row.value.clone()).collect()
109}
110
111/// Reflect `value` into one porcelain record (`lens.parity`), the record
112/// grammar `git ents comment list --porcelain` established: a head line of
113/// `id` then each `ents::head` field's value, space-separated; one
114/// `<name> <value>` line per remaining field (omitted when
115/// `ents::skip_empty` and empty); the `ents::body` field's lines each
116/// tab-prefixed. Ids render full, never abbreviated (`lens.porcelain`).
117///
118/// # Examples
119///
120/// ```
121/// let effect = ents_model::Effect {
122/// name: "unit".to_owned(),
123/// trigger: "rev(refs/heads/main)".to_owned(),
124/// toolchains: vec![],
125/// run: "cargo test".to_owned(),
126/// };
127/// assert_eq!(
128/// ents_forge::present::record("unit", &effect),
129/// "unit\ntrigger rev(refs/heads/main)\nrun cargo test\n"
130/// );
131/// ```
132// @relation(lens.porcelain, scope=function)
133#[must_use]
134pub fn record<T: Facet<'static>>(id: &str, value: &T) -> String {
135 let rows = rows(value, Audience::Porcelain);
136 let mut out = id.to_owned();
137 for row in rows.iter().filter(|row| row.policy.head) {
138 out.push(' ');
139 out.push_str(&row.value);
140 }
141 out.push('\n');
142 for row in &rows {
143 if row.policy.head || row.policy.body || (row.policy.skip_empty && row.empty) {
144 continue;
145 }
146 out.push_str(row.name);
147 out.push(' ');
148 out.push_str(&row.value);
149 out.push('\n');
150 }
151 if let Some(body) = rows.iter().find(|row| row.policy.body) {
152 for line in body.value.lines() {
153 out.push('\t');
154 out.push_str(line);
155 out.push('\n');
156 }
157 }
158 out
159}
160
161/// One walked field with its declared presentation roles — for a surface
162/// (the web) that renders its own markup over the same attribute-driven
163/// policy [`view`] and [`columns`] apply.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct PresentedField {
166 /// The field's declared name, exactly as the struct spells it.
167 pub name: &'static str,
168 /// The field's rendered value, human audience: ids abbreviated.
169 pub value: String,
170 /// Whether the value counts as empty for `ents::skip_empty`.
171 pub empty: bool,
172 /// `ents::head`: a leading list column.
173 pub head: bool,
174 /// `ents::col`: a list column after the head columns.
175 pub col: bool,
176 /// `ents::skip_empty`: omit from a view when empty.
177 pub skip_empty: bool,
178 /// `ents::body`: the message body, rendered last.
179 pub body: bool,
180}
181
182/// Walk `value` for a human audience: every non-`ents::skip` field with
183/// its rendered value and declared roles, in declaration order. A
184/// non-struct `T` yields no fields.
185#[must_use]
186pub fn fields<T: Facet<'static>>(value: &T) -> Vec<PresentedField> {
187 rows(value, Audience::Human)
188 .into_iter()
189 .map(|row| PresentedField {
190 name: row.name,
191 value: row.value,
192 empty: row.empty,
193 head: row.policy.head,
194 col: row.policy.col,
195 skip_empty: row.policy.skip_empty,
196 body: row.policy.body,
197 })
198 .collect()
199}
200
201/// [`record`] over every `(id, entity)` row, records separated by one
202/// blank line — the whole `--porcelain` output for a listing
203/// (`lens.porcelain`).
204// @relation(lens.porcelain, scope=function)
205#[must_use]
206pub fn porcelain<T: Facet<'static>>(rows: &[(String, T)]) -> String {
207 rows.iter()
208 .map(|(id, value)| record(id, value))
209 .collect::<Vec<_>>()
210 .join("\n")
211}
212
213/// Who the rendering is for: humans get abbreviated ids, porcelain full.
214#[derive(Clone, Copy, PartialEq, Eq)]
215enum Audience {
216 Human,
217 Porcelain,
218}
219
220/// The presentation roles one field declares via `#[facet(ents::...)]` —
221/// the parsed form of [`ents_attrs::Attr`], read once per field.
222#[derive(Default, Clone, Copy)]
223struct FieldPolicy {
224 skip: bool,
225 head: bool,
226 col: bool,
227 skip_empty: bool,
228 id: bool,
229 body: bool,
230}
231
232impl FieldPolicy {
233 fn of(field: &Field) -> Self {
234 let has = |key: &str| field.has_attr(Some("ents"), key);
235 Self {
236 skip: has("skip"),
237 head: has("head"),
238 col: has("col"),
239 skip_empty: has("skip_empty"),
240 id: has("id"),
241 body: has("body"),
242 }
243 }
244}
245
246/// One walked field: its policy, name, rendered value, and emptiness.
247struct Row {
248 policy: FieldPolicy,
249 name: &'static str,
250 value: String,
251 empty: bool,
252}
253
254/// Walk `value`'s shape into one [`Row`] per non-`ents::skip` field, in
255/// declaration order. A non-struct `T` yields no rows — reflection is a
256/// presentation convenience, never a correctness path.
257fn rows<T: Facet<'static>>(value: &T, audience: Audience) -> Vec<Row> {
258 let peek = Peek::new(value);
259 let Ok(structure) = peek.into_struct() else {
260 return Vec::new();
261 };
262 structure
263 .ty()
264 .fields
265 .iter()
266 .enumerate()
267 .filter_map(|(index, field)| {
268 let policy = FieldPolicy::of(field);
269 if policy.skip {
270 return None;
271 }
272 let peek = structure.field(index).ok()?;
273 Some(Row {
274 policy,
275 name: field.name,
276 value: render(peek, policy, audience),
277 empty: is_empty(peek),
278 })
279 })
280 .collect()
281}
282
283/// Render one field's value: id-valued fields as full-or-abbreviated ids
284/// (raw 20-byte oids as hex), otherwise [`scalar`].
285fn render(peek: Peek<'_, '_>, policy: FieldPolicy, audience: Audience) -> String {
286 if !policy.id {
287 return scalar(peek);
288 }
289 let full = peek
290 .get::<[u8; 20]>()
291 .map(|bytes| bytes.iter().map(|byte| format!("{byte:02x}")).collect())
292 .unwrap_or_else(|_| scalar(peek));
293 match audience {
294 Audience::Human => crate::abbreviate_id(&full).to_owned(),
295 Audience::Porcelain => full,
296 }
297}
298
299/// Render one value as plain text: a `str` verbatim, an `Option` as its
300/// inner value (or empty), a list as its items joined with `", "`, and
301/// anything else via its own `Display` — falling back to `Debug` so an
302/// enum without `Display` still shows its variant name rather than an
303/// opaque placeholder (the same rule `ents-web`'s renderer applies).
304fn scalar(peek: Peek<'_, '_>) -> String {
305 if let Some(text) = peek.as_str() {
306 return text.to_owned();
307 }
308 if let Ok(option) = peek.into_option() {
309 return option.value().map(scalar).unwrap_or_default();
310 }
311 if let Ok(list) = peek.into_list_like() {
312 return list.iter().map(scalar).collect::<Vec<_>>().join(", ");
313 }
314 let displayed = format!("{peek}");
315 if displayed.starts_with('\u{27e8}') {
316 format!("{peek:?}")
317 } else {
318 displayed
319 }
320}
321
322/// Whether a value counts as empty for `ents::skip_empty`: an empty
323/// string, a `None`, or an empty list.
324fn is_empty(peek: Peek<'_, '_>) -> bool {
325 if let Some(text) = peek.as_str() {
326 return text.is_empty();
327 }
328 if let Ok(option) = peek.into_option() {
329 return option.is_none();
330 }
331 if let Ok(list) = peek.into_list_like() {
332 return list.is_empty();
333 }
334 false
335}
336
337#[cfg(test)]
338mod tests {
339 #![allow(clippy::expect_used, reason = "unit test")]
340
341 use ents_model::MemberId;
342 use gix_hash::ObjectId;
343 use rstest::rstest;
344
345 use super::*;
346 use crate::Issue;
347 use crate::comment::Comment;
348 use crate::review::{Review, Verdict};
349
350 fn issue() -> Issue {
351 Issue {
352 title: "gate rejects a valid signature".to_owned(),
353 body: "first line\n\nthird line".to_owned(),
354 state: "open".to_owned(),
355 assignees: vec![MemberId::new("jdc"), MemberId::new("alice")],
356 labels: vec![],
357 }
358 }
359
360 fn review() -> Review {
361 let target =
362 ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex");
363 Review::new(target, Verdict::RequestChanges, "please fix")
364 }
365
366 /// The whole walk is attribute-driven: the same [`view`] call renders
367 /// an issue and a comment with each one's own field policy, no branch
368 /// on the concrete type anywhere in this module.
369 #[rstest]
370 // @relation(model.presentation, model.issue, model.comment, scope=function, role=Verifies)
371 fn view_orders_lines_by_declaration_with_the_body_last_and_empties_skipped() {
372 let rendered = view(&issue()).to_string();
373 assert_eq!(
374 rendered,
375 "title: gate rejects a valid signature\nstate: open\nassignees: jdc, alice\nbody: first line\n\nthird line\n"
376 );
377
378 let comment = Comment {
379 body: "looks off".to_owned(),
380 state: "open".to_owned(),
381 anchor: None,
382 context: None,
383 parent: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
384 };
385 assert_eq!(
386 view(&comment).to_string(),
387 "state: open\nparent: 0123456\nbody: looks off\n"
388 );
389 }
390
391 /// `model.issue`: human columns abbreviate id-valued fields the way
392 /// git abbreviates oids; head columns lead, then plain columns.
393 #[rstest]
394 // @relation(model.presentation, model.issue, model.review, scope=function, role=Verifies)
395 fn columns_lead_with_head_fields_and_abbreviate_ids() {
396 assert_eq!(
397 columns(&issue()),
398 vec![
399 "open".to_owned(),
400 "gate rejects a valid signature".to_owned()
401 ]
402 );
403 assert_eq!(
404 columns(&review()),
405 vec![
406 "0123456".to_owned(),
407 "request-changes".to_owned(),
408 "active".to_owned()
409 ]
410 );
411 }
412
413 /// `lens.parity`, `model.issue`: a porcelain record carries the full
414 /// id and full field values on a space-separated head line, keyed
415 /// lines for the rest, and the body tab-prefixed line by line.
416 #[rstest]
417 // @relation(lens.porcelain, lens.parity, model.issue, model.review, scope=function, role=Verifies)
418 fn record_renders_full_ids_keyed_lines_and_a_tab_prefixed_body() {
419 let id = "89abcdef0123456789abcdef0123456789abcdef";
420 assert_eq!(
421 record(id, &issue()),
422 format!(
423 "{id} open\ntitle gate rejects a valid signature\nassignees jdc, alice\n\tfirst line\n\t\n\tthird line\n"
424 )
425 );
426 assert_eq!(
427 record("0123456789abcdef0123456789abcdef01234567 jdc", &review()),
428 "0123456789abcdef0123456789abcdef01234567 jdc \
429 0123456789abcdef0123456789abcdef01234567 request-changes active\n\tplease fix\n"
430 );
431 }
432
433 /// Records separate with exactly one blank line, mirroring the comment
434 /// porcelain grammar.
435 #[rstest]
436 // @relation(lens.porcelain, lens.parity, scope=function, role=Verifies)
437 fn porcelain_separates_records_with_one_blank_line() {
438 let rows = vec![
439 ("a".repeat(40), issue()),
440 ("b".repeat(40), issue()),
441 ];
442 let rendered = porcelain(&rows);
443 assert_eq!(rendered.split("\n\n").count(), 2, "{rendered}");
444 assert!(
445 rendered.contains(&format!("\tthird line\n\n{}", "b".repeat(40))),
446 "a blank body line renders as a lone tab, so only the record \
447 separator is a true blank line: {rendered}"
448 );
449 assert!(rendered.ends_with("third line\n"));
450 }
451
452 /// A non-struct value yields no rows, never a panic — reflection is a
453 /// presentation convenience, not a correctness path.
454 #[rstest]
455 fn a_non_struct_value_renders_as_nothing() {
456 let empty = view(&42u32);
457 assert!(empty.lines.is_empty() && empty.body.is_none());
458 assert!(columns(&42u32).is_empty());
459 }
460}