Skip to main content

primase_core/
print.rs

1//! Printing the world around passes (DESIGN.md ยง4 `print-world-after`): [`WorldPrinter`] is an [`Instrument`] that
2//! makes one text per pass: what the world holds after it (entities, components and resources by name), what the
3//! pass spawned, despawned and changed, the pass's own counts, and optionally the components of chosen entities.
4//!
5//! Core does no I/O: each print goes to the [`PrintSink`] the caller supplies (a command writes files, a test keeps
6//! them), with the run's label ([`WorldPrinter::set_label`]) and the print's name: `00-input` (the world before the
7//! first pass), `NN-<pass>` after pass NN (from 01), and `NN-<pass>.failed` instead when the pass failed (the world
8//! as the failure left it; the print before it is the pass's input). Labels and names are safe as file names. The
9//! same world, pipeline and options give the same text.
10//!
11//! Changes are found by comparing a digest of each component's `Debug` text before and after the pass (64-bit
12//! FNV-1a): JSON views summarise bulk data (a mesh shows its counts, not its vertices), so a pass that only moves
13//! vertices would not show in them. The digest is a debugging aid and nothing more: `Debug` text is not a format and
14//! may change between builds, so the digest is never written out, gated on or used as a cache key.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::fmt::{self, Write as _};
18
19use crate::components::{Name, Source};
20use crate::pass::{Error, Instrument, Step, StepReport};
21use crate::world::{Entity, World};
22
23/// What a [`WorldPrinter`] writes.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PrintOptions {
26    /// Print after these passes only (by pass name); empty: the input and after every pass.
27    pub only: BTreeSet<String>,
28    /// Entities listed under `changed`, `spawned` and `despawned` before `... N more`.
29    pub limit: usize,
30    /// The entities whose components are written out; `None`: none.
31    pub dump: Option<EntityFilter>,
32}
33
34impl Default for PrintOptions {
35    fn default() -> Self {
36        PrintOptions {
37            only: BTreeSet::new(),
38            limit: 200,
39            dump: None,
40        }
41    }
42}
43
44/// Which entities a print writes out in full. An entity matches when it meets every kind of criterion given, and
45/// within a kind any one value; no criteria at all match every entity.
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct EntityFilter {
48    pub entities: BTreeSet<Entity>,
49    /// Component names (typed or opaque), any of which the entity has.
50    pub has: BTreeSet<String>,
51    /// Text found in the entity's `core.source`.
52    pub source: Vec<String>,
53    /// Text found in the entity's `core.name`.
54    pub name: Vec<String>,
55    /// Only entities the pass spawned or changed (none in the input print).
56    pub changed: bool,
57}
58
59impl EntityFilter {
60    /// `all`, or criteria separated by commas: `entity=12` (also `e12`), `has=<component name>`,
61    /// `source=<text>`, `name=<text>`, `changed`.
62    pub fn parse(text: &str) -> Result<Self, String> {
63        let mut f = EntityFilter::default();
64        let text = text.trim();
65        if text == "all" {
66            return Ok(f);
67        }
68        if text.is_empty() {
69            return Err("empty entity filter (`all` prints every entity)".to_owned());
70        }
71        for item in text.split(',').map(str::trim) {
72            if item == "changed" {
73                f.changed = true;
74                continue;
75            }
76            if let Some(index) = item.strip_prefix('e').and_then(|n| n.parse::<u32>().ok()) {
77                f.entities.insert(Entity::from_index(index));
78                continue;
79            }
80            let Some((k, v)) = item.split_once('=') else {
81                return Err(format!(
82                    "entity filter item {item:?} is not key=value or `changed`"
83                ));
84            };
85            let v = v.trim();
86            if v.is_empty() {
87                return Err(format!("entity filter item {item:?} has no value"));
88            }
89            match k.trim() {
90                "entity" => {
91                    let n = v.strip_prefix('e').unwrap_or(v);
92                    let index = n
93                        .parse::<u32>()
94                        .map_err(|_| format!("entity filter: {v:?} is not an entity"))?;
95                    f.entities.insert(Entity::from_index(index));
96                }
97                "has" => {
98                    f.has.insert(v.to_owned());
99                }
100                "source" => f.source.push(v.to_owned()),
101                "name" => f.name.push(v.to_owned()),
102                k => return Err(format!("entity filter: unknown key {k:?}")),
103            }
104        }
105        Ok(f)
106    }
107
108    fn matches(&self, w: &World, e: Entity, changed: &BTreeSet<Entity>) -> bool {
109        let contains = |texts: &[String], value: Option<&String>| {
110            texts.is_empty() || value.is_some_and(|v| texts.iter().any(|t| v.contains(t.as_str())))
111        };
112        (self.entities.is_empty() || self.entities.contains(&e))
113            && (self.has.is_empty() || self.has.iter().any(|n| w.entity_has_named(e, n)))
114            && contains(&self.source, w.get::<Source>(e).map(|s| &s.0))
115            && contains(&self.name, w.get::<Name>(e).map(|n| &n.0))
116            && (!self.changed || changed.contains(&e))
117    }
118}
119
120/// Where a [`WorldPrinter`]'s prints go: `label` is the run's label when one is set, `name` the print's name, unique
121/// within a run (see the module docs). An error stops the run as [`Error::Instrument`]. Closures
122/// `FnMut(Option<&str>, &str, &str) -> Result<(), String>` are sinks.
123pub trait PrintSink {
124    fn print(&mut self, label: Option<&str>, name: &str, text: &str) -> Result<(), String>;
125}
126
127impl<F: FnMut(Option<&str>, &str, &str) -> Result<(), String>> PrintSink for F {
128    fn print(&mut self, label: Option<&str>, name: &str, text: &str) -> Result<(), String> {
129        self(label, name, text)
130    }
131}
132
133/// Prints the world around every pass of the runs it instruments, into `sink` (see the module docs).
134pub struct WorldPrinter<S: PrintSink> {
135    sink: S,
136    label: Option<String>,
137    options: PrintOptions,
138    /// The world before the current pass, when a print needs it.
139    before: Option<Snapshot>,
140}
141
142impl<S: PrintSink> fmt::Debug for WorldPrinter<S> {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("WorldPrinter")
145            .field("label", &self.label)
146            .field("options", &self.options)
147            .finish_non_exhaustive()
148    }
149}
150
151impl<S: PrintSink> WorldPrinter<S> {
152    pub fn new(sink: S, options: PrintOptions) -> Self {
153        WorldPrinter {
154            sink,
155            label: None,
156            options,
157            before: None,
158        }
159    }
160
161    /// Label the next runs' prints (a streamed conversion's cell): the sink gets it with each print. ASCII letters,
162    /// digits, `_`, `-` and `.`, not starting with `.`, so it is safe as a folder name.
163    pub fn set_label(&mut self, label: &str) -> Result<(), String> {
164        let ok = !label.is_empty()
165            && !label.starts_with('.')
166            && label
167                .bytes()
168                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
169        if !ok {
170            return Err(format!("print label {label:?}: use letters, digits, _, - and ."));
171        }
172        self.label = Some(label.to_owned());
173        self.before = None;
174        Ok(())
175    }
176
177    fn selected(&self, pass: &str) -> bool {
178        self.options.only.is_empty() || self.options.only.contains(pass)
179    }
180
181    fn write(&mut self, name: &str, text: &str) -> Result<(), String> {
182        self.sink.print(self.label.as_deref(), name, text)
183    }
184
185    /// The text of one print: `head`, the world as `after` holds it, the change from `before`, `report`'s counts and
186    /// the dump.
187    fn render(
188        &self,
189        head: &str,
190        w: &World,
191        before: Option<&Snapshot>,
192        after: &Snapshot,
193        report: Option<&StepReport>,
194    ) -> String {
195        let mut out = String::new();
196        // writing into a String cannot fail
197        let _ = render(&mut out, head, w, before, after, report, &self.options);
198        out
199    }
200}
201
202impl<S: PrintSink> Instrument for WorldPrinter<S> {
203    fn before_pass(&mut self, step: &Step<'_>, w: &World) -> Result<(), String> {
204        if step.index == 0 {
205            self.before = None;
206        }
207        if !self.selected(step.name) || self.before.is_some() {
208            return Ok(());
209        }
210        let snap = Snapshot::take(w);
211        if step.index == 0 && self.options.only.is_empty() {
212            let text = self.render("input", w, None, &snap, None);
213            self.write("00-input", &text)?;
214        }
215        self.before = Some(snap);
216        Ok(())
217    }
218
219    fn after_pass(&mut self, step: &Step<'_>, w: &World, report: &StepReport) -> Result<(), String> {
220        if !self.selected(step.name) {
221            self.before = None;
222            return Ok(());
223        }
224        let after = Snapshot::take(w);
225        let head = format!("after step {}: {}", step.index + 1, step.text);
226        let text = self.render(&head, w, self.before.as_ref(), &after, Some(report));
227        self.write(&format!("{:02}-{}", step.index + 1, file_name(step.name)), &text)?;
228        self.before = Some(after);
229        Ok(())
230    }
231
232    fn pass_failed(&mut self, step: &Step<'_>, w: &World, error: &Error) -> Result<(), String> {
233        let before = self.before.take();
234        let after = Snapshot::take(w);
235        let head = format!("step {} failed: {}\nerror: {error}", step.index + 1, step.text);
236        let text = self.render(&head, w, before.as_ref(), &after, None);
237        self.write(
238            &format!("{:02}-{}.failed", step.index + 1, file_name(step.name)),
239            &text,
240        )
241    }
242}
243
244/// What a print compares: who is alive, and a digest of every component and resource.
245#[derive(Debug, Default)]
246struct Snapshot {
247    alive: BTreeSet<Entity>,
248    /// Component name -> (schema version, entity -> digest).
249    components: BTreeMap<String, (u32, BTreeMap<Entity, u64>)>,
250    /// Resource name -> (schema version, digest).
251    resources: BTreeMap<String, (u32, u64)>,
252}
253
254impl Snapshot {
255    fn take(w: &World) -> Self {
256        let mut s = Snapshot {
257            alive: w.entities().collect(),
258            ..Snapshot::default()
259        };
260        w.visit_components_debug(&mut |name, version, e, value| {
261            let digest = digest(value);
262            match s.components.get_mut(name) {
263                Some((_, col)) => {
264                    col.insert(e, digest);
265                }
266                None => {
267                    s.components
268                        .insert(name.to_owned(), (version, BTreeMap::from([(e, digest)])));
269                }
270            }
271        });
272        w.visit_resources_debug(&mut |name, version, value| {
273            s.resources.insert(name.to_owned(), (version, digest(value)));
274        });
275        s
276    }
277}
278
279/// 64-bit FNV-1a over text written into it.
280struct Fnv(u64);
281
282impl fmt::Write for Fnv {
283    fn write_str(&mut self, s: &str) -> fmt::Result {
284        for b in s.bytes() {
285            self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
286        }
287        Ok(())
288    }
289}
290
291/// The digest of a value's `Debug` text, streamed (no allocation). Debugging only, see the module docs.
292fn digest(value: &dyn fmt::Debug) -> u64 {
293    let mut h = Fnv(0xcbf2_9ce4_8422_2325);
294    // a Debug impl that fails leaves what it wrote so far: still deterministic
295    let _ = write!(h, "{value:?}");
296    h.0
297}
298
299/// How an entity's components changed in a pass: `+` gained, `-` lost, `~` changed, each with the name.
300type Changes<'a> = BTreeMap<Entity, Vec<(char, &'a str)>>;
301
302fn render(
303    out: &mut String,
304    head: &str,
305    w: &World,
306    before: Option<&Snapshot>,
307    after: &Snapshot,
308    report: Option<&StepReport>,
309    options: &PrintOptions,
310) -> fmt::Result {
311    writeln!(out, "{head}")?;
312    let empty = Snapshot::default();
313    let b = before.unwrap_or(&empty);
314    let spawned: Vec<Entity> = after.alive.difference(&b.alive).copied().collect();
315    let despawned: Vec<Entity> = b.alive.difference(&after.alive).copied().collect();
316    write!(out, "entities {}", after.alive.len())?;
317    if before.is_some() {
318        write!(out, " spawned {} despawned {}", spawned.len(), despawned.len())?;
319    }
320    writeln!(out)?;
321
322    // components by name: count, and gained (spawned entities included) / lost / changed
323    let mut changes: Changes<'_> = BTreeMap::new();
324    let names: BTreeSet<&str> = after
325        .components
326        .keys()
327        .chain(b.components.keys())
328        .map(String::as_str)
329        .collect();
330    let none = (0, BTreeMap::new());
331    for name in names {
332        let (va, now) = after.components.get(name).unwrap_or(&none);
333        let (vb, was) = b.components.get(name).unwrap_or(&none);
334        let version = if after.components.contains_key(name) {
335            va
336        } else {
337            vb
338        };
339        write!(out, "component {name}@{version} {}", now.len())?;
340        if before.is_some() {
341            let (mut gained, mut lost, mut changed) = (0, 0, 0);
342            for (e, d) in now {
343                match was.get(e) {
344                    None => {
345                        gained += 1;
346                        if b.alive.contains(e) {
347                            changes.entry(*e).or_default().push(('+', name));
348                        }
349                    }
350                    Some(old) if old != d => {
351                        changed += 1;
352                        changes.entry(*e).or_default().push(('~', name));
353                    }
354                    Some(_) => {}
355                }
356            }
357            for e in was.keys().filter(|e| !now.contains_key(e)) {
358                lost += 1;
359                if after.alive.contains(e) {
360                    changes.entry(*e).or_default().push(('-', name));
361                }
362            }
363            write!(out, " gained {gained} lost {lost} changed {changed}")?;
364        }
365        writeln!(out)?;
366    }
367
368    let res: BTreeSet<&str> = after
369        .resources
370        .keys()
371        .chain(b.resources.keys())
372        .map(String::as_str)
373        .collect();
374    for name in res {
375        match (after.resources.get(name), b.resources.get(name)) {
376            (Some((v, _)), _) if before.is_none() => writeln!(out, "resource {name}@{v}")?,
377            (Some((v, d)), Some((_, old))) => writeln!(
378                out,
379                "resource {name}@{v}{}",
380                if d == old { "" } else { " changed" }
381            )?,
382            (Some((v, _)), None) => writeln!(out, "resource {name}@{v} added")?,
383            (None, Some((v, _))) => writeln!(out, "resource {name}@{v} removed")?,
384            (None, None) => {}
385        }
386    }
387
388    if let Some(r) = report {
389        for (k, n) in &r.log.counts {
390            writeln!(out, "count {k} {n}")?;
391        }
392        for (k, n) in &r.log.approximations {
393            writeln!(out, "approximated {k} {n}")?;
394        }
395    }
396
397    if before.is_some() {
398        writeln!(out, "changed {}", changes.len())?;
399        list(out, options.limit, changes.iter(), |out, (e, cs)| {
400            write!(out, "  {e:?}")?;
401            for (c, n) in cs {
402                write!(out, " {c}{n}")?;
403            }
404            Ok(())
405        })?;
406        for (what, ids, snap) in [("spawned", &spawned, after), ("despawned", &despawned, b)] {
407            writeln!(out, "{what} {}", ids.len())?;
408            list(out, options.limit, ids.iter(), |out, e| {
409                write!(out, "  {e:?}")?;
410                for (name, (_, col)) in &snap.components {
411                    if col.contains_key(e) {
412                        write!(out, " {name}")?;
413                    }
414                }
415                Ok(())
416            })?;
417        }
418    }
419
420    if let Some(filter) = &options.dump {
421        let changed: BTreeSet<Entity> = changes.keys().chain(&spawned).copied().collect();
422        let picked: Vec<Entity> = w.entities().filter(|&e| filter.matches(w, e, &changed)).collect();
423        writeln!(out, "dump {}", picked.len())?;
424        for e in picked {
425            writeln!(out, "{e:?}")?;
426            match w.components_json(e) {
427                Ok(cs) => {
428                    for c in cs {
429                        match c.value {
430                            Some(v) => writeln!(out, "  {}@{} {v}", c.name, c.version)?,
431                            None => writeln!(out, "  {}@{} (no view)", c.name, c.version)?,
432                        }
433                    }
434                }
435                Err(err) => writeln!(out, "  (no faithful view: {err})")?,
436            }
437        }
438    }
439    Ok(())
440}
441
442/// Up to `limit` lines from `items`, then `  ... N more`.
443fn list<T>(
444    out: &mut String,
445    limit: usize,
446    items: impl ExactSizeIterator<Item = T>,
447    mut line: impl FnMut(&mut String, T) -> fmt::Result,
448) -> fmt::Result {
449    let total = items.len();
450    for item in items.take(limit) {
451        line(out, item)?;
452        writeln!(out)?;
453    }
454    if total > limit {
455        writeln!(out, "  ... {} more", total - limit)?;
456    }
457    Ok(())
458}
459
460/// A pass name as a file name: characters other than ASCII letters, digits, `_`, `-` and `.` become `_`.
461fn file_name(pass: &str) -> String {
462    pass.chars()
463        .map(|c| {
464            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
465                c
466            } else {
467                '_'
468            }
469        })
470        .collect()
471}
472
473#[cfg(test)]
474#[allow(
475    clippy::disallowed_methods,
476    clippy::disallowed_types,
477    reason = "test I/O: unit tests write and read a sink file"
478)]
479mod tests {
480    use super::*;
481    use crate::components::{Mesh, Primitive, Semantic, Stream, Topology};
482    use crate::pass::{Pass, PassLog, PassManager, PassRegistry, Pipeline, RunReport};
483    use crate::world::{Opaque, OpaqueData};
484    use std::sync::{Arc, Mutex};
485
486    fn mesh(x: f32) -> Mesh {
487        Mesh::new(vec![Primitive {
488            topology: Topology::Triangles,
489            streams: BTreeMap::from([(
490                Semantic::Position,
491                Stream::F32x3(vec![[x, 0., 0.], [0., 1., 0.], [0., 0., 1.]]),
492            )]),
493            indices: None,
494            material: None,
495        }])
496    }
497
498    /// e0 a named mesh from `m11:wall`, e1 a named light-ish opaque thing, e2 a bare mesh.
499    fn world() -> World {
500        let mut w = World::new();
501        let a = w.spawn();
502        w.insert(a, Name("wall".into()));
503        w.insert(a, Source("m11:wall".into()));
504        w.insert(a, mesh(0.));
505        let b = w.spawn();
506        w.insert(b, Name("lamp".into()));
507        w.insert_opaque(
508            b,
509            "game.lamp",
510            Opaque {
511                version: 2,
512                data: OpaqueData::Json(serde_json::json!({"on": true})),
513            },
514        )
515        .unwrap();
516        let c = w.spawn();
517        w.insert(c, mesh(5.));
518        w
519    }
520
521    /// Moves the first vertex of every mesh (the JSON view stays the same), despawns `game.lamp` holders, spawns one
522    /// named entity and renames e0.
523    struct Edit;
524    impl Pass for Edit {
525        fn name(&self) -> &'static str {
526            "edit"
527        }
528        fn provides(&self) -> &[&'static str] {
529            &["core.name"]
530        }
531        fn removes(&self) -> &[&'static str] {
532            &["game.lamp", "core.name"]
533        }
534        fn modifies(&self) -> &[&'static str] {
535            &["core.mesh"]
536        }
537        fn despawns_entities(&self) -> bool {
538            true
539        }
540        fn run(&self, w: &mut World, log: &mut PassLog) -> Result<(), String> {
541            let meshes: Vec<Entity> = w.iter::<Mesh>().map(|(e, _)| e).collect();
542            for e in meshes {
543                if let Some(mut p) = w.get_mut::<Mesh>(e).and_then(|m| m.primitive_mut(0))
544                    && let Some(Stream::F32x3(v)) = p.streams_mut().get_mut(&Semantic::Position)
545                {
546                    v[0][0] += 1.;
547                    log.count("moved", 1);
548                }
549            }
550            for e in w.entities_named("game.lamp") {
551                w.despawn(e);
552            }
553            let n = w.spawn();
554            w.insert(n, Name("new".into()));
555            let first = w.entities().next().expect("e0");
556            w.insert(first, Name("wall2".into()));
557            Ok(())
558        }
559    }
560
561    /// Fails after removing e2's mesh.
562    struct Break;
563    impl Pass for Break {
564        fn name(&self) -> &'static str {
565            "break"
566        }
567        fn run(&self, w: &mut World, _: &mut PassLog) -> Result<(), String> {
568            w.remove::<Mesh>(Entity::from_index(2));
569            Err("broke on purpose".into())
570        }
571    }
572
573    fn pipeline(passes: Vec<Box<dyn Pass>>) -> Pipeline {
574        Pipeline { passes }
575    }
576
577    /// Prints by (label, name), as a sink keeps them.
578    type Prints = Arc<Mutex<BTreeMap<(Option<String>, String), String>>>;
579
580    fn keeper() -> (Prints, impl PrintSink) {
581        let prints = Prints::default();
582        let kept = prints.clone();
583        let sink = move |label: Option<&str>, name: &str, text: &str| {
584            let key = (label.map(str::to_owned), name.to_owned());
585            match kept.lock().unwrap().insert(key, text.to_owned()) {
586                None => Ok(()),
587                Some(_) => Err(format!("{name} printed twice")),
588            }
589        };
590        (prints, sink)
591    }
592
593    /// Run `passes` on [`world`], printing; the prints of the unlabelled run by name.
594    fn run(
595        options: PrintOptions,
596        passes: Vec<Box<dyn Pass>>,
597    ) -> (crate::pass::Result<()>, BTreeMap<String, String>) {
598        let (prints, sink) = keeper();
599        let mut printer = WorldPrinter::new(sink, options);
600        let result = PassManager::new(PassRegistry::new()).run_instrumented(
601            &mut world(),
602            &pipeline(passes),
603            &mut RunReport::default(),
604            &mut printer,
605        );
606        let by_name = prints
607            .lock()
608            .unwrap()
609            .iter()
610            .map(|((_, n), t)| (n.clone(), t.clone()))
611            .collect();
612        (result, by_name)
613    }
614
615    #[test]
616    fn prints_counts_changes_and_dumps() {
617        let options = PrintOptions {
618            dump: Some(EntityFilter::parse("source=m11,has=core.mesh").unwrap()),
619            ..PrintOptions::default()
620        };
621        let (result, prints) = run(options, vec![Box::new(Edit)]);
622        result.unwrap();
623        assert_eq!(prints.keys().collect::<Vec<_>>(), ["00-input", "01-edit"]);
624        let input = "input
625entities 3
626component core.mesh@2 2
627component core.name@1 2
628component core.source@1 1
629component game.lamp@2 1
630dump 1
631e0
632  core.mesh@2 {\"primitives\":[{\"topology\":\"Triangles\",\"vertices\":3,\"indices\":null,\"streams\":[\"Position\"],\"material\":null}]}
633  core.name@1 \"wall\"
634  core.source@1 \"m11:wall\"
635";
636        assert_eq!(prints["00-input"], input);
637        // the meshes' JSON views did not change; their vertices did
638        let after = "after step 1: edit
639entities 3 spawned 1 despawned 1
640component core.mesh@2 2 gained 0 lost 0 changed 2
641component core.name@1 2 gained 1 lost 1 changed 1
642component core.source@1 1 gained 0 lost 0 changed 0
643component game.lamp@2 0 gained 0 lost 1 changed 0
644count moved 2
645changed 2
646  e0 ~core.mesh ~core.name
647  e2 ~core.mesh
648spawned 1
649  e3 core.name
650despawned 1
651  e1 core.name game.lamp
652dump 1
653e0
654  core.mesh@2 {\"primitives\":[{\"topology\":\"Triangles\",\"vertices\":3,\"indices\":null,\"streams\":[\"Position\"],\"material\":null}]}
655  core.name@1 \"wall2\"
656  core.source@1 \"m11:wall\"
657";
658        assert_eq!(prints["01-edit"], after);
659    }
660
661    #[test]
662    fn a_failing_pass_is_printed_as_the_failure_left_the_world() {
663        let options = PrintOptions {
664            limit: 1,
665            ..PrintOptions::default()
666        };
667        let (result, prints) = run(options, vec![Box::new(Edit), Box::new(Break)]);
668        assert_eq!(
669            result.unwrap_err().to_string(),
670            "pass break failed: broke on purpose"
671        );
672        assert_eq!(
673            prints.keys().collect::<Vec<_>>(),
674            ["00-input", "01-edit", "02-break.failed"]
675        );
676        assert!(prints["01-edit"].contains("changed 2\n  e0 ~core.mesh ~core.name\n  ... 1 more\n"));
677        assert_eq!(
678            prints["02-break.failed"],
679            "step 2 failed: break
680error: pass break failed: broke on purpose
681entities 3 spawned 0 despawned 0
682component core.mesh@2 1 gained 0 lost 1 changed 0
683component core.name@1 2 gained 0 lost 0 changed 0
684component core.source@1 1 gained 0 lost 0 changed 0
685changed 1
686  e2 -core.mesh
687spawned 0
688despawned 0
689"
690        );
691    }
692
693    #[test]
694    fn only_labels_and_sink_errors() {
695        let m = PassManager::new(PassRegistry::new());
696        let edit = || pipeline(vec![Box::new(Edit)]);
697        // not selected: nothing printed
698        let (prints, sink) = keeper();
699        let options = PrintOptions {
700            only: BTreeSet::from(["break".to_owned()]),
701            ..PrintOptions::default()
702        };
703        let mut printer = WorldPrinter::new(sink, options);
704        m.run_instrumented(&mut world(), &edit(), &mut RunReport::default(), &mut printer)
705            .unwrap();
706        assert!(prints.lock().unwrap().is_empty());
707
708        // each run under its label; a sink's refusal stops the run, naming the step
709        let (prints, sink) = keeper();
710        let mut printer = WorldPrinter::new(sink, PrintOptions::default());
711        printer.set_label("cell_0_-1").unwrap();
712        m.run_instrumented(&mut world(), &edit(), &mut RunReport::default(), &mut printer)
713            .unwrap();
714        let err = m
715            .run_instrumented(&mut world(), &edit(), &mut RunReport::default(), &mut printer)
716            .unwrap_err();
717        assert!(
718            matches!(&err, crate::pass::Error::Instrument { step, message }
719                if step == "edit" && message == "00-input printed twice"),
720            "{err}"
721        );
722        printer.set_label("cell_0_0").unwrap();
723        m.run_instrumented(&mut world(), &edit(), &mut RunReport::default(), &mut printer)
724            .unwrap();
725        let prints = prints.lock().unwrap();
726        let of = |label: &str| -> Vec<(&String, &String)> {
727            prints
728                .iter()
729                .filter(|((l, _), _)| l.as_deref() == Some(label))
730                .map(|((_, n), t)| (n, t))
731                .collect()
732        };
733        assert_eq!(of("cell_0_-1").len(), 2);
734        assert_eq!(of("cell_0_-1"), of("cell_0_0"));
735        for bad in ["", "..", "a/b", ".x", "a b"] {
736            assert!(printer.set_label(bad).is_err(), "{bad:?}");
737        }
738    }
739
740    #[test]
741    fn filters_parse() {
742        let f = EntityFilter::parse("e3, entity=7, has=core.light, source=m11, name=wall, changed").unwrap();
743        assert_eq!(
744            f.entities,
745            BTreeSet::from([Entity::from_index(3), Entity::from_index(7)])
746        );
747        assert_eq!(f.has, BTreeSet::from(["core.light".to_owned()]));
748        assert_eq!(
749            (f.source, f.name, f.changed),
750            (vec!["m11".into()], vec!["wall".into()], true)
751        );
752        assert_eq!(EntityFilter::parse("all").unwrap(), EntityFilter::default());
753        for bad in ["", "e", "e3x", "entity=x", "has=", "colour=red"] {
754            assert!(EntityFilter::parse(bad).is_err(), "{bad:?}");
755        }
756    }
757
758    /// The child half of [`prints_are_identical_across_processes`]: writes its prints, one after another, to the file
759    /// the variable names.
760    #[test]
761    #[ignore = "run by prints_are_identical_across_processes"]
762    fn print_child() {
763        let path = std::env::var_os("PRIMASE_PRINT_CHILD_FILE").expect("set by its parent test");
764        let options = PrintOptions {
765            dump: Some(EntityFilter::parse("all").unwrap()),
766            ..PrintOptions::default()
767        };
768        let (result, prints) = run(options, vec![Box::new(Edit), Box::new(Break)]);
769        result.unwrap_err();
770        let all: String = prints
771            .iter()
772            .map(|(n, t)| {
773                format!(
774                    "== {n}
775{t}"
776                )
777            })
778            .collect();
779        std::fs::write(path, all).unwrap();
780    }
781
782    /// Each process seeds its hash maps (the world's column table among them) differently: the prints must not show it.
783    #[test]
784    fn prints_are_identical_across_processes() {
785        let exe = std::env::current_exe().unwrap();
786        let texts = ["a", "b"].map(|run| {
787            let path = std::env::temp_dir().join(format!("primase-print-{run}-{}.txt", std::process::id()));
788            // output(), not status(): the child's own test output must not reach this binary's
789            let child = std::process::Command::new(&exe)
790                .args([
791                    "--ignored",
792                    "--exact",
793                    "print::tests::print_child",
794                    "--test-threads=1",
795                ])
796                .env("PRIMASE_PRINT_CHILD_FILE", &path)
797                .output()
798                .unwrap();
799            assert!(
800                child.status.success(),
801                "{}{}",
802                String::from_utf8_lossy(&child.stdout),
803                String::from_utf8_lossy(&child.stderr)
804            );
805            let text = std::fs::read_to_string(&path).unwrap();
806            std::fs::remove_file(&path).unwrap();
807            text
808        });
809        assert_eq!(
810            texts[0]
811                .matches(
812                    "
813== "
814                )
815                .count()
816                + 1,
817            3
818        );
819        assert_eq!(texts[0], texts[1]);
820    }
821}