monospace-core 0.1.0

Core library for Monospace: parsing, layout and ASCII rendering of diagrams
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
//! Glyph rules and the catalog they are looked up in. See _Strokes, glyph sets and the catalog_
//! in [`docs/model.md`](../../../docs/model.md).
//!
//! A key carries a stroke per side rather than one stroke and four flags, because all four sides
//! are read on every lookup and the tables the rules come from already have four independent
//! columns: the narrower key would have had to change, with the catalog and every rule reading it,
//! the day strokes stopped being uniform. Glyph sets collapse into one catalog rather than being
//! consulted in turn, because precedence is settled while a catalog is being built rather than
//! while it is read, and nothing can tell the two apart afterwards.

use std::collections::HashMap;

use unicode_segmentation::UnicodeSegmentation;

use crate::Stroke;

/// What a cell renders to: one extended grapheme cluster, never a control character.
///
/// A grapheme cluster is the unit [UAX #29](https://www.unicode.org/reports/tr29/) draws a
/// boundary around — what a reader sees as one character, which may be built from several code
/// points: `é` written as `e` followed by a combining acute, a flag, or an emoji with a modifier.
/// A control character is one in Unicode's `Cc` category — what [`char::is_control`] answers —
/// and the refusal holds even inside an otherwise-accepted cluster: `"\r\n"` is one cluster by
/// UAX #29 and is refused anyway.
///
/// Width is not part of this invariant: a grapheme wider than one column is accepted and shifts
/// the rest of its row by a column, and a cluster made only of format characters is accepted and
/// occupies no column. Equality is by text, not by appearance: two glyphs that render alike but
/// are encoded differently compare unequal, because nothing here is normalized.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Glyph(String);

impl Glyph {
    /// `Some` when `text` is exactly one extended grapheme cluster containing no control
    /// character, `None` otherwise.
    #[must_use]
    pub fn new(text: &str) -> Option<Self> {
        let mut clusters = text.graphemes(true);
        clusters.next()?;
        if clusters.next().is_some() || text.chars().any(char::is_control) {
            return None;
        }

        Some(Self(text.to_owned()))
    }

    /// The glyph's text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A symbol a cell can name: a direction an arrow points in, or a shape a terminal stands on.
///
/// Closed rather than a name, so that a symbol the core does not know cannot arrive and be
/// rendered as a space: a misspelled style and a missing pair answer alike, and this is the half
/// that must not. [`Self::ALL`] is the order a sheet prints the vocabulary in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Symbol {
    /// An arrow pointing up.
    DirectionUp,
    /// An arrow pointing right.
    DirectionRight,
    /// An arrow pointing down.
    DirectionDown,
    /// An arrow pointing left.
    DirectionLeft,
    /// A diamond, which points nowhere.
    Diamond,
    /// A hexagon, which points nowhere.
    Hexagon,
    /// A circle, which points nowhere.
    Circle,
}

impl Symbol {
    /// Every symbol, in the order a sheet prints them: the four directions, then the shapes.
    pub const ALL: [Symbol; 7] = [
        Symbol::DirectionUp,
        Symbol::DirectionRight,
        Symbol::DirectionDown,
        Symbol::DirectionLeft,
        Symbol::Diamond,
        Symbol::Hexagon,
        Symbol::Circle,
    ];
}

/// The name of the style a symbol is drawn in — `solid`, `outline`, `arrow`.
///
/// Open, as [`Stroke`] is: the catalog decides which styles exist, so a style someone loads is a
/// style like any other. A style is not a table: a table may hold rules of more than one, and one
/// style may be spread over several.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SymbolStyleName(pub String);

impl From<&str> for SymbolStyleName {
    fn from(name: &str) -> Self {
        Self(name.to_owned())
    }
}

/// A symbol and the style it is drawn in: the value a cell holds and the key its rule is filed
/// under.
///
/// **The two are one value on purpose**, so writing a cell and looking one up are the same
/// spelling: a second key carrying the same two fields would be a second way to say one thing,
/// with a conversion at every write and every lookup.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SymbolCell {
    /// Which symbol is drawn.
    pub symbol: Symbol,
    /// Which style it is drawn in.
    pub style: SymbolStyleName,
}

