1use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct Character {
28 pub nickname: String,
30 pub emoji: String,
32 pub palette: Palette,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct Palette {
44 pub primary_hex: String,
46 pub accent_hex: String,
48 pub ansi256_primary: u8,
50 pub ansi256_accent: u8,
52}
53
54impl Character {
55 pub fn from_did(did: &str) -> Self {
72 let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
73 if let Some(idx) = stripped.rfind('-') {
74 let suffix = &stripped[idx + 1..];
75 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
76 return Self::from_seed(suffix.as_bytes());
77 }
78 }
79 Self::from_seed(did.as_bytes())
81 }
82
83 pub fn from_card(card: &serde_json::Value) -> Self {
106 let did_opt = card.get("did").and_then(|d| d.as_str());
107 let did = match did_opt {
108 Some(d) if !d.is_empty() => d,
109 _ => return Self::unknown_peer(),
110 };
111 let display = card.get("display").and_then(|d| d.as_object());
112 let nick = display
113 .and_then(|d| d.get("nickname"))
114 .and_then(|n| n.as_str())
115 .map(sanitize_display_text)
116 .filter(|s| !s.is_empty());
117 let emoji = display
118 .and_then(|d| d.get("emoji"))
119 .and_then(|e| e.as_str())
120 .map(sanitize_display_text)
121 .filter(|s| !s.is_empty());
122 Self::from_did_with_override(did, nick.as_deref(), emoji.as_deref())
123 }
124
125 fn unknown_peer() -> Self {
130 Self {
131 nickname: "unknown-peer".to_string(),
132 emoji: "❓".to_string(),
133 palette: Palette {
134 primary_hex: "#7d7d7d".to_string(),
135 accent_hex: "#a8a8a8".to_string(),
136 ansi256_primary: 244,
137 ansi256_accent: 248,
138 },
139 }
140 }
141
142 pub fn from_did_with_override(
151 did: &str,
152 nickname_override: Option<&str>,
153 emoji_override: Option<&str>,
154 ) -> Self {
155 let auto = Self::from_did(did);
156 Self {
157 nickname: nickname_override
158 .filter(|s| !s.is_empty())
159 .map(str::to_string)
160 .unwrap_or(auto.nickname),
161 emoji: emoji_override
162 .filter(|s| !s.is_empty())
163 .map(str::to_string)
164 .unwrap_or(auto.emoji),
165 palette: auto.palette,
166 }
167 }
168
169 pub fn from_seed(seed: &[u8]) -> Self {
175 let mut h = Sha256::new();
176 h.update(seed);
177 let digest = h.finalize();
178 let adj_idx =
181 u32::from_be_bytes(digest[0..4].try_into().unwrap()) as usize % ADJECTIVES.len();
182 let noun_idx = u32::from_be_bytes(digest[4..8].try_into().unwrap()) as usize % NOUNS.len();
183 let emoji_idx =
184 u32::from_be_bytes(digest[8..12].try_into().unwrap()) as usize % EMOJIS.len();
185 let hue_raw = u32::from_be_bytes(digest[12..16].try_into().unwrap());
187 let hue_deg = (hue_raw % 3600) as f32 / 10.0; let sat = 0.55 + (digest[16] as f32 / 255.0) * 0.25; let light = 0.50 + (digest[17] as f32 / 255.0) * 0.15; let accent_hue_deg = (hue_deg + 30.0) % 360.0;
191 let accent_light = 0.65 + (digest[18] as f32 / 255.0) * 0.15; let (pr, pg, pb) = hsl_to_rgb(hue_deg, sat, light);
194 let (ar, ag, ab) = hsl_to_rgb(accent_hue_deg, sat, accent_light);
195
196 Self {
197 nickname: format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx]),
198 emoji: EMOJIS[emoji_idx].to_string(),
199 palette: Palette {
200 primary_hex: format!("#{pr:02x}{pg:02x}{pb:02x}"),
201 accent_hex: format!("#{ar:02x}{ag:02x}{ab:02x}"),
202 ansi256_primary: rgb_to_ansi256(pr, pg, pb),
203 ansi256_accent: rgb_to_ansi256(ar, ag, ab),
204 },
205 }
206 }
207
208 pub fn short(&self) -> String {
210 format!("{} {}", self.emoji, self.nickname)
211 }
212
213 pub fn colored(&self) -> String {
218 format!(
219 "\x1b[38;5;{}m{} {}\x1b[0m",
220 self.palette.ansi256_primary, self.emoji, self.nickname
221 )
222 }
223}
224
225pub const MAX_DISPLAY_CHARS: usize = 64;
239
240pub fn sanitize_display_text(s: &str) -> String {
241 s.chars()
242 .filter(|c| !c.is_control())
243 .take(MAX_DISPLAY_CHARS)
244 .collect()
245}
246
247fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
250 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
251 let h_prime = h / 60.0;
252 let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
253 let (r1, g1, b1) = match h_prime as i32 {
254 0 => (c, x, 0.0),
255 1 => (x, c, 0.0),
256 2 => (0.0, c, x),
257 3 => (0.0, x, c),
258 4 => (x, 0.0, c),
259 _ => (c, 0.0, x),
260 };
261 let m = l - c / 2.0;
262 let r = ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
263 let g = ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
264 let b = ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
265 (r, g, b)
266}
267
268fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
270 let q = |c: u8| -> u8 { ((c as u16 * 5 + 127) / 255) as u8 }; 16 + 36 * q(r) + 6 * q(g) + q(b)
272}
273
274pub fn terminal_supports_emoji() -> bool {
288 if let Ok(v) = std::env::var("WIRE_EMOJI") {
289 return matches!(v.as_str(), "on" | "1" | "true");
290 }
291 if std::env::var("TERM_PROGRAM").is_ok() {
293 return true;
294 }
295 if let Ok(term) = std::env::var("TERM") {
296 let t = term.to_ascii_lowercase();
297 if t.contains("256color") || t.contains("kitty") || t.contains("alacritty") {
300 return true;
301 }
302 }
303 for var in ["LC_ALL", "LC_CTYPE", "LANG"] {
305 if let Ok(v) = std::env::var(var)
306 && (v.contains("UTF-8") || v.contains("utf8"))
307 {
308 return true;
309 }
310 }
311 if cfg!(windows) {
316 return false;
317 }
318 true
320}
321
322pub fn emoji_with_fallback(ch: &Character) -> String {
328 if terminal_supports_emoji() {
329 return ch.emoji.clone();
330 }
331 let label: &str = match ch.emoji.as_str() {
335 "🐻" => "bear",
336 "🐅" => "tiger",
337 "🦊" => "fox",
338 "🦔" => "hedgehog",
339 "🐦" => "bird",
340 "🦉" => "owl",
341 "🐺" => "wolf",
342 "🦌" => "deer",
343 "🐢" => "turtle",
344 "🦎" => "lizard",
345 "🐍" => "snake",
346 "🐳" => "whale",
347 "🐬" => "dolphin",
348 "🐠" => "fish",
349 "🐌" => "snail",
350 "🦋" => "butterfly",
351 "🌳" => "tree",
352 "🌲" => "evergreen",
353 "🌴" => "palm",
354 "🌵" => "cactus",
355 "🌾" => "grain",
356 "🌻" => "sunflower",
357 "🌷" => "tulip",
358 "🌹" => "rose",
359 "🌸" => "blossom",
360 "🍄" => "mushroom",
361 "🍇" => "grapes",
362 "🍓" => "berry",
363 "🍒" => "cherry",
364 "🍋" => "lemon",
365 "🌙" => "moon",
366 "⭐" => "star",
367 "🌟" => "sparkle",
368 "✨" => "shimmer",
369 "☄" => "comet",
370 "🪐" => "ringed-planet",
371 "🛰" => "satellite",
372 "🛡" => "shield",
373 "⚓" => "anchor",
374 "⚙" => "gear",
375 "🕯" => "candle",
376 "🪴" => "potted-plant",
377 "🪨" => "rock",
378 "👻" => "ghost",
379 "📖" => "book",
380 "🔭" => "telescope",
381 "🌊" => "wave",
382 _ => "*",
383 };
384 format!("[{label}]")
385}
386
387const ADJECTIVES: &[&str] = &[
391 "agate",
392 "alpine",
393 "amber",
394 "ancient",
395 "antique",
396 "arctic",
397 "ashen",
398 "auburn",
399 "autumn",
400 "azure",
401 "balmy",
402 "blithe",
403 "brave",
404 "breezy",
405 "briar",
406 "bright",
407 "brisk",
408 "bronze",
409 "brushed",
410 "bubbling",
411 "burnished",
412 "calm",
413 "candle",
414 "cedar",
415 "chestnut",
416 "chill",
417 "chipper",
418 "cinder",
419 "clay",
420 "clear",
421 "cliffside",
422 "cobalt",
423 "copper",
424 "coral",
425 "cordial",
426 "cosmic",
427 "crimson",
428 "crisp",
429 "crystal",
430 "curious",
431 "dapper",
432 "dappled",
433 "dawn",
434 "daydream",
435 "deep",
436 "delta",
437 "dewy",
438 "distant",
439 "drift",
440 "drowsy",
441 "dune",
442 "dusky",
443 "eager",
444 "echoing",
445 "ember",
446 "emerald",
447 "feral",
448 "ferny",
449 "festive",
450 "fjord",
451 "flaxen",
452 "fluted",
453 "fond",
454 "forest",
455 "foxtrot",
456 "fragrant",
457 "frosted",
458 "frosty",
459 "garnet",
460 "gentle",
461 "ginger",
462 "glacial",
463 "glassy",
464 "gleaming",
465 "glint",
466 "glossy",
467 "gold",
468 "graceful",
469 "granite",
470 "grove",
471 "hammered",
472 "harbor",
473 "hardy",
474 "hazel",
475 "heath",
476 "honey",
477 "humble",
478 "hush",
479 "indigo",
480 "ivory",
481 "jade",
482 "jaunty",
483 "juniper",
484 "keen",
485 "kelp",
486 "kindly",
487 "knit",
488 "lacquered",
489 "lapis",
490 "lavender",
491 "leaden",
492 "lichen",
493 "lilac",
494 "linen",
495 "lively",
496 "lonely",
497 "lucid",
498 "lunar",
499 "marble",
500 "marsh",
501 "meadow",
502 "mellow",
503 "merry",
504 "mild",
505 "minted",
506 "misted",
507 "misty",
508 "moonlit",
509 "morning",
510 "mossy",
511 "muted",
512 "neon",
513 "nimble",
514 "noble",
515 "north",
516 "ochre",
517 "olive",
518 "onyx",
519 "opal",
520 "orchid",
521 "outback",
522 "pearl",
523 "pearled",
524 "peat",
525 "petal",
526 "pewter",
527 "pine",
528 "placid",
529 "plucky",
530 "plum",
531 "polar",
532 "polished",
533 "poppy",
534 "prairie",
535 "primrose",
536 "prussian",
537 "purpled",
538 "quartz",
539 "quiet",
540 "quill",
541 "raven",
542 "reckless",
543 "redwood",
544 "restless",
545 "ribbon",
546 "river",
547 "rosemary",
548 "rosy",
549 "ruby",
550 "rusted",
551 "rustic",
552 "russet",
553 "saffron",
554 "sage",
555 "salt",
556 "sandy",
557 "satin",
558 "scarlet",
559 "sea",
560 "shaded",
561 "shadow",
562 "shimmer",
563 "shining",
564 "shore",
565 "silken",
566 "silver",
567 "skylit",
568 "slate",
569 "slatey",
570 "smoky",
571 "smolder",
572 "snowy",
573 "soft",
574 "solar",
575 "splendid",
576 "spruce",
577 "starry",
578 "steadfast",
579 "steady",
580 "sterling",
581 "stone",
582 "sturdy",
583 "sublime",
584 "summer",
585 "sunlit",
586 "sunny",
587 "supple",
588 "sweetbay",
589 "swift",
590 "tawny",
591 "teal",
592 "tender",
593 "terra",
594 "thistle",
595 "thrushlike",
596 "tidal",
597 "tinder",
598 "tinkling",
599 "topaz",
600 "torrid",
601 "tranquil",
602 "trillium",
603 "twilight",
604 "umber",
605 "valley",
606 "velvet",
607 "vernal",
608 "verdant",
609 "vesper",
610 "vibrant",
611 "violet",
612 "vivid",
613 "warm",
614 "warmer",
615 "weathered",
616 "westwind",
617 "whispered",
618 "wildflower",
619 "willow",
620 "winterly",
621 "windy",
622 "winter",
623 "wisp",
624 "withered",
625 "witty",
626 "woodland",
627 "woven",
628 "wren",
629 "wry",
630 "yarrow",
631 "yonder",
632 "zen",
633 "zephyr",
634];
635
636const NOUNS: &[&str] = &[
640 "anchor",
641 "ash",
642 "aspen",
643 "atlas",
644 "aurora",
645 "badger",
646 "bark",
647 "bay",
648 "bayou",
649 "beacon",
650 "beaver",
651 "bell",
652 "birch",
653 "bison",
654 "blossom",
655 "bough",
656 "branch",
657 "breeze",
658 "briar",
659 "brook",
660 "bud",
661 "bunting",
662 "burrow",
663 "butte",
664 "caldera",
665 "camellia",
666 "canyon",
667 "cardinal",
668 "caribou",
669 "cedar",
670 "chime",
671 "chinook",
672 "cinder",
673 "cirrus",
674 "cliff",
675 "cloudburst",
676 "comet",
677 "compass",
678 "copper",
679 "coral",
680 "cove",
681 "creek",
682 "crest",
683 "cricket",
684 "crow",
685 "cumulus",
686 "cyclone",
687 "cypress",
688 "dale",
689 "delta",
690 "dew",
691 "dewdrop",
692 "dolphin",
693 "dove",
694 "dragonfly",
695 "dune",
696 "dusk",
697 "eagle",
698 "ember",
699 "fern",
700 "field",
701 "finch",
702 "fjord",
703 "flame",
704 "flax",
705 "fleck",
706 "fog",
707 "foam",
708 "forest",
709 "fox",
710 "frost",
711 "garnet",
712 "gander",
713 "geode",
714 "geyser",
715 "glade",
716 "gleam",
717 "glen",
718 "glimmer",
719 "gorge",
720 "grove",
721 "hare",
722 "harbor",
723 "haze",
724 "headland",
725 "hearth",
726 "heath",
727 "heron",
728 "hollow",
729 "hummingbird",
730 "ibis",
731 "iris",
732 "ivy",
733 "jasmine",
734 "jasper",
735 "jay",
736 "juniper",
737 "kelp",
738 "kestrel",
739 "kettle",
740 "kingfisher",
741 "knoll",
742 "lagoon",
743 "lake",
744 "lantern",
745 "lark",
746 "laurel",
747 "leaf",
748 "leaflet",
749 "ledge",
750 "lichen",
751 "lily",
752 "linden",
753 "lion",
754 "loft",
755 "loon",
756 "lotus",
757 "lupin",
758 "lynx",
759 "magnolia",
760 "magpie",
761 "maple",
762 "marmot",
763 "marsh",
764 "meadow",
765 "meadowlark",
766 "mesa",
767 "mist",
768 "moor",
769 "moss",
770 "moth",
771 "mountain",
772 "narwhal",
773 "nettle",
774 "nightingale",
775 "nimbus",
776 "oak",
777 "ocean",
778 "ochre",
779 "opal",
780 "orchard",
781 "orchid",
782 "oriole",
783 "otter",
784 "owl",
785 "palm",
786 "pelican",
787 "petal",
788 "pine",
789 "pinion",
790 "plain",
791 "plateau",
792 "plover",
793 "pond",
794 "poppy",
795 "prairie",
796 "prism",
797 "puffin",
798 "quartz",
799 "quill",
800 "raindrop",
801 "rapid",
802 "raven",
803 "ravine",
804 "redwood",
805 "reef",
806 "ridge",
807 "river",
808 "robin",
809 "rook",
810 "rosemary",
811 "rowan",
812 "sable",
813 "saffron",
814 "sage",
815 "salmon",
816 "sand",
817 "sandbar",
818 "sandpiper",
819 "sapling",
820 "savanna",
821 "scrub",
822 "sea",
823 "shadow",
824 "shale",
825 "shard",
826 "sheaf",
827 "shore",
828 "shrub",
829 "sky",
830 "slate",
831 "snowdrop",
832 "snowfall",
833 "snowflake",
834 "sparrow",
835 "spindle",
836 "spire",
837 "spring",
838 "sprout",
839 "spruce",
840 "squall",
841 "starling",
842 "starshine",
843 "steppe",
844 "stone",
845 "stratus",
846 "summit",
847 "swallow",
848 "swift",
849 "tarn",
850 "tern",
851 "thaw",
852 "thicket",
853 "thistle",
854 "thrush",
855 "tide",
856 "tideline",
857 "tinder",
858 "topaz",
859 "tournesol",
860 "trillium",
861 "trout",
862 "tundra",
863 "twilight",
864 "twig",
865 "valley",
866 "vesper",
867 "vine",
868 "violet",
869 "wallaby",
870 "warbler",
871 "wave",
872 "weasel",
873 "whirlwind",
874 "willow",
875 "wisp",
876 "wolf",
877 "wood",
878 "wren",
879 "yarrow",
880 "yew",
881 "zephyr",
882];
883
884const EMOJIS: &[&str] = &[
892 "🦊", "🐺", "🐻", "🐅", "🐆", "🦓", "🦒", "🦌", "🦘", "🐇", "🦔", "🦣", "🦏", "🐈", "🐱", "🐶",
894 "🐰", "🦦", "🦥", "🦡", "🦨", "🦄", "🐴", "🐗", "🐘", "🦬", "🦫", "🐪", "🦙", "🐭", "🐹", "🐀",
895 "🦅", "🦉", "🦢", "🦩", "🐧", "🦃", "🦚", "🦜", "🦤", "🦆", "🐓", "🐔", "🐦", "🪶",
897 "🐊", "🦎", "🐍", "🐢", "🦕", "🦖", "🐸", "🐙", "🐬", "🐳", "🐋", "🐡", "🦈", "🦭", "🐟", "🐠", "🦀", "🦞", "🦐", "🐚",
900 "🐝", "🦋", "🐌", "🐞", "🦗", "🕷", "🦂", "🌲", "🌳", "🌴", "🌵", "🌱", "🌿", "🍃", "🍀", "🍁", "🍂", "🌷", "🌸", "🌺", "🌻", "🌼", "🌹", "🪻", "🪷", "🍄", "🍇", "🍈", "🍉", "🍊", "🍋", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", "🍓", "🫐", "🥝",
905 "🌊", "🌋", "🌙", "🌟", "🌈", "🔥", "❄", "💧", "⚡", "☀", "☁", "⛄",
907 "💎", "🪄", "🔮", "🧿", "🌠", "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻", "🥁", "🪕", "🪈", "⚓", "🧭", "🏺", "🪴", "🗿", "🛡", "🗝", "🎲", "🎭", "🎨", "🎯", "🪐",
911];
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916 use serde_json::json;
917 use std::collections::HashSet;
918
919 #[test]
920 fn deterministic_same_did() {
921 let a = Character::from_did("did:wire:paul-a1b2c3d4");
922 let b = Character::from_did("did:wire:paul-a1b2c3d4");
923 assert_eq!(a, b);
924 }
925
926 #[test]
927 fn different_dids_differ() {
928 let a = Character::from_did("did:wire:paul-a1b2c3d4");
929 let b = Character::from_did("did:wire:paul-e5f6a7b8");
930 assert_ne!(a, b);
931 }
932
933 #[test]
934 fn nickname_is_hyphenated_pair() {
935 let c = Character::from_did("did:wire:test-deadbeef");
936 let parts: Vec<&str> = c.nickname.split('-').collect();
937 assert_eq!(parts.len(), 2);
938 assert!(parts[0].chars().all(|ch| ch.is_ascii_lowercase()));
939 assert!(parts[1].chars().all(|ch| ch.is_ascii_lowercase()));
940 }
941
942 #[test]
943 fn emoji_is_in_curated_set() {
944 let c = Character::from_did("did:wire:test-cafebabe");
945 assert!(EMOJIS.contains(&c.emoji.as_str()));
946 }
947
948 #[test]
949 fn palette_hex_is_well_formed() {
950 let c = Character::from_did("did:wire:test-12345678");
951 assert!(c.palette.primary_hex.starts_with('#'));
952 assert_eq!(c.palette.primary_hex.len(), 7);
953 assert!(c.palette.accent_hex.starts_with('#'));
954 assert_eq!(c.palette.accent_hex.len(), 7);
955 }
956
957 #[test]
958 fn ansi256_in_cube_range() {
959 let c = Character::from_did("did:wire:test-87654321");
960 assert!((16..=231).contains(&c.palette.ansi256_primary));
961 assert!((16..=231).contains(&c.palette.ansi256_accent));
962 }
963
964 #[test]
965 fn short_format() {
966 let c = Character::from_did("did:wire:fixed-seed-here");
967 let short = c.short();
968 assert!(short.contains(&c.emoji));
969 assert!(short.contains(&c.nickname));
970 assert_eq!(short, format!("{} {}", c.emoji, c.nickname));
971 }
972
973 #[test]
974 fn colored_includes_ansi_escape() {
975 let c = Character::from_did("did:wire:colored-test");
976 let colored = c.colored();
977 assert!(colored.starts_with("\x1b[38;5;"));
978 assert!(colored.ends_with("\x1b[0m"));
979 assert!(colored.contains(&c.nickname));
980 }
981
982 #[test]
983 fn no_nickname_collisions_10k_samples() {
984 let mut chars: HashSet<(String, String, String)> = HashSet::new();
989 let mut collisions = 0;
990 for i in 0..10_000 {
991 let did = format!("did:wire:test-{i:08x}");
992 let c = Character::from_did(&did);
993 let key = (
994 c.nickname.clone(),
995 c.emoji.clone(),
996 c.palette.primary_hex.clone(),
997 );
998 if !chars.insert(key) {
999 collisions += 1;
1000 }
1001 }
1002 assert!(
1003 collisions < 100,
1004 "saw {collisions} character-triple collisions in 10k samples (>1%)"
1005 );
1006 }
1007
1008 #[test]
1009 fn word_lists_have_expected_size() {
1010 assert!(ADJECTIVES.len() >= 100, "adjective list too small");
1011 assert!(NOUNS.len() >= 100, "noun list too small");
1012 assert!(EMOJIS.len() >= 50, "emoji list too small");
1013 }
1014
1015 #[test]
1016 fn no_duplicate_words() {
1017 let adj_set: HashSet<&&str> = ADJECTIVES.iter().collect();
1018 assert_eq!(adj_set.len(), ADJECTIVES.len(), "duplicate adjective");
1019 let noun_set: HashSet<&&str> = NOUNS.iter().collect();
1020 assert_eq!(noun_set.len(), NOUNS.len(), "duplicate noun");
1021 let emoji_set: HashSet<&&str> = EMOJIS.iter().collect();
1022 assert_eq!(emoji_set.len(), EMOJIS.len(), "duplicate emoji");
1023 }
1024
1025 #[test]
1026 fn hsl_to_rgb_known_values() {
1027 let (r, g, b) = hsl_to_rgb(0.0, 1.0, 0.5);
1029 assert_eq!(r, 255);
1030 assert_eq!(g, 0);
1031 assert_eq!(b, 0);
1032 let (r, g, b) = hsl_to_rgb(120.0, 1.0, 0.5);
1034 assert_eq!(r, 0);
1035 assert_eq!(g, 255);
1036 assert_eq!(b, 0);
1037 let (r, g, b) = hsl_to_rgb(240.0, 1.0, 0.5);
1039 assert_eq!(r, 0);
1040 assert_eq!(g, 0);
1041 assert_eq!(b, 255);
1042 }
1043
1044 #[test]
1045 fn sanitize_strips_ansi_escape() {
1046 let out = sanitize_display_text("\x1b]0;owned\x07");
1051 assert!(!out.contains('\x1b'), "ESC must be stripped: {out:?}");
1052 assert!(!out.contains('\x07'), "BEL must be stripped: {out:?}");
1053 assert_eq!(out, "]0;owned");
1055 let out2 = sanitize_display_text("\x1b[2J\x1b[H");
1057 assert!(!out2.contains('\x1b'));
1058 assert_eq!(out2, "[2J[H");
1059 assert_eq!(sanitize_display_text("hello\nworld"), "helloworld");
1061 assert_eq!(sanitize_display_text("a\tb\x7fc"), "abc");
1062 }
1063
1064 #[test]
1065 fn sanitize_preserves_unicode_emoji_and_text() {
1066 assert_eq!(
1067 sanitize_display_text("🦊 foxtrot-meadow"),
1068 "🦊 foxtrot-meadow"
1069 );
1070 assert_eq!(sanitize_display_text("café résumé"), "café résumé");
1071 }
1072
1073 #[test]
1074 fn sanitize_caps_length() {
1075 let long = "a".repeat(200);
1076 let out = sanitize_display_text(&long);
1077 assert_eq!(out.chars().count(), MAX_DISPLAY_CHARS);
1078 }
1079
1080 #[test]
1081 fn from_card_with_empty_did_returns_unknown_sentinel() {
1082 let card = json!({"handle": "broken"});
1086 let c = Character::from_card(&card);
1087 assert_eq!(c.nickname, "unknown-peer");
1088 assert_eq!(c.emoji, "❓");
1089 }
1090
1091 #[test]
1092 fn from_card_with_null_did_returns_unknown_sentinel() {
1093 let card = json!({"did": null, "handle": "broken"});
1094 let c = Character::from_card(&card);
1095 assert_eq!(c.nickname, "unknown-peer");
1096 }
1097
1098 #[test]
1099 fn from_card_strips_escape_from_published_nickname() {
1100 let card = json!({
1105 "did": "did:wire:malicious-deadbeef",
1106 "display": {"nickname": "\x1b]0;OWNED\x07evil", "emoji": "🦊"},
1107 });
1108 let c = Character::from_card(&card);
1109 assert!(!c.nickname.contains('\x1b'));
1111 assert!(!c.nickname.contains('\x07'));
1112 assert!(c.nickname.contains("OWNED")); assert_eq!(c.emoji, "🦊");
1114 }
1115
1116 #[test]
1117 fn from_card_with_published_override_uses_it() {
1118 let card = json!({
1119 "did": "did:wire:friend-12345678",
1120 "display": {"nickname": "the-forge", "emoji": "🔨"},
1121 });
1122 let c = Character::from_card(&card);
1123 assert_eq!(c.nickname, "the-forge");
1124 assert_eq!(c.emoji, "🔨");
1125 }
1126
1127 #[test]
1128 fn from_card_without_display_falls_back_to_did() {
1129 let card = json!({"did": "did:wire:friend-12345678"});
1130 let c = Character::from_card(&card);
1131 let auto = Character::from_did("did:wire:friend-12345678");
1132 assert_eq!(c, auto);
1133 }
1134
1135 #[test]
1136 fn rgb_to_ansi256_matches_cube() {
1137 assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
1139 assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
1140 assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
1142 assert_eq!(rgb_to_ansi256(0, 255, 0), 46);
1144 }
1145}