use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Character {
pub nickname: String,
pub emoji: String,
pub palette: Palette,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Palette {
pub primary_hex: String,
pub accent_hex: String,
pub ansi256_primary: u8,
pub ansi256_accent: u8,
}
impl Character {
pub fn from_did(did: &str) -> Self {
let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
if let Some(idx) = stripped.rfind('-') {
let suffix = &stripped[idx + 1..];
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
return Self::from_seed(suffix.as_bytes());
}
}
Self::from_seed(did.as_bytes())
}
pub fn from_card(card: &serde_json::Value) -> Self {
let did_opt = card.get("did").and_then(|d| d.as_str());
let did = match did_opt {
Some(d) if !d.is_empty() => d,
_ => return Self::unknown_peer(),
};
let display = card.get("display").and_then(|d| d.as_object());
let nick = display
.and_then(|d| d.get("nickname"))
.and_then(|n| n.as_str())
.map(sanitize_display_text)
.filter(|s| !s.is_empty());
let emoji = display
.and_then(|d| d.get("emoji"))
.and_then(|e| e.as_str())
.map(sanitize_display_text)
.filter(|s| !s.is_empty());
Self::from_did_with_override(did, nick.as_deref(), emoji.as_deref())
}
fn unknown_peer() -> Self {
Self {
nickname: "unknown-peer".to_string(),
emoji: "❓".to_string(),
palette: Palette {
primary_hex: "#7d7d7d".to_string(),
accent_hex: "#a8a8a8".to_string(),
ansi256_primary: 244,
ansi256_accent: 248,
},
}
}
pub fn from_did_with_override(
did: &str,
nickname_override: Option<&str>,
emoji_override: Option<&str>,
) -> Self {
let auto = Self::from_did(did);
Self {
nickname: nickname_override
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or(auto.nickname),
emoji: emoji_override
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or(auto.emoji),
palette: auto.palette,
}
}
pub fn from_seed(seed: &[u8]) -> Self {
let mut h = Sha256::new();
h.update(seed);
let digest = h.finalize();
let adj_idx =
u32::from_be_bytes(digest[0..4].try_into().unwrap()) as usize % ADJECTIVES.len();
let noun_idx = u32::from_be_bytes(digest[4..8].try_into().unwrap()) as usize % NOUNS.len();
let emoji_idx =
u32::from_be_bytes(digest[8..12].try_into().unwrap()) as usize % EMOJIS.len();
let hue_raw = u32::from_be_bytes(digest[12..16].try_into().unwrap());
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;
let accent_light = 0.65 + (digest[18] as f32 / 255.0) * 0.15;
let (pr, pg, pb) = hsl_to_rgb(hue_deg, sat, light);
let (ar, ag, ab) = hsl_to_rgb(accent_hue_deg, sat, accent_light);
Self {
nickname: format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx]),
emoji: EMOJIS[emoji_idx].to_string(),
palette: Palette {
primary_hex: format!("#{:02x}{:02x}{:02x}", pr, pg, pb),
accent_hex: format!("#{:02x}{:02x}{:02x}", ar, ag, ab),
ansi256_primary: rgb_to_ansi256(pr, pg, pb),
ansi256_accent: rgb_to_ansi256(ar, ag, ab),
},
}
}
pub fn short(&self) -> String {
format!("{} {}", self.emoji, self.nickname)
}
pub fn colored(&self) -> String {
format!(
"\x1b[38;5;{}m{} {}\x1b[0m",
self.palette.ansi256_primary, self.emoji, self.nickname
)
}
}
pub const MAX_DISPLAY_CHARS: usize = 64;
pub fn sanitize_display_text(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control())
.take(MAX_DISPLAY_CHARS)
.collect()
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let h_prime = h / 60.0;
let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
let (r1, g1, b1) = match h_prime as i32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let r = ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
let g = ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
let b = ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(r, g, b)
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
let q = |c: u8| -> u8 { ((c as u16 * 5 + 127) / 255) as u8 }; 16 + 36 * q(r) + 6 * q(g) + q(b)
}
pub fn terminal_supports_emoji() -> bool {
if let Ok(v) = std::env::var("WIRE_EMOJI") {
return matches!(v.as_str(), "on" | "1" | "true");
}
if std::env::var("TERM_PROGRAM").is_ok() {
return true;
}
if let Ok(term) = std::env::var("TERM") {
let t = term.to_ascii_lowercase();
if t.contains("256color") || t.contains("kitty") || t.contains("alacritty") {
return true;
}
}
for var in ["LC_ALL", "LC_CTYPE", "LANG"] {
if let Ok(v) = std::env::var(var)
&& (v.contains("UTF-8") || v.contains("utf8"))
{
return true;
}
}
if cfg!(windows) {
return false;
}
true
}
pub fn emoji_with_fallback(ch: &Character) -> String {
if terminal_supports_emoji() {
return ch.emoji.clone();
}
let label: &str = match ch.emoji.as_str() {
"🐻" => "bear",
"🐅" => "tiger",
"🦊" => "fox",
"🦔" => "hedgehog",
"🐦" => "bird",
"🦉" => "owl",
"🐺" => "wolf",
"🦌" => "deer",
"🐢" => "turtle",
"🦎" => "lizard",
"🐍" => "snake",
"🐳" => "whale",
"🐬" => "dolphin",
"🐠" => "fish",
"🐌" => "snail",
"🦋" => "butterfly",
"🌳" => "tree",
"🌲" => "evergreen",
"🌴" => "palm",
"🌵" => "cactus",
"🌾" => "grain",
"🌻" => "sunflower",
"🌷" => "tulip",
"🌹" => "rose",
"🌸" => "blossom",
"🍄" => "mushroom",
"🍇" => "grapes",
"🍓" => "berry",
"🍒" => "cherry",
"🍋" => "lemon",
"🌙" => "moon",
"⭐" => "star",
"🌟" => "sparkle",
"✨" => "shimmer",
"☄" => "comet",
"🪐" => "ringed-planet",
"🛰" => "satellite",
"🛡" => "shield",
"⚓" => "anchor",
"⚙" => "gear",
"🕯" => "candle",
"🪴" => "potted-plant",
"🪨" => "rock",
"👻" => "ghost",
"📖" => "book",
"🔭" => "telescope",
"🌊" => "wave",
_ => "*",
};
format!("[{label}]")
}
const ADJECTIVES: &[&str] = &[
"agate",
"alpine",
"amber",
"ancient",
"antique",
"arctic",
"ashen",
"auburn",
"autumn",
"azure",
"balmy",
"blithe",
"brave",
"breezy",
"briar",
"bright",
"brisk",
"bronze",
"brushed",
"bubbling",
"burnished",
"calm",
"candle",
"cedar",
"chestnut",
"chill",
"chipper",
"cinder",
"clay",
"clear",
"cliffside",
"cobalt",
"copper",
"coral",
"cordial",
"cosmic",
"crimson",
"crisp",
"crystal",
"curious",
"dapper",
"dappled",
"dawn",
"daydream",
"deep",
"delta",
"dewy",
"distant",
"drift",
"drowsy",
"dune",
"dusky",
"eager",
"echoing",
"ember",
"emerald",
"feral",
"ferny",
"festive",
"fjord",
"flaxen",
"fluted",
"fond",
"forest",
"foxtrot",
"fragrant",
"frosted",
"frosty",
"garnet",
"gentle",
"ginger",
"glacial",
"glassy",
"gleaming",
"glint",
"glossy",
"gold",
"graceful",
"granite",
"grove",
"hammered",
"harbor",
"hardy",
"hazel",
"heath",
"honey",
"humble",
"hush",
"indigo",
"ivory",
"jade",
"jaunty",
"juniper",
"keen",
"kelp",
"kindly",
"knit",
"lacquered",
"lapis",
"lavender",
"leaden",
"lichen",
"lilac",
"linen",
"lively",
"lonely",
"lucid",
"lunar",
"marble",
"marsh",
"meadow",
"mellow",
"merry",
"mild",
"minted",
"misted",
"misty",
"moonlit",
"morning",
"mossy",
"muted",
"neon",
"nimble",
"noble",
"north",
"ochre",
"olive",
"onyx",
"opal",
"orchid",
"outback",
"pearl",
"pearled",
"peat",
"petal",
"pewter",
"pine",
"placid",
"plucky",
"plum",
"polar",
"polished",
"poppy",
"prairie",
"primrose",
"prussian",
"purpled",
"quartz",
"quiet",
"quill",
"raven",
"reckless",
"redwood",
"restless",
"ribbon",
"river",
"rosemary",
"rosy",
"ruby",
"rusted",
"rustic",
"russet",
"saffron",
"sage",
"salt",
"sandy",
"satin",
"scarlet",
"sea",
"shaded",
"shadow",
"shimmer",
"shining",
"shore",
"silken",
"silver",
"skylit",
"slate",
"slatey",
"smoky",
"smolder",
"snowy",
"soft",
"solar",
"splendid",
"spruce",
"starry",
"steadfast",
"steady",
"sterling",
"stone",
"sturdy",
"sublime",
"summer",
"sunlit",
"sunny",
"supple",
"sweetbay",
"swift",
"tawny",
"teal",
"tender",
"terra",
"thistle",
"thrushlike",
"tidal",
"tinder",
"tinkling",
"topaz",
"torrid",
"tranquil",
"trillium",
"twilight",
"umber",
"valley",
"velvet",
"vernal",
"verdant",
"vesper",
"vibrant",
"violet",
"vivid",
"warm",
"warmer",
"weathered",
"westwind",
"whispered",
"wildflower",
"willow",
"winterly",
"windy",
"winter",
"wisp",
"withered",
"witty",
"woodland",
"woven",
"wren",
"wry",
"yarrow",
"yonder",
"zen",
"zephyr",
];
const NOUNS: &[&str] = &[
"anchor",
"ash",
"aspen",
"atlas",
"aurora",
"badger",
"bark",
"bay",
"bayou",
"beacon",
"beaver",
"bell",
"birch",
"bison",
"blossom",
"bough",
"branch",
"breeze",
"briar",
"brook",
"bud",
"bunting",
"burrow",
"butte",
"caldera",
"camellia",
"canyon",
"cardinal",
"caribou",
"cedar",
"chime",
"chinook",
"cinder",
"cirrus",
"cliff",
"cloudburst",
"comet",
"compass",
"copper",
"coral",
"cove",
"creek",
"crest",
"cricket",
"crow",
"cumulus",
"cyclone",
"cypress",
"dale",
"delta",
"dew",
"dewdrop",
"dolphin",
"dove",
"dragonfly",
"dune",
"dusk",
"eagle",
"ember",
"fern",
"field",
"finch",
"fjord",
"flame",
"flax",
"fleck",
"fog",
"foam",
"forest",
"fox",
"frost",
"garnet",
"gander",
"geode",
"geyser",
"glade",
"gleam",
"glen",
"glimmer",
"gorge",
"grove",
"hare",
"harbor",
"haze",
"headland",
"hearth",
"heath",
"heron",
"hollow",
"hummingbird",
"ibis",
"iris",
"ivy",
"jasmine",
"jasper",
"jay",
"juniper",
"kelp",
"kestrel",
"kettle",
"kingfisher",
"knoll",
"lagoon",
"lake",
"lantern",
"lark",
"laurel",
"leaf",
"leaflet",
"ledge",
"lichen",
"lily",
"linden",
"lion",
"loft",
"loon",
"lotus",
"lupin",
"lynx",
"magnolia",
"magpie",
"maple",
"marmot",
"marsh",
"meadow",
"meadowlark",
"mesa",
"mist",
"moor",
"moss",
"moth",
"mountain",
"narwhal",
"nettle",
"nightingale",
"nimbus",
"oak",
"ocean",
"ochre",
"opal",
"orchard",
"orchid",
"oriole",
"otter",
"owl",
"palm",
"pelican",
"petal",
"pine",
"pinion",
"plain",
"plateau",
"plover",
"pond",
"poppy",
"prairie",
"prism",
"puffin",
"quartz",
"quill",
"raindrop",
"rapid",
"raven",
"ravine",
"redwood",
"reef",
"ridge",
"river",
"robin",
"rook",
"rosemary",
"rowan",
"sable",
"saffron",
"sage",
"salmon",
"sand",
"sandbar",
"sandpiper",
"sapling",
"savanna",
"scrub",
"sea",
"shadow",
"shale",
"shard",
"sheaf",
"shore",
"shrub",
"sky",
"slate",
"snowdrop",
"snowfall",
"snowflake",
"sparrow",
"spindle",
"spire",
"spring",
"sprout",
"spruce",
"squall",
"starling",
"starshine",
"steppe",
"stone",
"stratus",
"summit",
"swallow",
"swift",
"tarn",
"tern",
"thaw",
"thicket",
"thistle",
"thrush",
"tide",
"tideline",
"tinder",
"topaz",
"tournesol",
"trillium",
"trout",
"tundra",
"twilight",
"twig",
"valley",
"vesper",
"vine",
"violet",
"wallaby",
"warbler",
"wave",
"weasel",
"whirlwind",
"willow",
"wisp",
"wolf",
"wood",
"wren",
"yarrow",
"yew",
"zephyr",
];
const EMOJIS: &[&str] = &[
"🦊", "🐺", "🐻", "🐅", "🐆", "🦓", "🦒", "🦌", "🦘", "🐇", "🦔", "🦣", "🦏", "🐈", "🐱", "🐶",
"🐰", "🦦", "🦥", "🦡", "🦨", "🦄", "🐴", "🐗", "🐘", "🦬", "🦫", "🐪", "🦙", "🐭", "🐹", "🐀",
"🦅", "🦉", "🦢", "🦩", "🐧", "🦃", "🦚", "🦜", "🦤", "🦆", "🐓", "🐔", "🐦", "🪶",
"🐊", "🦎", "🐍", "🐢", "🦕", "🦖", "🐸", "🐙", "🐬", "🐳", "🐋", "🐡", "🦈", "🦭", "🐟", "🐠", "🦀", "🦞", "🦐", "🐚",
"🐝", "🦋", "🐌", "🐞", "🦗", "🕷", "🦂", "🌲", "🌳", "🌴", "🌵", "🌱", "🌿", "🍃", "🍀", "🍁", "🍂", "🌷", "🌸", "🌺", "🌻", "🌼", "🌹", "🪻", "🪷", "🍄", "🍇", "🍈", "🍉", "🍊", "🍋", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", "🍓", "🫐", "🥝",
"🌊", "🌋", "🌙", "🌟", "🌈", "🔥", "❄", "💧", "⚡", "☀", "☁", "⛄",
"💎", "🪄", "🔮", "🧿", "🌠", "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻", "🥁", "🪕", "🪈", "⚓", "🧭", "🏺", "🪴", "🗿", "🛡", "🗝", "🎲", "🎭", "🎨", "🎯", "🪐",
];
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashSet;
#[test]
fn deterministic_same_did() {
let a = Character::from_did("did:wire:paul-a1b2c3d4");
let b = Character::from_did("did:wire:paul-a1b2c3d4");
assert_eq!(a, b);
}
#[test]
fn different_dids_differ() {
let a = Character::from_did("did:wire:paul-a1b2c3d4");
let b = Character::from_did("did:wire:paul-e5f6a7b8");
assert_ne!(a, b);
}
#[test]
fn nickname_is_hyphenated_pair() {
let c = Character::from_did("did:wire:test-deadbeef");
let parts: Vec<&str> = c.nickname.split('-').collect();
assert_eq!(parts.len(), 2);
assert!(parts[0].chars().all(|ch| ch.is_ascii_lowercase()));
assert!(parts[1].chars().all(|ch| ch.is_ascii_lowercase()));
}
#[test]
fn emoji_is_in_curated_set() {
let c = Character::from_did("did:wire:test-cafebabe");
assert!(EMOJIS.contains(&c.emoji.as_str()));
}
#[test]
fn palette_hex_is_well_formed() {
let c = Character::from_did("did:wire:test-12345678");
assert!(c.palette.primary_hex.starts_with('#'));
assert_eq!(c.palette.primary_hex.len(), 7);
assert!(c.palette.accent_hex.starts_with('#'));
assert_eq!(c.palette.accent_hex.len(), 7);
}
#[test]
fn ansi256_in_cube_range() {
let c = Character::from_did("did:wire:test-87654321");
assert!((16..=231).contains(&c.palette.ansi256_primary));
assert!((16..=231).contains(&c.palette.ansi256_accent));
}
#[test]
fn short_format() {
let c = Character::from_did("did:wire:fixed-seed-here");
let short = c.short();
assert!(short.contains(&c.emoji));
assert!(short.contains(&c.nickname));
assert_eq!(short, format!("{} {}", c.emoji, c.nickname));
}
#[test]
fn colored_includes_ansi_escape() {
let c = Character::from_did("did:wire:colored-test");
let colored = c.colored();
assert!(colored.starts_with("\x1b[38;5;"));
assert!(colored.ends_with("\x1b[0m"));
assert!(colored.contains(&c.nickname));
}
#[test]
fn no_nickname_collisions_10k_samples() {
let mut chars: HashSet<(String, String, String)> = HashSet::new();
let mut collisions = 0;
for i in 0..10_000 {
let did = format!("did:wire:test-{:08x}", i);
let c = Character::from_did(&did);
let key = (
c.nickname.clone(),
c.emoji.clone(),
c.palette.primary_hex.clone(),
);
if !chars.insert(key) {
collisions += 1;
}
}
assert!(
collisions < 100,
"saw {collisions} character-triple collisions in 10k samples (>1%)"
);
}
#[test]
fn word_lists_have_expected_size() {
assert!(ADJECTIVES.len() >= 100, "adjective list too small");
assert!(NOUNS.len() >= 100, "noun list too small");
assert!(EMOJIS.len() >= 50, "emoji list too small");
}
#[test]
fn no_duplicate_words() {
let adj_set: HashSet<&&str> = ADJECTIVES.iter().collect();
assert_eq!(adj_set.len(), ADJECTIVES.len(), "duplicate adjective");
let noun_set: HashSet<&&str> = NOUNS.iter().collect();
assert_eq!(noun_set.len(), NOUNS.len(), "duplicate noun");
let emoji_set: HashSet<&&str> = EMOJIS.iter().collect();
assert_eq!(emoji_set.len(), EMOJIS.len(), "duplicate emoji");
}
#[test]
fn hsl_to_rgb_known_values() {
let (r, g, b) = hsl_to_rgb(0.0, 1.0, 0.5);
assert_eq!(r, 255);
assert_eq!(g, 0);
assert_eq!(b, 0);
let (r, g, b) = hsl_to_rgb(120.0, 1.0, 0.5);
assert_eq!(r, 0);
assert_eq!(g, 255);
assert_eq!(b, 0);
let (r, g, b) = hsl_to_rgb(240.0, 1.0, 0.5);
assert_eq!(r, 0);
assert_eq!(g, 0);
assert_eq!(b, 255);
}
#[test]
fn sanitize_strips_ansi_escape() {
let out = sanitize_display_text("\x1b]0;owned\x07");
assert!(!out.contains('\x1b'), "ESC must be stripped: {out:?}");
assert!(!out.contains('\x07'), "BEL must be stripped: {out:?}");
assert_eq!(out, "]0;owned");
let out2 = sanitize_display_text("\x1b[2J\x1b[H");
assert!(!out2.contains('\x1b'));
assert_eq!(out2, "[2J[H");
assert_eq!(sanitize_display_text("hello\nworld"), "helloworld");
assert_eq!(sanitize_display_text("a\tb\x7fc"), "abc");
}
#[test]
fn sanitize_preserves_unicode_emoji_and_text() {
assert_eq!(
sanitize_display_text("🦊 foxtrot-meadow"),
"🦊 foxtrot-meadow"
);
assert_eq!(sanitize_display_text("café résumé"), "café résumé");
}
#[test]
fn sanitize_caps_length() {
let long = "a".repeat(200);
let out = sanitize_display_text(&long);
assert_eq!(out.chars().count(), MAX_DISPLAY_CHARS);
}
#[test]
fn from_card_with_empty_did_returns_unknown_sentinel() {
let card = json!({"handle": "broken"});
let c = Character::from_card(&card);
assert_eq!(c.nickname, "unknown-peer");
assert_eq!(c.emoji, "❓");
}
#[test]
fn from_card_with_null_did_returns_unknown_sentinel() {
let card = json!({"did": null, "handle": "broken"});
let c = Character::from_card(&card);
assert_eq!(c.nickname, "unknown-peer");
}
#[test]
fn from_card_strips_escape_from_published_nickname() {
let card = json!({
"did": "did:wire:malicious-deadbeef",
"display": {"nickname": "\x1b]0;OWNED\x07evil", "emoji": "🦊"},
});
let c = Character::from_card(&card);
assert!(!c.nickname.contains('\x1b'));
assert!(!c.nickname.contains('\x07'));
assert!(c.nickname.contains("OWNED")); assert_eq!(c.emoji, "🦊");
}
#[test]
fn from_card_with_published_override_uses_it() {
let card = json!({
"did": "did:wire:friend-12345678",
"display": {"nickname": "the-forge", "emoji": "🔨"},
});
let c = Character::from_card(&card);
assert_eq!(c.nickname, "the-forge");
assert_eq!(c.emoji, "🔨");
}
#[test]
fn from_card_without_display_falls_back_to_did() {
let card = json!({"did": "did:wire:friend-12345678"});
let c = Character::from_card(&card);
let auto = Character::from_did("did:wire:friend-12345678");
assert_eq!(c, auto);
}
#[test]
fn rgb_to_ansi256_matches_cube() {
assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
assert_eq!(rgb_to_ansi256(0, 255, 0), 46);
}
}