/// The four sides of a glyph rule: a stroke name on each side, or nothing.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GlyphKey {
    /// The stroke on the top side, or `None` if none runs there.
    pub top: Option<Stroke>,
    /// The stroke on the right side, or `None` if none runs there.
    pub right: Option<Stroke>,
    /// The stroke on the bottom side, or `None` if none runs there.
    pub bottom: Option<Stroke>,
    /// The stroke on the left side, or `None` if none runs there.
    pub left: Option<Stroke>,
}

/// Every glyph rule in play. Answering a key is one lookup; nothing about a catalog says which
/// set a rule came from.
///
/// **Two maps, because there are two kinds of key.** An arm rule is filed under four strokes, a
/// symbol rule under a symbol and a style, and the two answer different questions about a miss:
/// an arm cell degrades to its base stroke, while a symbol cell answers nothing. Folding them into
/// one map over one key would make the answers collide.
pub struct GlyphCatalog {
    /// The rules filed under a combination of arms.
    arms: HashMap<GlyphKey, Glyph>,
    /// The rules filed under a symbol and the style it is drawn in.
    symbols: HashMap<SymbolCell, Glyph>,
}

/// A row of a glyph table: a stroke name or nothing on each side, and the glyph it draws.
///
/// The glyph is `&'static str` rather than `Glyph` so that no row moves when the invariant widens
/// past one character.
type Row = (
    Option<&'static str>,
    Option<&'static str>,
    Option<&'static str>,
    Option<&'static str>,
    &'static str,
);

/// The 15 rules of the Light table in [`docs/glyph-sets.md`](../../../docs/glyph-sets.md), held as
/// data rather than parsed at run time.
const LIGHT: &[Row] = &[
    (None, None, Some("light"), None, "│"),
    (None, None, Some("light"), Some("light"), "┐"),
    (None, Some("light"), Some("light"), Some("light"), "┬"),
    (
        Some("light"),
        Some("light"),
        Some("light"),
        Some("light"),
        "┼",
    ),
    (Some("light"), None, Some("light"), Some("light"), "┤"),
    (None, Some("light"), Some("light"), None, "┌"),
    (Some("light"), Some("light"), Some("light"), None, "├"),
    (Some("light"), None, Some("light"), None, "│"),
    (None, None, None, Some("light"), "─"),
    (None, Some("light"), None, Some("light"), "─"),
    (Some("light"), Some("light"), None, Some("light"), "┴"),
    (Some("light"), None, None, Some("light"), "┘"),
    (None, Some("light"), None, None, "─"),
    (Some("light"), Some("light"), None, None, "└"),
    (Some("light"), None, None, None, "│"),
];

/// The seven rules of the Solid style, held as data rather than parsed at run time. The style is
/// the core's the way Light is: `solid` is what a cell draws when nothing has said otherwise.
const SOLID: &[(Symbol, &str)] = &[
    (Symbol::DirectionUp, "▲"),
    (Symbol::DirectionRight, "▶"),
    (Symbol::DirectionDown, "▼"),
    (Symbol::DirectionLeft, "◀"),
    (Symbol::Diamond, "◆"),
    (Symbol::Hexagon, "⬢"),
    (Symbol::Circle, "●"),
];

impl GlyphCatalog {
    /// Builds a catalog from an ordered group of rules. Where two rules claim the same key, the
    /// first one in `rules` wins.
    #[must_use]
    pub fn from_rules(rules: impl IntoIterator<Item = (GlyphKey, Glyph)>) -> Self {
        let mut map = HashMap::new();
        for (key, glyph) in rules {
            map.entry(key).or_insert(glyph);
        }

        Self {
            arms: map,
            symbols: HashMap::new(),
        }
    }

