1use 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#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PrintOptions {
26 pub only: BTreeSet<String>,
28 pub limit: usize,
30 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct EntityFilter {
48 pub entities: BTreeSet<Entity>,
49 pub has: BTreeSet<String>,
51 pub source: Vec<String>,
53 pub name: Vec<String>,
55 pub changed: bool,
57}
58
59impl EntityFilter {
60 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
120pub 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
133pub struct WorldPrinter<S: PrintSink> {
135 sink: S,
136 label: Option<String>,
137 options: PrintOptions,
138 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 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 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 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#[derive(Debug, Default)]
246struct Snapshot {
247 alive: BTreeSet<Entity>,
248 components: BTreeMap<String, (u32, BTreeMap<Entity, u64>)>,
250 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
279struct 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
291fn digest(value: &dyn fmt::Debug) -> u64 {
293 let mut h = Fnv(0xcbf2_9ce4_8422_2325);
294 let _ = write!(h, "{value:?}");
296 h.0
297}
298
299type 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 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
442fn 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
460fn 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 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 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 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 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 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 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 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 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 #[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 #[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 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}