moss_core/ast/extract_hero.rs
1//! Pre-render Hero extraction.
2//!
3//! Walks the top-level [`Document::blocks`] looking for the first
4//! `Block::Shortcode(Shortcode::Hero(_))`, removes it from the document,
5//! and returns the rendered hero HTML plus the OG-fallback fields the
6//! cover/description chains consume.
7//!
8//! # Why extract-at-caller (Phase 4 PR7a)
9//!
10//! Production has historically hoisted the first `:::hero` block to the
11//! article template's hero slot (the rendered HTML lands in the template
12//! header, separate from the body). `apply_typed_shortcodes` intercepted
13//! Hero variants in the AST before they reached the HTML renderer.
14//!
15//! When PR7a flips production to `render_document`, the body renderer
16//! walks the full block sequence. Letting it render a Hero shortcode
17//! inline would duplicate the slot rendering (hero appears in BOTH the
18//! template hero slot AND the body) OR force the hooks to emit nothing
19//! for Hero (which the renderer can't distinguish from a real empty
20//! emission).
21//!
22//! Extract-at-caller solves this cleanly:
23//! 1. The pipeline calls `extract_hero(&mut doc, &hooks)` BEFORE
24//! `render_document(&doc, &hooks)`.
25//! 2. `extract_hero` walks `doc.blocks`, finds the first Hero, calls
26//! the hooks to render it, removes the Hero block from `doc.blocks`,
27//! and returns the rendered HTML + captured OG fields.
28//! 3. `render_document` then walks the hero-free block sequence; no
29//! special Hero arm needed in the renderer or hooks.
30//!
31//! # Why top-level only
32//!
33//! Per the current SoCiviC + chps fixtures (the 4 client sites at Phase 4
34//! cutover), `:::hero` blocks only appear at the document top level
35//! (or as the only block in the document). The extractor doesn't descend
36//! into shortcode bodies. If a future fixture nests Hero inside Grid
37//! cells, this function will not extract it — the renderer's hooks
38//! implementation must decide what to do then (probably error or render
39//! inline). Keeping the extractor top-level matches today's interception
40//! semantics in `apply_typed_shortcodes`.
41
42use super::document::Document;
43use super::hooks::RenderHooks;
44use super::node::Block;
45use super::parser::{parse_fragment_with_config, ParseConfig};
46use crate::resolve::md_extract::{AssetPathSpan, MediaLineSpan, PathContainer};
47use super::shortcode::{HeroShortcode, Shortcode};
48use super::url::Url;
49
50/// Captured Hero data after extraction.
51///
52/// The pipeline threads these into `ParsedDocument`:
53/// - `html` — rendered `<section class="moss-hero">…</section>` lands in
54/// the template hero slot.
55/// - `image_url` — drives the homepage-hero rung of the cover chain.
56/// - `overlay_text` — drives the homepage-hero rung of the description
57/// chain (first-paragraph text extraction).
58#[derive(Debug, Default, Clone)]
59pub struct HeroExtraction {
60 pub html: String,
61 pub image_url: Option<String>,
62 pub overlay_text: Option<String>,
63}
64
65/// Find and extract the first top-level Hero shortcode from `doc`.
66///
67/// Returns `Some(HeroExtraction)` if a Hero was found (and removed from
68/// `doc.blocks`); returns `None` if the document has no Hero at the top
69/// level.
70///
71/// The Hero is rendered via `hooks.render_shortcode(&mut out, sc)` — the
72/// caller's `RenderHooks` impl decides the exact byte shape (production
73/// uses `PipelineHooks::render_shortcode` with the Hero arm calling
74/// `render_hero_html_typed`).
75pub fn extract_hero(doc: &mut Document, hooks: &dyn RenderHooks) -> Option<HeroExtraction> {
76 let hero_idx = doc.blocks.iter().position(|b| {
77 matches!(b, Block::Shortcode(Shortcode::Hero(_)))
78 })?;
79
80 // Pop the block from the document. Keep `block_meta` in sync — both
81 // vecs must remain the same length per the Document invariant
82 // asserted in `render_document`.
83 // Capture source_line before removing meta so the hero template slot
84 // can carry data-source-range for click-to-source in the preview.
85 let hero_source_line = doc.block_meta.get(hero_idx).and_then(|m| m.source_line);
86 let hero_block = doc.blocks.remove(hero_idx);
87 if hero_idx < doc.block_meta.len() {
88 doc.block_meta.remove(hero_idx);
89 }
90
91 // Pattern-match again to access the typed HeroShortcode for OG-fallback
92 // field capture.
93 let hero_shortcode = match &hero_block {
94 Block::Shortcode(sc) => sc,
95 _ => return None,
96 };
97 let hero_args = match hero_shortcode {
98 Shortcode::Hero(args) => args,
99 _ => return None,
100 };
101
102 // OG-fallback fields, read directly from the typed AST (post URL
103 // resolution by `resolve_urls`). The plan's Decision 1 calls this out:
104 // "captures `image_url` from `args.image` (Url::Resolved → href);
105 // captures `overlay_text` from the existing `args.overlay_text` field"
106 let image_url = match &hero_args.image {
107 Some(Url::Resolved(r)) => Some(r.href.clone()),
108 Some(Url::Unresolved(s)) => {
109 // Defensive: visit_urls_mut / resolve_urls should have
110 // classified this; if not, return raw so the cover chain
111 // still gets a value (silent None would erase the hero rung).
112 debug_assert!(
113 false,
114 "Url::Unresolved({s:?}) reached extract_hero — \
115 resolve_urls missing for Hero (image)"
116 );
117 Some(s.clone())
118 }
119 None => None,
120 };
121
122 // overlay_text: walk the typed overlay first; fall back to the
123 // captured-at-parse-time markdown source if the typed walk yields
124 // empty.
125 //
126 // Plan Decision 1 notes the existing `overlay_text` field is the
127 // one PR4.5 flagged as the TODO(phase4-cleanup) consumed at
128 // extract-at-caller. Today we still also walk the typed Vec<Block>
129 // (production builds overlay_text alongside the typed overlay, so
130 // either source works); when the TODO is closed, only the typed
131 // walk remains.
132 let walked = first_paragraph_plain_text(&hero_args.overlay);
133 let overlay_text = if !walked.trim().is_empty() {
134 Some(walked)
135 } else if !hero_args.overlay_text.trim().is_empty() {
136 Some(hero_args.overlay_text.clone())
137 } else {
138 None
139 };
140
141 // Render via the hooks' Hero arm. Production's `PipelineHooks`
142 // dispatches to `render_hero_html_typed` which produces the full
143 // section+slot+overlay HTML.
144 let mut html = String::new();
145 // Hero is hoisted out of the body to the article template's hero slot.
146 // The slot IS in the preview DOM and clickable, so we pass source_line
147 // so the rendered section carries data-source-range for click-to-source.
148 hooks.render_shortcode(&mut html, hero_shortcode, hero_source_line);
149
150 Some(HeroExtraction {
151 html,
152 image_url,
153 overlay_text,
154 })
155}
156
157/// Walk a typed block sequence and return the first paragraph's plain
158/// text (no markdown formatting). Returns empty string if no paragraph
159/// is found.
160///
161/// Mirrors the intent of `crate::build::page::meta::extract_description`
162/// but operates on the typed AST instead of markdown source — the
163/// described follow-up at `HeroShortcode::overlay_text` (TODO
164/// `phase4-cleanup`).
165fn first_paragraph_plain_text(blocks: &[Block]) -> String {
166 for block in blocks {
167 match block {
168 Block::Paragraph(inlines) => {
169 return crate::ast::plain_text::inlines_to_plain_text(inlines)
170 }
171 // Skip headings and shortcodes; the description chain wants
172 // first body prose. Lists and other paragraphs follow if the
173 // first hit didn't qualify.
174 //
175 // This walk is deliberately SHALLOW — it never descends into a
176 // container, `Block::FootnoteDefinition` included. Endnote prose
177 // is not the overlay's opening line, and a footnote body reached
178 // by recursion would land in `<meta name="description">`.
179 _ => continue,
180 }
181 }
182 String::new()
183}
184
185// ── Source parsing: `:::hero` block → HeroShortcode ─────────────────────
186//
187// Moved here from `shortcode_extract` (2026-08-03): the hero concern gets
188// one owner. `shortcode_extract` still routes the `:::hero` name to
189// `parse_hero`; everything that decides what a hero's image IS lives here.
190
191/// Parse a `:::hero` block in any of three syntactic forms.
192///
193/// Image source priority:
194/// 1. `image=path` attribute in the `{...}` block (new grammar).
195/// 2. **Directive-line path**: `:::hero ./path.jpg` or
196/// `:::hero ./path.jpg|attrs` or `:::hero ./path.jpg {.classes}` —
197/// moss-releases / client-site backward-compat. The path appears as
198/// raw text before any `{...}` attribute block.
199/// 3. **Body-image fallback**: scan first non-empty body line for a
200/// media reference (`![[path|attrs]]`, ``, or
201/// bare media filename). Step 3 of the grammar migration rewrites
202/// these to use the `image=` attribute.
203/// 4. None — renderer emits a `<section>` with no `<img>`.
204///
205/// Returns `(HeroShortcode, bool, Vec<String>)` where the bool is `true`
206/// when the body-image fallback (Priority 3) fired, signaling the caller
207/// to emit a deprecation warning, and the `Vec<String>` carries warnings
208/// collected while re-parsing the overlay body as a fragment (e.g. a
209/// misspelled `:::name` shortcode nested inside the overlay) — see
210/// [`parse_overlay_to_blocks`].
211pub(super) fn parse_hero(args: &str, body: &str, config: &ParseConfig) -> (HeroShortcode, bool, Vec<String>) {
212 let trimmed_args = args.trim();
213
214 // Split args on the first `{` to separate the directive-line path
215 // (if any) from the attribute block (if any).
216 let (positional, attr_block): (&str, &str) = if let Some(pos) = trimmed_args.find('{') {
217 // char-aligned: pos points to ASCII '{' from str::find — safe to slice.
218 #[allow(clippy::string_slice)]
219 (trimmed_args[..pos].trim(), &trimmed_args[pos..])
220 } else {
221 (trimmed_args, "")
222 };
223
224 // Parse the attribute block, if present.
225 let parsed = if attr_block.is_empty() {
226 Default::default()
227 } else {
228 crate::ast::attrs::parse_attrs(attr_block).unwrap_or_default()
229 };
230 let classes = parsed.class_string();
231 let width = parsed.width.map(str::to_string);
232 let mobile = parsed.get("mobile").map(str::to_string);
233 // Read once, for all three image-source branches below — a caption belongs
234 // to the hero, not to whichever syntax named its image.
235 let caption = parsed.get("caption").unwrap_or_default().trim().to_string();
236
237 // Priority 1: `image=` attribute.
238 if let Some(image_value) = parsed.get("image") {
239 let (path, attrs_str) = crate::media::split_pipe(image_value);
240 let overlay_text = body.trim().to_string();
241 let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
242 return (
243 HeroShortcode {
244 image: if path.trim().is_empty() {
245 None
246 } else {
247 Some(Url::unresolved(path.trim().to_string()))
248 },
249 extra_images: Vec::new(),
250 attrs: attrs_str.to_string(),
251 classes,
252 overlay,
253 overlay_text,
254 width,
255 mobile,
256 caption,
257 },
258 false,
259 overlay_warnings,
260 );
261 }
262
263 // Priority 2: directive-line path (legacy syntax). When the
264 // positional text is non-empty, treat it as the image path with
265 // optional `|attrs` pipe suffix. Body becomes pure overlay markdown.
266 if !positional.is_empty() {
267 let (path, attrs_str) = crate::media::split_pipe(positional);
268 let overlay_text = body.trim().to_string();
269 let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
270 return (
271 HeroShortcode {
272 image: if path.trim().is_empty() {
273 None
274 } else {
275 Some(Url::unresolved(path.trim().to_string()))
276 },
277 extra_images: Vec::new(),
278 attrs: attrs_str.to_string(),
279 classes,
280 overlay,
281 overlay_text,
282 width,
283 mobile,
284 caption,
285 },
286 false,
287 overlay_warnings,
288 );
289 }
290
291 // Priority 3: body-image fallback. Every CONSECUTIVE leading media
292 // line is a background slide (2026-07-27 multi-image hero) — the
293 // first is the primary image, the rest `extra_images`; blank lines
294 // between media lines don't end the run. The first non-media,
295 // non-empty line starts the overlay.
296 // The media/overlay split is `hero_media_run`'s, so the span emitter in
297 // `shortcode_asset_spans` and this parser cannot disagree about which
298 // body lines are slides. Kept honest by `spans_agree_with_parsers`.
299 let lines: Vec<&str> = body.lines().collect();
300 let run = hero_media_run(&lines);
301
302 let mut image_path: Option<String> = None;
303 let mut image_attrs = String::new();
304 let mut extra_images: Vec<Url> = Vec::new();
305 for &k in &run.media {
306 let Some(m) = hero_media_line_span(lines[k]) else {
307 continue;
308 };
309 if image_path.is_none() {
310 image_path = Some(m.path);
311 // Frame-level media attrs (object-fit/position) come from the
312 // primary slide and apply to every slide.
313 image_attrs = m.attrs;
314 } else {
315 extra_images.push(Url::unresolved(m.path));
316 }
317 }
318 let used_priority_3 = image_path.is_some();
319 let overlay_text = run
320 .overlay
321 .iter()
322 .map(|&k| lines[k])
323 .collect::<Vec<_>>()
324 .join("\n")
325 .trim()
326 .to_string();
327 let (overlay, overlay_warnings) = parse_overlay_to_blocks(&overlay_text, config);
328 (
329 HeroShortcode {
330 image: image_path.map(Url::unresolved),
331 extra_images,
332 attrs: image_attrs,
333 classes,
334 overlay,
335 overlay_text,
336 width,
337 mobile,
338 caption,
339 },
340 used_priority_3,
341 overlay_warnings,
342 )
343}
344
345/// Parse a hero overlay's raw markdown source into `Vec<Block>`.
346///
347/// Phase 4 PR4.5 (2026-05-28): mirrors `parse_cell_to_blocks` for the
348/// grid-cell path but without compound-link detection (an overlay is not
349/// a compound-link surface; the SoCiviC pattern is grid-cell-specific).
350/// Returns an empty vec (and no warnings) when the overlay is empty.
351/// Otherwise returns `(blocks, warnings)` — the fragment `Document`'s
352/// warnings (e.g. a misspelled `:::name` shortcode nested inside the
353/// overlay body) are carried out here rather than dropped, so they reach
354/// [`parse_hero`] → [`super::shortcode_extract::parse_shortcode_block`],
355/// which already merges its `Vec<String>` into `doc.warnings`.
356fn parse_overlay_to_blocks(raw: &str, config: &ParseConfig) -> (Vec<Block>, Vec<String>) {
357 if raw.is_empty() {
358 return (Vec::new(), Vec::new());
359 }
360 let doc = parse_fragment_with_config(raw, config);
361 (doc.blocks, doc.warnings)
362}
363
364/// File extensions recognized as media for hero body-image fallback.
365pub(crate) const HERO_MEDIA_EXTENSIONS: &[&str] = &[
366 "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov",
367];
368
369pub(crate) fn is_bare_hero_media(s: &str) -> bool {
370 let (path_part, _) = crate::media::split_pipe(s);
371 let path = path_part.trim();
372 path.rfind('.')
373 .map(|dot| {
374 // char-aligned: dot points to ASCII '.' from str::rfind — `dot + 1`
375 // lands on the byte after '.', which is also a char boundary.
376 #[allow(clippy::string_slice)]
377 let ext = &path[dot + 1..];
378 HERO_MEDIA_EXTENSIONS
379 .iter()
380 .any(|e| e.eq_ignore_ascii_case(ext))
381 })
382 .unwrap_or(false)
383}
384
385/// Recognize one hero media line, with LINE-RELATIVE byte offsets.
386///
387/// The single grammar for a hero media line: [`parse_hero_media_line`] is a
388/// wrapper over it and [`crate::ast::shortcode_extract::shortcode_asset_spans`]
389/// lifts its offsets to absolute. Three arms, in the historical order —
390/// wikilink embed, markdown image, bare media filename.
391pub(crate) fn hero_media_line_span(line: &str) -> Option<MediaLineSpan> {
392 let lead = line.len() - line.trim_start().len();
393 let trimmed = line.trim();
394
395 // Wikilink embed: ![[path|attrs]]
396 if let Some(inner) = trimmed
397 .strip_prefix("![[")
398 .and_then(|s| s.strip_suffix("]]"))
399 {
400 let (path, attrs_str) = crate::media::split_pipe(inner);
401 // `
414 if trimmed.starts_with(" {
416 if trimmed.ends_with(')') {
417 // char-aligned: paren_open points to ASCII "](" from str::find
418 // (paren_open + 2 lands on first byte after `](`, char boundary);
419 // `trimmed.len() - 1` is the byte before the trailing ASCII ')'.
420 #[allow(clippy::string_slice)]
421 let inner = &trimmed[paren_open + 2..trimmed.len() - 1];
422 #[allow(clippy::string_slice)]
423 let alt = trimmed[2..paren_open].to_string();
424 let (path, attrs_str) = crate::media::split_pipe(inner);
425 let start = lead + paren_open + 2;
426 return Some(MediaLineSpan {
427 path: path.trim().to_string(),
428 alt,
429 attrs: attrs_str.to_string(),
430 value: start..start + inner.len(),
431 value_attrs: attrs_str.to_string(),
432 is_token: true,
433 });
434 }
435 }
436 }
437
438 // Bare media filename: photo.jpg or photo.jpg|contain
439 if is_bare_hero_media(trimmed) {
440 let (path, attrs_str) = crate::media::split_pipe(trimmed);
441 return Some(MediaLineSpan {
442 path: path.trim().to_string(),
443 alt: String::new(),
444 attrs: attrs_str.to_string(),
445 value: lead..lead + trimmed.len(),
446 value_attrs: attrs_str.to_string(),
447 is_token: false,
448 });
449 }
450
451 None
452}
453
454/// Which body lines of a `:::hero` are background-media slides and which are
455/// overlay markdown.
456///
457/// The Priority-3 consecutive-run rule, lifted verbatim out of [`parse_hero`]
458/// so the span emitter and the parser cannot drift. `overlay` is literally
459/// the index list `parse_hero` pushes — blank lines INSIDE the run excluded,
460/// everything from the first non-media line onward included — rather than
461/// `lines[last_media + 1 ..]`, which is equivalent only by accident of the
462/// trailing `.trim()`.
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub(crate) struct HeroRun {
465 pub media: Vec<usize>,
466 pub overlay: Vec<usize>,
467}
468
469pub(crate) fn hero_media_run(lines: &[&str]) -> HeroRun {
470 let mut media = Vec::new();
471 let mut overlay = Vec::new();
472 let mut in_media_run = true;
473 let mut have_primary = false;
474
475 for (k, line) in lines.iter().enumerate() {
476 if in_media_run {
477 if line.trim().is_empty() {
478 continue;
479 }
480 if let Some(m) = hero_media_line_span(line) {
481 // A bare filename containing whitespace on a CONTINUATION
482 // line is almost certainly prose that happens to end in a
483 // media extension ("Photo: alpine-meadow.jpg") — treat it
484 // as overlay rather than silently eating a caption. The
485 // first line keeps the historical bare-filename grammar.
486 let bare = !line.trim_start().starts_with("![");
487 if !(have_primary && bare && m.path.contains(char::is_whitespace)) {
488 have_primary = true;
489 media.push(k);
490 continue;
491 }
492 // else fall through: this line ends the media run.
493 }
494 // First non-media, non-empty line — overlay starts here.
495 in_media_run = false;
496 }
497 overlay.push(k);
498 }
499
500 HeroRun { media, overlay }
501}
502/// Hero image spans, mirroring [`parse_hero`]'s priority
503/// ladder and short-circuiting at the same points.
504#[allow(clippy::too_many_arguments)]
505pub(crate) fn hero_asset_spans(
506 source: &str,
507 mask: &str,
508 table: &[(usize, usize, usize)],
509 opener: usize,
510 body_start: usize,
511 close: usize,
512 out: &mut Vec<AssetPathSpan>,
513) {
514 let (obase, ocontent, _) = table[opener];
515 #[allow(clippy::string_slice)]
516 let opener_masked = &mask[obase..obase + ocontent];
517
518 // Priority 1: an `image=` attribute anywhere in the (possibly multi-line)
519 // attribute block. `parse_attrs_spanned` is handed the rest of the
520 // document from the `{` — it returns at the first `}` at item position,
521 // so it terminates exactly where the gathered-args form would. See its
522 // doc comment for the constraint that makes this legal.
523 if let Some(brace_rel) = opener_masked.find('{') {
524 let brace_abs = obase + brace_rel;
525 #[allow(clippy::string_slice)]
526 // `find` on ASCII '{' → char boundary.
527 let rest = &source[brace_abs..];
528 if let Ok((_, kvs)) = crate::ast::attrs::parse_attrs_spanned(rest) {
529 if let Some(kv) = kvs.iter().find(|kv| kv.key == "image") {
530 let value = brace_abs + kv.value.start..brace_abs + kv.value.end;
531 let mut item = brace_abs + kv.item.start..brace_abs + kv.item.end;
532 // Absorb one leading space so removing the item doesn't
533 // leave a double space inside the braces.
534 if item.start > brace_abs && source.as_bytes()[item.start - 1] == b' ' {
535 item.start -= 1;
536 }
537 #[allow(clippy::string_slice)]
538 // Both ends come from `char_indices()` over `rest`.
539 let raw = &source[value.clone()];
540 let inner = raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(raw);
541 let (path, attrs) = crate::media::split_pipe(inner);
542 out.push(AssetPathSpan {
543 path: crate::media::strip_wikilink(path).to_string(),
544 attrs: attrs.to_string(),
545 quote: if raw.starts_with('"') { Some('"') } else { None },
546 value,
547 outer: item,
548 container: PathContainer::ShortcodeAttr { key: "image".to_string() },
549 });
550 // Body lines are pure overlay when `image=` is present.
551 return;
552 }
553 }
554 }
555
556 // Priority 2: a positional path on the directive line, before any `{`.
557 // `parse_shortcode_opener` already stripped the colons and the name.
558 let opener_trimmed = opener_masked.trim();
559 if let Some((_, _name, args)) = super::shortcode_extract::parse_shortcode_opener(opener_trimmed) {
560 let cut = args.find('{').unwrap_or(args.len());
561 #[allow(clippy::string_slice)]
562 // `find` on ASCII '{' → char boundary; `args` is a suffix of the line.
563 let positional = args[..cut].trim();
564 if !positional.is_empty() {
565 // Absolute offset of `positional` in the raw line. `args` is
566 // `opener_trimmed` with the colons + name stripped and both ends
567 // trimmed, and `opener_trimmed` has no trailing whitespace, so
568 // `args` ends where `opener_trimmed` does — its start offset is
569 // the length difference. `positional` starts at `args[0]`
570 // because `args` is already left-trimmed.
571 let lead = ocontent - opener_masked.trim_start().len();
572 let start = obase + lead + (opener_trimmed.len() - args.len());
573 let value = start..start + positional.len();
574 let (path, attrs) = crate::media::split_pipe(positional);
575 out.push(AssetPathSpan {
576 path: crate::media::strip_wikilink(path.trim()).to_string(),
577 attrs: attrs.to_string(),
578 quote: None,
579 value: value.clone(),
580 outer: value,
581 container: PathContainer::HeroDirective,
582 });
583 return;
584 }
585 }
586
587 // Priority 3: the leading run of body media lines.
588 let body: Vec<&str> = (body_start..close)
589 .map(|k| {
590 let (base, content, _) = table[k];
591 #[allow(clippy::string_slice)]
592 // Line boundaries from `line_table`; read verbatim, exactly as
593 // `parse_hero` reads them.
594 &source[base..base + content]
595 })
596 .collect();
597 let run = hero_media_run(&body);
598 for &k in &run.media {
599 let Some(m) = hero_media_line_span(body[k]) else {
600 continue;
601 };
602 let (base, content, term) = table[body_start + k];
603 out.push(AssetPathSpan {
604 path: crate::media::strip_wikilink(&m.path).to_string(),
605 attrs: m.value_attrs,
606 quote: None,
607 value: base + m.value.start..base + m.value.end,
608 outer: base..base + content + term,
609 container: PathContainer::HeroBodyMedia,
610 });
611 }
612}
613
614#[cfg(test)]
615#[path = "extract_hero_tests.rs"]
616mod tests;