    /// Builds a catalog from an ordered group of symbol rules. Where two rules claim the same
    /// pair, the first one in `rules` wins.
    #[must_use]
    pub fn from_symbol_rules(rules: impl IntoIterator<Item = (SymbolCell, Glyph)>) -> Self {
        let mut map = HashMap::new();
        for (key, glyph) in rules {
            map.entry(key).or_insert(glyph);
        }

        Self {
            arms: HashMap::new(),
            symbols: map,
        }
    }

    /// Builds a catalog by merging other catalogs, in the order given. Where two claim the same
    /// key, the earlier one in `catalogs` wins, and each of the two maps keeps its own claims.
    #[must_use]
    pub fn union(catalogs: impl IntoIterator<Item = Self>) -> Self {
        let mut arms = HashMap::new();
        let mut symbols = HashMap::new();
        for catalog in catalogs {
            for (key, glyph) in catalog.arms {
                arms.entry(key).or_insert(glyph);
            }
            for (key, glyph) in catalog.symbols {
                symbols.entry(key).or_insert(glyph);
            }
        }

        Self { arms, symbols }
    }

    /// Builds a catalog from the Light table alone.
    ///
    /// Named after what the catalog holds, not where it comes from: it stays accurate once a
    /// second built-in table exists, which is why it is not called `built_in`.
    ///
    /// # Panics
    ///
    /// Panics if a row of the Light table is not a valid glyph. That is a bug in data this
    /// library ships, never a condition a caller can trigger.
    #[must_use]
    pub fn light() -> Self {
        Self::from_rules(LIGHT.iter().map(|&(top, right, bottom, left, glyph)| {
            let key = GlyphKey {
                top: top.map(Stroke::from),
                right: right.map(Stroke::from),
                bottom: bottom.map(Stroke::from),
                left: left.map(Stroke::from),
            };
            let glyph = Glyph::new(glyph)
                .unwrap_or_else(|| panic!("Light table row {key:?} is not a valid glyph"));
            (key, glyph)
        }))
    }

    /// Builds a catalog from the Solid style alone: one glyph per [`Symbol`].
    ///
    /// Named after what it holds, as [`light`](Self::light) is, and it stays accurate once another
    /// style ships beside it.
    ///
    /// # Panics
    ///
    /// Panics if a row of the Solid table is not a valid glyph. That is a bug in data this
    /// library ships, never a condition a caller can trigger.
    #[must_use]
    pub fn solid() -> Self {
        Self::from_symbol_rules(SOLID.iter().map(|&(symbol, glyph)| {
            let key = SymbolCell {
                symbol,
                style: SymbolStyleName::from("solid"),
            };
            let glyph = Glyph::new(glyph)
                .unwrap_or_else(|| panic!("Solid table row {key:?} is not a valid glyph"));
            (key, glyph)
        }))
    }

    /// Returns the glyph `key` answers to, or `None` if the catalog has no rule for it.
    #[must_use]
    pub fn glyph(&self, key: &GlyphKey) -> Option<&Glyph> {
        self.arms.get(key)
    }

    /// Returns the glyph the pair `key` answers to, or `None` if the catalog has no rule for it.
    ///
    /// **A miss is final.** An arm cell falls back to its base stroke; a symbol cell does not fall
    /// back to another style, because a fallback would replace the look the cell named with one it
    /// did not.
    #[must_use]
    pub fn symbol(&self, key: &SymbolCell) -> Option<&Glyph> {
        self.symbols.get(key)
    }

    /// Every style the catalog holds a symbol rule for, each once, in name order.
    ///
    /// **Read off the rules rather than off a list**, so a style added to a table is reported
    /// without a second place to change and a style with no rules is not reported at all.
    #[must_use]
    pub fn symbol_styles(&self) -> Vec<&SymbolStyleName> {
        let mut styles: Vec<&SymbolStyleName> = self.symbols.keys().map(|key| &key.style).collect();
        styles.sort();
        styles.dedup();

        styles
    }
}

#[cfg(test)]
mod tests {
    use super::{Glyph, GlyphCatalog, GlyphKey, Symbol, SymbolCell, SymbolStyleName};
    use crate::Stroke;

