crates/forge/ents-forge/src/present.rs
present.rshistorycomment 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) |
| 11 | use facet::{Facet, Field, Peek}; |
| 12 | |
| 13 | /// One rendered field: its declared name and display value. |
| 14 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 15 | pub 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)] |
| 26 | pub 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 | |
| 33 | impl 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] |
| 63 | pub 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] |
| 104 | pub 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] |
| 134 | pub 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)] |
| 165 | pub 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] |
| 186 | pub 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] |
| 206 | pub 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)] |
| 215 | enum 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)] |
| 223 | struct FieldPolicy { |
| 224 | skip: bool, |
| 225 | head: bool, |
| 226 | col: bool, |
| 227 | skip_empty: bool, |
| 228 | id: bool, |
| 229 | body: bool, |
| 230 | } |
| 231 | |
| 232 | impl 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. |
| 247 | struct 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. |
| 257 | fn 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`]. |
| 285 | fn 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). |
| 304 | fn 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. |
| 324 | fn 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)] |
| 338 | mod 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 | } |