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