    /// `"│"` is one cluster and one character, so it is accepted and reads back
    /// unchanged.
    #[test]
    fn a_single_character_is_accepted_and_reads_back_unchanged() {
        assert_eq!(Glyph::new("│").as_ref().map(Glyph::as_str), Some("│"));
    }

    /// Empty text is not a glyph.
    #[test]
    fn empty_text_is_refused() {
        assert_eq!(Glyph::new(""), None);
    }

    /// Two characters are two clusters too, so `"ab"` stays
    /// refused once the invariant widens.
    #[test]
    fn more_than_one_character_is_refused() {
        assert_eq!(Glyph::new("ab"), None);
    }

    /// A control character on its own is refused.
    #[test]
    fn a_control_character_is_refused() {
        assert_eq!(Glyph::new("\n"), None);
    }

    /// A zero-width joiner is a format character, not a control character, so it is
    /// accepted on its own even though it occupies no column.
    #[test]
    fn a_lone_format_character_is_accepted() {
        assert!(Glyph::new("\u{200D}").is_some());
    }

    /// `é` decomposed as `e` followed by a combining acute is two code
    /// points and one cluster, so it is accepted as one glyph.
    #[test]
    fn a_cluster_built_from_several_code_points_is_accepted() {
        assert!(Glyph::new("e\u{301}").is_some());
    }

    /// A regional-indicator pair is one cluster, so it is accepted as one
    /// glyph.
    #[test]
    fn a_regional_indicator_pair_is_accepted() {
        assert!(Glyph::new("🇦🇷").is_some());
    }

    /// `"\r\n"` is one cluster by UAX #29 and is refused anyway, which is
    /// why the invariant has two halves rather than one.
    #[test]
    fn a_control_character_is_refused_even_inside_one_cluster() {
        assert_eq!(Glyph::new("\r\n"), None);
    }

    /// `é` as U+00E9 and as `e` followed by U+0301 both construct and compare
    /// unequal, because construction does not normalize.
    #[test]
    fn two_normal_forms_of_the_same_letter_compare_unequal() {
        let composed = Glyph::new("é").expect("é is one glyph");
        let decomposed = Glyph::new("e\u{301}").expect("e followed by U+0301 is one glyph");

        assert_ne!(composed, decomposed);
    }

    /// The example named "From a cell to a character": the cell `(light; S, C, S, C)` builds a
    /// key with `light` on top and bottom and nothing on the sides, which answers `│`.
    #[test]
    fn an_exact_key_answers_the_light_table() {
        let catalog = GlyphCatalog::light();
        let key = GlyphKey {
            top: Some(Stroke::from("light")),
            right: None,
            bottom: Some(Stroke::from("light")),
            left: None,
        };

        assert_eq!(catalog.glyph(&key).map(Glyph::as_str), Some("│"));
    }

    /// The property `docs/model.md` names under _Properties worth testing_: a catalog built from
    /// one stroke's complete set answers every key a cell of that stroke can produce.
    #[test]
    fn light_answers_every_non_empty_combination_of_its_own_stroke() {
        let catalog = GlyphCatalog::light();

        for mask in 1u8..16 {
            let side = |bit: u8| {
                if mask & bit == 0 {
                    None
                } else {
                    Some(Stroke::from("light"))
                }
            };
            let key = GlyphKey {
                top: side(0b0001),
                right: side(0b0010),
                bottom: side(0b0100),
                left: side(0b1000),
            };

            assert!(catalog.glyph(&key).is_some(), "no glyph for {key:?}");
        }
    }

    #[test]
    fn the_empty_key_has_no_answer_in_the_light_table() {
        let catalog = GlyphCatalog::light();
        let key = GlyphKey {
            top: None,
            right: None,
            bottom: None,
            left: None,
        };

        assert_eq!(catalog.glyph(&key), None);
    }

    /// A key with the given stroke on its top side and nothing else, so two distinct keys are
    /// cheap to build for the `from_rules`/`union` tests below.
    fn key_with_top(stroke: &str) -> GlyphKey {
        GlyphKey {
            top: Some(Stroke::from(stroke)),
            right: None,
            bottom: None,
            left: None,
        }
    }

    /// A key only one side holds answers from that side regardless of
    /// union order.
    #[test]
    fn a_key_only_one_catalog_holds_answers_the_same_regardless_of_union_order() {
        let mine_key = key_with_top("mine");
        let mine = Glyph::new("m").expect("\"m\" is one glyph");
        let other_key = key_with_top("other");
        let other = Glyph::new("o").expect("\"o\" is one glyph");
        let mine_catalog = GlyphCatalog::from_rules([(mine_key.clone(), mine.clone())]);
        let other_catalog = GlyphCatalog::from_rules([(other_key.clone(), other.clone())]);

        let mine_first = GlyphCatalog::union([
            GlyphCatalog::from_rules([(mine_key.clone(), mine.clone())]),
            GlyphCatalog::from_rules([(other_key.clone(), other.clone())]),
        ]);
        let other_first = GlyphCatalog::union([other_catalog, mine_catalog]);

        assert_eq!(mine_first.glyph(&mine_key), Some(&mine));
        assert_eq!(mine_first.glyph(&other_key), Some(&other));
        assert_eq!(other_first.glyph(&mine_key), Some(&mine));
        assert_eq!(other_first.glyph(&other_key), Some(&other));
    }

    /// A key two catalogs both claim answers from whichever was
    /// given first to `union`, and reversing the order reverses the answer.
    #[test]
    fn a_key_two_catalogs_both_claim_answers_from_whichever_came_first() {
        let key = key_with_top("shared");
        let first = Glyph::new("1").expect("\"1\" is one glyph");
        let second = Glyph::new("2").expect("\"2\" is one glyph");

        let first_wins = GlyphCatalog::union([
            GlyphCatalog::from_rules([(key.clone(), first.clone())]),
            GlyphCatalog::from_rules([(key.clone(), second.clone())]),
        ]);
        let second_wins = GlyphCatalog::union([
            GlyphCatalog::from_rules([(key.clone(), second.clone())]),
            GlyphCatalog::from_rules([(key.clone(), first.clone())]),
        ]);

        assert_eq!(first_wins.glyph(&key), Some(&first));
        assert_eq!(second_wins.glyph(&key), Some(&second));
    }

    /// an empty `rules` iterator produces a valid catalog that answers nothing.
    #[test]
    fn an_empty_rules_iterator_produces_a_catalog_with_no_answers() {
        let catalog = GlyphCatalog::from_rules(std::iter::empty());

        assert_eq!(catalog.glyph(&key_with_top("anything")), None);
    }

    /// a zero-element `catalogs` list produces a valid catalog that answers nothing.
    #[test]
    fn a_union_of_no_catalogs_produces_a_catalog_with_no_answers() {
        let catalog = GlyphCatalog::union(std::iter::empty());

        assert_eq!(catalog.glyph(&key_with_top("anything")), None);
    }

    /// a one-element `catalogs` list answers exactly as that catalog does on its own.
    #[test]
    fn a_union_of_a_single_catalog_answers_the_same_as_that_catalog_alone() {
        let key = key_with_top("solo");
        let glyph = Glyph::new("s").expect("\"s\" is one glyph");
        let alone = GlyphCatalog::from_rules([(key.clone(), glyph.clone())]);
        let merged =
            GlyphCatalog::union([GlyphCatalog::from_rules([(key.clone(), glyph.clone())])]);

        assert_eq!(alone.glyph(&key), merged.glyph(&key));
        assert_eq!(merged.glyph(&key), Some(&glyph));
    }

    /// A pair with `symbol` drawn in `style`, so a test can name one without the whole struct
    /// spelled at every call.
    fn a_symbol(symbol: Symbol, style: &str) -> SymbolCell {
        SymbolCell {
            symbol,
            style: SymbolStyleName::from(style),
        }
    }

    /// Rule 5: `union` merges both maps by the same first-claim rule within each, and neither
    /// answers for the other. Two styles of one symbol are two pairs, so both survive.
    #[test]
    fn union_merges_both_maps_and_holds_two_styles_of_one_symbol() {
        let arm_key = key_with_top("shared-arm");
        let arm = Glyph::new("│").expect("\"│\" is one glyph");
        let solid = a_symbol(Symbol::DirectionUp, "solid");
        let outline = a_symbol(Symbol::DirectionUp, "outline");
        let solid_glyph = Glyph::new("▲").expect("\"▲\" is one glyph");
        let outline_glyph = Glyph::new("△").expect("\"△\" is one glyph");

        let arms_only = GlyphCatalog::from_rules([(arm_key.clone(), arm.clone())]);
        let symbols_only = GlyphCatalog::from_symbol_rules([
            (solid.clone(), solid_glyph.clone()),
            (outline.clone(), outline_glyph.clone()),
        ]);
        assert_eq!(arms_only.symbol(&solid), None, "an arm rule is not a pair");
        assert_eq!(
            symbols_only.glyph(&arm_key),
            None,
            "a symbol rule is not a key of arms"
        );

        let merged = GlyphCatalog::union([arms_only, symbols_only]);

        assert_eq!(merged.glyph(&arm_key), Some(&arm));
        assert_eq!(merged.symbol(&solid), Some(&solid_glyph));
        assert_eq!(merged.symbol(&outline), Some(&outline_glyph));
    }

    /// The first-claim rule is per map: a pair two catalogs both claim answers from whichever came
    /// first, and reversing the order reverses the answer.
    #[test]
    fn a_pair_two_catalogs_both_claim_answers_from_whichever_came_first() {
        let key = a_symbol(Symbol::Diamond, "shared");
        let first = Glyph::new("1").expect("\"1\" is one glyph");
        let second = Glyph::new("2").expect("\"2\" is one glyph");

        let first_wins = GlyphCatalog::union([
            GlyphCatalog::from_symbol_rules([(key.clone(), first.clone())]),
            GlyphCatalog::from_symbol_rules([(key.clone(), second.clone())]),
        ]);
        let second_wins = GlyphCatalog::union([
            GlyphCatalog::from_symbol_rules([(key.clone(), second.clone())]),
            GlyphCatalog::from_symbol_rules([(key.clone(), first.clone())]),
        ]);

        assert_eq!(first_wins.symbol(&key), Some(&first));
        assert_eq!(second_wins.symbol(&key), Some(&second));
    }

    /// Rule 6: the core's Solid style answers every symbol, and draws the characters it names.
    #[test]
    fn solid_answers_all_seven_symbols() {
        let catalog = GlyphCatalog::solid();

        let drawn: String = Symbol::ALL
            .iter()
            .filter_map(|symbol| catalog.symbol(&a_symbol(*symbol, "solid")))
            .map(Glyph::as_str)
            .collect();

        assert_eq!(drawn, "▲▶▼◀◆⬢●");
    }

    /// Rule 7: a style is reported once however many rules carry it, and the names come back in
    /// name order rather than in the order the rules were built.
    #[test]
    fn symbol_styles_reports_each_style_once_in_name_order() {
        let catalog = GlyphCatalog::from_symbol_rules([
            (
                a_symbol(Symbol::Diamond, "zulu"),
                Glyph::new("z").expect("\"z\" is one glyph"),
            ),
            (
                a_symbol(Symbol::Diamond, "alpha"),
                Glyph::new("a").expect("\"a\" is one glyph"),
            ),
            (
                a_symbol(Symbol::Circle, "alpha"),
                Glyph::new("A").expect("\"A\" is one glyph"),
            ),
            (
                a_symbol(Symbol::Circle, "mike"),
                Glyph::new("m").expect("\"m\" is one glyph"),
            ),
        ]);

        let styles: Vec<&str> = catalog
            .symbol_styles()
            .iter()
            .map(|style| style.0.as_str())
            .collect();

        assert_eq!(styles, ["alpha", "mike", "zulu"]);
    }
}